Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c012a9e442 | ||
|
|
5a0605741b | ||
|
|
ef77deff5f | ||
|
|
4ffce253f2 | ||
|
|
8e28283dad | ||
|
|
a6f1a6dbeb | ||
|
|
87af68f550 | ||
|
|
94fdbf0fca | ||
|
|
75f9e5ae6e | ||
|
|
2030a81327 | ||
|
|
4c42a708f7 | ||
|
|
a530a0d914 | ||
|
|
c7d0a82079 | ||
|
|
09323ddb45 | ||
|
|
c6eeac35c1 | ||
|
|
755576de8a | ||
|
|
c3a1712069 | ||
|
|
92c3588099 | ||
|
|
df7f7f0769 | ||
|
|
1e88839575 | ||
|
|
b90554561f | ||
|
|
b3098f8739 | ||
|
|
21072a23de | ||
|
|
840b4b7dc5 | ||
|
|
e85300234f | ||
|
|
e3d42d8be1 | ||
|
|
f220c7108b | ||
|
|
3e997dab43 | ||
|
|
29667e63e8 | ||
|
|
7a9cb2cb22 | ||
|
|
1dc4c27dc1 | ||
|
|
0a0bac2bf2 | ||
|
|
fe1fc30be5 | ||
|
|
cc83e01c57 | ||
|
|
4a5d897782 | ||
|
|
c983987c64 | ||
|
|
05dc20a70b | ||
|
|
198ead4015 | ||
|
|
57f7bd3d7e | ||
|
|
1a91bb324d | ||
|
|
b5e54cb802 | ||
|
|
3385209c6f | ||
|
|
d23c2efaec | ||
|
|
22f2e0c9cc | ||
|
|
1f85a8caa3 | ||
|
|
c0cbcadf3d | ||
|
|
d1557a87fe | ||
|
|
5112855b83 | ||
|
|
441f03d093 | ||
|
|
4711f9474a | ||
|
|
0625125ed7 | ||
|
|
92b6225a72 | ||
|
|
5aace9b5b3 | ||
|
|
5fcbee028e | ||
|
|
a2d646a75e | ||
|
|
3363181a01 | ||
|
|
84f42f6f8a | ||
|
|
5189554a6a | ||
|
|
dcf03e3378 | ||
|
|
a0ae8b2111 | ||
|
|
587cbc3b01 | ||
|
|
980a50c1d9 | ||
|
|
3a023f3e25 | ||
|
|
f9c1c3cb24 | ||
|
|
2908a77850 | ||
|
|
bbf2dba092 | ||
|
|
89dfc62c2f | ||
|
|
1f36693c8f | ||
|
|
b377c882d6 | ||
|
|
3458117357 | ||
|
|
750f5f5a61 | ||
|
|
7a078b1c04 | ||
|
|
91e8467e4f | ||
|
|
0966dfa2a6 | ||
|
|
a09e3c856b | ||
|
|
1372094f8a | ||
|
|
e8e0b92d87 | ||
|
|
25dfbcce42 | ||
|
|
d78a4c2e52 | ||
|
|
8ba59701b4 | ||
|
|
358ed2f54f | ||
|
|
ecd78b9ee6 | ||
|
|
4ee855bb27 | ||
|
|
3eaca26246 | ||
|
|
464a4d50c1 | ||
|
|
0e0d8bd114 | ||
|
|
a7bb67c452 | ||
|
|
c2ad804fed | ||
|
|
7dd4043afe | ||
|
|
35ee88aa18 | ||
|
|
47bdbf981d | ||
|
|
b89efb55c1 | ||
|
|
27af5e19b7 | ||
|
|
de8cdd0b03 | ||
|
|
a53c79cb55 | ||
|
|
b65f1696b2 | ||
|
|
8819ff787c | ||
|
|
febcffefd6 | ||
|
|
836513ddfe | ||
|
|
0ec1882697 | ||
|
|
507e8e32d2 | ||
|
|
491b3f6212 | ||
|
|
111a6f1a94 | ||
|
|
43e2144090 | ||
|
|
8e45f2c275 | ||
|
|
86ba1bda5c | ||
|
|
93b2ae343c | ||
|
|
7dd50a3ca4 | ||
|
|
2e28a69c04 | ||
|
|
69c1440cbe | ||
|
|
12487794a3 | ||
|
|
8fe31545ea | ||
|
|
4c18322a34 | ||
|
|
02172f9a7e | ||
|
|
a0b4a69452 | ||
|
|
c83b803f63 | ||
|
|
db80b75814 | ||
|
|
31bc886c7a | ||
|
|
ed9ec7748d | ||
|
|
79d583ea0f | ||
|
|
162bb421f1 | ||
|
|
54f50f9518 | ||
|
|
a0d11f1c22 | ||
|
|
dddb2d0584 | ||
|
|
b19277ae7a | ||
|
|
e06c85eb9e | ||
|
|
cb7e3a2711 | ||
|
|
080ff048c6 | ||
|
|
3d51bde9dd | ||
|
|
d3ed8ffb05 | ||
|
|
072139353f | ||
|
|
08f669acad | ||
|
|
d7ec7f486a | ||
|
|
d9824de4a5 | ||
|
|
fbaba5fecd | ||
|
|
0bf150633c | ||
|
|
c1fd58c6ce | ||
|
|
90948833e0 | ||
|
|
73987bdbae | ||
|
|
7df37d4b15 | ||
|
|
52c12e2ad7 | ||
|
|
1fcc7acf84 | ||
|
|
3aab588b8b | ||
|
|
f074e10442 | ||
|
|
05abcec6c2 | ||
|
|
012119e2c2 | ||
|
|
f5a84f5f74 | ||
|
|
910d437819 | ||
|
|
0e4ea5e099 | ||
|
|
ed2541170a | ||
|
|
0bbdd463ea | ||
|
|
65fb1112ab | ||
|
|
de8b7b6321 | ||
|
|
70c5c9b26f | ||
|
|
f4b6d1dbff | ||
|
|
2d5dfb2137 | ||
|
|
ad84a52ed2 | ||
|
|
5758dc7911 | ||
|
|
29af2aef91 | ||
|
|
696b64c30f | ||
|
|
a0f48c7207 | ||
|
|
9e38fe866d | ||
|
|
c26b142630 | ||
|
|
6bb3b04e60 | ||
|
|
b1180bf987 | ||
|
|
786121c21a | ||
|
|
e223a6256a | ||
|
|
fe557054ef | ||
|
|
fc85550534 | ||
|
|
3d8864c313 | ||
|
|
2443746b35 | ||
|
|
8b12cdd5c7 | ||
|
|
6b9e669c7e | ||
|
|
eb66b86e26 | ||
|
|
f78680e091 | ||
|
|
13b506150c | ||
|
|
bdd84a491d | ||
|
|
904dc3381b | ||
|
|
9dcbb9c9a6 | ||
|
|
8df4ea040c | ||
|
|
e8cada94d0 | ||
|
|
f8e0be970e | ||
|
|
94c98e0c0b | ||
|
|
ffb71de6e1 | ||
|
|
a11b971dae | ||
|
|
6bfa0b410a | ||
|
|
29e7a4ca31 | ||
|
|
794122a302 | ||
|
|
860707efa6 | ||
|
|
a84a8339ae | ||
|
|
f89545a66a | ||
|
|
0f99a1c687 | ||
|
|
9fc8e27933 | ||
|
|
b73a3bb976 | ||
|
|
7d689fb6a2 | ||
|
|
0b6b6c966c | ||
|
|
1fbb5581ea | ||
|
|
f6d227d8cb | ||
|
|
22f1006f71 | ||
|
|
1c16972cf2 | ||
|
|
b0d0fc9900 | ||
|
|
321add294b | ||
|
|
4c91f9b731 | ||
|
|
8409101483 | ||
|
|
e0d5adba23 | ||
|
|
06b98f01c7 | ||
|
|
2188d891ff | ||
|
|
1fc9d1e8d5 | ||
|
|
94a10a1bf3 | ||
|
|
14e2d25e68 | ||
|
|
4a60674530 | ||
|
|
ddde08fb4c | ||
|
|
a8f6be3bee | ||
|
|
872dfdb421 | ||
|
|
854ced2888 | ||
|
|
c8bd641e27 | ||
|
|
48cae3eb1d |
No files matched your search
@@ -1,2 +1,13 @@
|
||||
# Every change to FieldCraft needs the maintainer's review before it merges.
|
||||
* @jyviko
|
||||
|
||||
# Release and supply-chain paths, named explicitly so they stay owner-reviewed if the rule above
|
||||
# is ever relaxed: workflows and secrets use, packaging and signing, the updater, dependencies,
|
||||
# licences.
|
||||
/.github/ @jyviko
|
||||
/packaging/ @jyviko
|
||||
/crates/update/ @jyviko
|
||||
/Cargo.toml @jyviko
|
||||
/Cargo.lock @jyviko
|
||||
/ATTRIBUTION.md @jyviko
|
||||
/deny.toml @jyviko
|
||||
@@ -41,6 +41,6 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version and platform
|
||||
placeholder: "v0.5.7 (or commit), macOS 15 arm64"
|
||||
placeholder: "v0.6.0 (or commit), macOS 15 arm64"
|
||||
validations:
|
||||
required: true
|
||||
@@ -21,6 +21,8 @@ modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft
|
||||
To start with a sample model, run the app with `--sample cantilever` (macOS:
|
||||
`/Applications/FieldCraft.app/Contents/MacOS/FieldCraft --sample cantilever`).
|
||||
|
||||
**Updates.** {update_signing}
|
||||
|
||||
**Signing.** {macos_signing} The Linux and Windows builds are not signed; on Windows, SmartScreen
|
||||
may say "Windows protected your PC": choose **More info**, then **Run anyway**.
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ name: CI
|
||||
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
|
||||
# the run, so the review sees it; so do Dependabot's (.github/dependabot.yml), which nobody tests
|
||||
# locally. The heavy job stays off pull requests.
|
||||
#
|
||||
# What runs when (docs/releasing.md, "What CI runs when"): the `plan` job picks a base, the last
|
||||
# `develop` commit whose CI run passed (a pull request: its merge base; nightly and manual runs:
|
||||
# none), and `cargo xtask affected` maps the change since it to crates. `cargo xtask ci --affected`
|
||||
# then lints and tests those crates and their dependents only; the heavy and oracle jobs run when
|
||||
# the change reaches the solvers, the oracle or everything (Cargo.toml/Cargo.lock/xtask/workflows).
|
||||
# Nightly and manual runs, and every release (release.yml), run all of it.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -26,7 +33,7 @@ on:
|
||||
- 'docs/features.md'
|
||||
- 'docs/oracle.md'
|
||||
schedule:
|
||||
# Nightly, on the default branch (develop): the heavy tests.
|
||||
# Nightly, on the default branch (develop): the whole gate, the heavy tests and the oracle.
|
||||
- cron: '23 3 * * *'
|
||||
# Weekly: the oracle on macOS too, so results that differ by platform show up in CI (the
|
||||
# fixtures are often blessed on a Mac; #115).
|
||||
@@ -37,7 +44,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# A newer merge cancels the run still going for the one before it, so a burst of merges is tested
|
||||
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
|
||||
# once, at the tip; nothing is missed, since the tip's run tests everything since the last green
|
||||
# run. Nightly and manual runs are grouped apart and never cancelled by a merge.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
@@ -46,13 +54,75 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan (affected crates)
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
# To find the last green run on develop.
|
||||
actions: read
|
||||
outputs:
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
everything: ${{ steps.affected.outputs.everything }}
|
||||
solver: ${{ steps.affected.outputs.solver }}
|
||||
wasm: ${{ steps.affected.outputs.wasm }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: plan
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- name: Pick the base
|
||||
id: base
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
PR_BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
base=""
|
||||
case "$EVENT" in
|
||||
push)
|
||||
# The newest develop commit whose push run passed and that this commit contains.
|
||||
# Runs that were cancelled or failed are skipped, so their changes are tested here.
|
||||
for sha in $(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?branch=develop&event=push&status=success&per_page=100" --jq '.workflow_runs[].head_sha'); do
|
||||
if [ "$sha" != "$GITHUB_SHA" ] && git merge-base --is-ancestor "$sha" HEAD; then
|
||||
base="$sha"
|
||||
break
|
||||
fi
|
||||
done
|
||||
;;
|
||||
pull_request)
|
||||
base="$(git merge-base "origin/$PR_BASE" HEAD || true)"
|
||||
;;
|
||||
esac
|
||||
# Nightly and manual runs, or no green run to start from: everything.
|
||||
echo "base=$base" >>"$GITHUB_OUTPUT"
|
||||
echo "### Base: \`${base:-none (everything)}\`" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: cargo xtask affected
|
||||
id: affected
|
||||
env:
|
||||
BASE: ${{ steps.base.outputs.base }}
|
||||
run: |
|
||||
if [ -n "$BASE" ]; then
|
||||
cargo xtask affected --base "$BASE" --github-output | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ci:
|
||||
name: cargo xtask ci
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
needs: plan
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
@@ -60,18 +130,18 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
# A dependency update changes THIRD-PARTY-LICENSES.txt, which Dependabot cannot regenerate:
|
||||
# regenerate it here so the gate checks everything else, and dependabot-licences.yml commits
|
||||
# it to the pull request.
|
||||
- name: Regenerate THIRD-PARTY-LICENSES.txt (Dependabot)
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
run: cargo xtask licences
|
||||
- run: cargo xtask ci
|
||||
- name: cargo xtask ci (the affected crates, or everything)
|
||||
env:
|
||||
BASE: ${{ needs.plan.outputs.base }}
|
||||
run: |
|
||||
if [ -n "$BASE" ]; then cargo xtask ci --affected "$BASE"; else cargo xtask ci; fi
|
||||
|
||||
heavy:
|
||||
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
|
||||
# The heavy verification and corpus tests (#[ignore]d) and the doc tests, when the change
|
||||
# reaches them.
|
||||
name: cargo xtask test --heavy
|
||||
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
|
||||
needs: plan
|
||||
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -85,9 +155,11 @@ jobs:
|
||||
oracle:
|
||||
# Verification and validation (docs/verification.md): every case against its reference and
|
||||
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
|
||||
# run here too while the whole set stays within a few minutes; `--quick` skips them.
|
||||
# run here too while the whole set stays within a few minutes; `--quick` skips them. It runs
|
||||
# when the change reaches the solvers or the oracle's package.
|
||||
name: cargo xtask oracle
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
needs: plan
|
||||
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -120,7 +192,7 @@ jobs:
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
advisories:
|
||||
name: Dependency advisories and sources
|
||||
name: Dependency licences, advisories and sources
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
@@ -129,4 +201,4 @@ jobs:
|
||||
- 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
|
||||
- run: cargo deny --locked check --warn unmaintained advisories bans licenses sources
|
||||
@@ -1,40 +0,0 @@
|
||||
# Dependabot's Cargo updates change THIRD-PARTY-LICENSES.txt (`cargo xtask licences`), which
|
||||
# Dependabot cannot regenerate: this commits the regenerated file to its pull request. CI's own
|
||||
# run on the pull request regenerates it too, so its result covers everything else.
|
||||
name: Dependabot licences
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
paths: [Cargo.lock]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dependabot-licences-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
licences:
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo xtask licences
|
||||
- name: Commit the regenerated file
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt; then
|
||||
echo "THIRD-PARTY-LICENSES.txt is up to date"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# [skip ci]: the pull request's CI run already regenerated this file and tested the
|
||||
# tree with it; a bot-authored commit would otherwise wait for a maintainer's approval.
|
||||
git commit -m "deps: regenerate THIRD-PARTY-LICENSES.txt [skip ci]" -- THIRD-PARTY-LICENSES.txt
|
||||
git push origin "HEAD:${GITHUB_HEAD_REF}"
|
||||
@@ -33,6 +33,10 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# POSTHOG_KEY lives in the `release` environment (main only, docs/releasing.md). The deploy job
|
||||
# already uses github-pages and a job has one environment, so the build job reads the key: on
|
||||
# main it runs in `release`, elsewhere in no environment and with an empty key.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -53,6 +57,10 @@ jobs:
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/fieldcraft-web
|
||||
env:
|
||||
# Usage analytics only in the deployed build (main), and only with the user's consent
|
||||
# (docs/privacy.md); develop builds get an empty key, which sends nothing.
|
||||
FIELDCRAFT_POSTHOG_KEY: ${{ github.ref == 'refs/heads/main' && secrets.POSTHOG_KEY || '' }}
|
||||
run: trunk build --release --public-url /fieldcraft/
|
||||
- name: Bundle size
|
||||
run: |
|
||||
|
||||
@@ -3,19 +3,25 @@
|
||||
#
|
||||
# A push to `main` (fast-forwarded from a verified `develop` commit; main holds released code only)
|
||||
# builds every package and creates or updates the
|
||||
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt and generated notes. The
|
||||
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt (signed with the release key
|
||||
# into SHA256SUMS.txt.minisig when the FIELDCRAFT_SIGNING_KEY secret exists) and generated notes. The
|
||||
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
|
||||
# pre-release. A person verifies the draft and publishes it; this workflow never publishes,
|
||||
# and pushes to develop or tags never start it.
|
||||
# pre-release. Every package, and the release itself, carries THIRD-PARTY-LICENSES.txt, generated
|
||||
# here by `cargo xtask licences` (it is not committed). A person verifies the draft and publishes
|
||||
# it; this workflow never publishes, and pushes to develop or tags never start it.
|
||||
#
|
||||
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
|
||||
# Linux x86_64 (glibc 2.35+) .tar.gz and .AppImage packaging/linux
|
||||
# Linux x86_64 (glibc 2.35+) .tar.gz, .AppImage, .deb and .rpm packaging/linux
|
||||
# Windows x64 .zip with fieldcraft.exe and fieldcraft-cli.exe packaging/windows
|
||||
#
|
||||
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
|
||||
# otherwise (the release notes say which). Linux and Windows builds are not signed.
|
||||
#
|
||||
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
|
||||
# Before anything is packaged, the verify jobs run everything CI can: the full `cargo xtask ci`,
|
||||
# `cargo xtask test --heavy` and `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's
|
||||
# CI tests only what each change affects (ci.yml), so this is where a release proves all of it.
|
||||
#
|
||||
# Dry run: `gh workflow run release.yml --ref <branch>` verifies and builds everything and uploads the
|
||||
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
|
||||
# the draft.
|
||||
name: Release
|
||||
@@ -69,9 +75,65 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The full gate, the heavy tests and the oracle on Linux and macOS: every packaging job needs
|
||||
# them, so a release (or a dry run) never builds from a commit that fails any of them.
|
||||
verify-ci:
|
||||
name: Verify (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
|
||||
with:
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask ci
|
||||
|
||||
verify-heavy:
|
||||
name: Verify (cargo xtask test --heavy)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask test --heavy
|
||||
|
||||
verify-oracle:
|
||||
name: Verify (cargo xtask oracle, ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
cache: release-oracle-linux
|
||||
- os: macos-14
|
||||
cache: release-oracle-macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ${{ matrix.cache }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
@@ -88,6 +150,8 @@ jobs:
|
||||
key: release-macos-universal
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
|
||||
run: cargo xtask licences
|
||||
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -97,6 +161,8 @@ jobs:
|
||||
- name: Build, sign, notarize and package (the dmg is mounted and checked)
|
||||
id: package
|
||||
env:
|
||||
# The analytics project key (docs/privacy.md), built in; never printed.
|
||||
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
@@ -113,39 +179,87 @@ jobs:
|
||||
|
||||
linux:
|
||||
name: Linux x86_64
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
# The .deb and .rpm builders (both MIT), pinned.
|
||||
CARGO_DEB_VERSION: 3.8.0
|
||||
CARGO_GENERATE_RPM_VERSION: 0.21.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils lintian rpm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-x86_64
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Build and package (tar.gz, AppImage)
|
||||
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
|
||||
run: cargo xtask licences
|
||||
- name: Install cargo-deb and cargo-generate-rpm
|
||||
run: |
|
||||
cargo install --locked --version "$CARGO_DEB_VERSION" cargo-deb
|
||||
cargo install --locked --version "$CARGO_GENERATE_RPM_VERSION" cargo-generate-rpm
|
||||
- name: Build and package (tar.gz, AppImage, deb, rpm)
|
||||
env:
|
||||
# The analytics project key (docs/privacy.md), built in; never printed.
|
||||
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
list="$(tar -tzf dist/release/*.tar.gz)"
|
||||
base="fieldcraft-$VERSION-linux-x86_64"
|
||||
deb="dist/release/fieldcraft_${VERSION}_amd64.deb"
|
||||
rpm="dist/release/fieldcraft-$VERSION.x86_64.rpm"
|
||||
list="$(tar -tzf "dist/release/$base.tar.gz")"
|
||||
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
|
||||
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
|
||||
grep -Fx -- "fieldcraft-$VERSION-linux-x86_64/$f" <<<"$list"
|
||||
grep -Fx -- "$base/$f" <<<"$list"
|
||||
done
|
||||
# The .deb and .rpm carry the tar.gz's files under /usr (the .deb adds its copyright
|
||||
# file); their lists live in apps/fieldcraft/Cargo.toml, so a drift fails here.
|
||||
files() { grep -v '/$' | sed -E "s#^(\./|/|$base/)##; s#^usr/##" | grep -vx 'share/doc/fieldcraft/copyright' | sort; }
|
||||
diff <(files <<<"$list") <(dpkg-deb --fsys-tarfile "$deb" | tar -t | files)
|
||||
diff <(files <<<"$list") <(rpm -qpl "$rpm" | files)
|
||||
dpkg-deb --info "$deb"
|
||||
rpm -qpi "$rpm"
|
||||
echo "rpm requires:"; rpm -qpR "$rpm"
|
||||
# The release notes live on GitHub, so the package has no Debian changelog (a native
|
||||
# package built upstream, not by a distribution).
|
||||
lintian --info --fail-on error --suppress-tags no-changelog "$deb"
|
||||
ldd target/release/fieldcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- name: Install the .deb and .rpm in containers
|
||||
run: |
|
||||
# The oldest supported Ubuntu (the build's glibc) and a current Fedora. Recommended and
|
||||
# weak dependencies (GPU drivers) are skipped to keep it quick; the hard ones must resolve.
|
||||
# Both images leave out documentation when installing (dpkg path-exclude, dnf nodocs), so
|
||||
# the licences are checked in the package's file list rather than on disk.
|
||||
docker run --rm -v "$PWD/dist/release:/dist:ro" ubuntu:22.04 bash -euxc '
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /dist/*.deb
|
||||
fieldcraft-cli --version
|
||||
fieldcraft --version
|
||||
dpkg -L fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt'
|
||||
docker run --rm -v "$PWD/dist/release:/dist:ro" fedora:44 bash -euxc '
|
||||
dnf install -y -q --setopt=install_weak_deps=False /dist/*.rpm
|
||||
fieldcraft-cli --version
|
||||
fieldcraft --version
|
||||
rpm -ql fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt
|
||||
dnf install -y -q rpmlint
|
||||
rpmlint /dist/*.rpm || true'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-x86_64
|
||||
@@ -154,7 +268,10 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows x64
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -169,8 +286,13 @@ jobs:
|
||||
key: release-windows-x64
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
|
||||
run: cargo xtask licences
|
||||
- name: Build and package (zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
# The analytics project key (docs/privacy.md), built in; never printed.
|
||||
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
run: ./packaging/windows/package.ps1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -181,6 +303,9 @@ jobs:
|
||||
release:
|
||||
name: Draft release (or dry-run artifacts)
|
||||
needs: [version, macos, linux, windows]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -197,21 +322,58 @@ jobs:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Check the set, checksums and notes
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The same file every package carries, attached to the release on its own.
|
||||
- name: Generate THIRD-PARTY-LICENSES.txt
|
||||
run: cargo xtask licences && cp THIRD-PARTY-LICENSES.txt artifacts/
|
||||
- name: Check the set and write the checksums
|
||||
run: |
|
||||
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
|
||||
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
|
||||
"fieldcraft-$VERSION-windows-x64.zip"; do
|
||||
"fieldcraft_${VERSION}_amd64.deb" "fieldcraft-$VERSION.x86_64.rpm" \
|
||||
"fieldcraft-$VERSION-windows-x64.zip" THIRD-PARTY-LICENSES.txt; do
|
||||
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
|
||||
done
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
- name: Sign SHA256SUMS.txt with the release key (skipped without FIELDCRAFT_SIGNING_KEY)
|
||||
id: sign
|
||||
env:
|
||||
FIELDCRAFT_SIGNING_KEY: ${{ secrets.FIELDCRAFT_SIGNING_KEY }}
|
||||
# rsign2 (MIT), a minisign-compatible signer, pinned.
|
||||
RSIGN_VERSION: 0.6.7
|
||||
run: |
|
||||
if [ -z "$FIELDCRAFT_SIGNING_KEY" ]; then
|
||||
echo "::warning::FIELDCRAFT_SIGNING_KEY is not set: SHA256SUMS.txt is not signed, so the app cannot install this release itself (docs/releasing.md)"
|
||||
echo "signed=false" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
cargo install --locked --quiet --root "$RUNNER_TEMP/rsign" rsign2 --version "$RSIGN_VERSION"
|
||||
rsign="$RUNNER_TEMP/rsign/bin/rsign"
|
||||
key="$RUNNER_TEMP/release.key"
|
||||
(umask 077 && printf '%s\n' "$FIELDCRAFT_SIGNING_KEY" >"$key")
|
||||
"$rsign" sign -W -s "$key" -t "FieldCraft v$VERSION SHA256SUMS.txt" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
|
||||
rm -f "$key"
|
||||
# The signature must verify with the public key the app is built with.
|
||||
public="$(sed -n 's/^pub const PUBLIC_KEY: &str = "\(.*\)";$/\1/p' crates/update/src/lib.rs)"
|
||||
"$rsign" verify -q -P "$public" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
|
||||
echo "signed=true" >>"$GITHUB_OUTPUT"
|
||||
- name: Write the notes
|
||||
env:
|
||||
SIGNED: ${{ steps.sign.outputs.signed }}
|
||||
run: |
|
||||
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
|
||||
case "$MACOS_SIGNING" in
|
||||
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
|
||||
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
|
||||
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
|
||||
esac
|
||||
awk -v v="$VERSION" -v m="$macos" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); print }' \
|
||||
# The updates paragraph says whether the app can install this release itself.
|
||||
if [ "$SIGNED" = true ]; then
|
||||
updates="\`SHA256SUMS.txt\` is signed with the FieldCraft release key (\`SHA256SUMS.txt.minisig\`), so FieldCraft's Help › Check for Updates can verify and install this release."
|
||||
else
|
||||
updates="This release's \`SHA256SUMS.txt\` is **not signed**: FieldCraft's in-app updates can't verify it and won't install it; download it from this page instead."
|
||||
fi
|
||||
awk -v v="$VERSION" -v m="$macos" -v u="$updates" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); gsub(/\{update_signing\}/, u); print }' \
|
||||
.github/release-notes.md >notes.md
|
||||
cat notes.md >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload the dry-run artifacts
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
__pycache__/
|
||||
/THIRD-PARTY-LICENSES.txt
|
||||
@@ -119,17 +119,22 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
|
||||
Then remove your worktree and delete your local branch.
|
||||
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
|
||||
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
|
||||
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
|
||||
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
|
||||
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs,
|
||||
per merge on `develop`, `cargo xtask ci --affected <last green commit>` (fmt, assets,
|
||||
layers on the whole tree; clippy and tests on the crates the change reaches; wasm when one
|
||||
of them builds for it), the oracle and heavy tests when the change reaches the solvers or
|
||||
the oracle, and the dependency licences and advisories; a change to Cargo.toml, Cargo.lock,
|
||||
xtask or the workflows, the nightly run and every release run all of it (docs/releasing.md,
|
||||
"What CI runs when"). `cargo xtask affected --base origin/develop` shows what your branch
|
||||
reaches. The coordinator
|
||||
watches it and sends a failure back to the change that caused it, which is fixed forward
|
||||
at once.
|
||||
- **Keep the trunk gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
|
||||
(convergence studies, the whole corpus, cross-checks between solvers) is
|
||||
`#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative
|
||||
test left in the trunk gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
|
||||
test, benchmarks included, and the doc tests) on every merge to `develop`, nightly and on
|
||||
demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
|
||||
test, benchmarks included, and the doc tests) on merges to `develop` that reach a crate with
|
||||
heavy tests, nightly, before every release and on demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
|
||||
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
|
||||
test.
|
||||
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
|
||||
|
||||
@@ -23,12 +23,32 @@ is committed and listed here with its exports.
|
||||
| docs/media/examples/heated-bracket.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved heated-bracket sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/bracket-hex.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved bracket-hex sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/impact.png | FieldCraft contributors | Original: the impact sample as docs/media/examples/impact.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the impact sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/parts.png | FieldCraft contributors | Original: the parts sample as docs/media/examples/parts.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the parts sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/bracket-hex.png | FieldCraft contributors | Original: the bracket-hex sample as docs/media/examples/bracket-hex.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the bracket-hex sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/crank-arm.png | FieldCraft contributors | Original: the crank-arm sample as docs/media/examples/crank-arm.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-arm sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/crank-mirror.png | FieldCraft contributors | Original: the crank-mirror sample as docs/media/examples/crank-mirror.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-mirror sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/drop-test.png | FieldCraft contributors | Original: the drop-test sample as docs/media/examples/drop-test.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the drop-test sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/heated-bracket.png | FieldCraft contributors | Original: the heated-bracket sample as docs/media/examples/heated-bracket.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the heated-bracket sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/rubber-seal.png | FieldCraft contributors | Original: the rubber-seal sample as docs/media/examples/rubber-seal.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the rubber-seal sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
|
||||
| crates/engine/tests/designs/pi5-enclosure.json | FieldCraft contributors | Original SolidCraft recipe (solid.* commands) of an enclosure for a single-board computer, for thermal analysis: base, lid, heat sink, board, chips, connectors and the air inside | MIT OR Apache-2.0 |
|
||||
| crates/geometry/tests/designs/hollow-box.solidcraft, crates/geometry/tests/designs/hollow-box-post.solidcraft, crates/geometry/tests/designs/open-tray-post.solidcraft | FieldCraft contributors | Built with `solidcraft-cli run` from the recipes beside them (crates/geometry/tests/designs/*.json) | MIT OR Apache-2.0 |
|
||||
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.write.abaqus.svg, assets/icons/deck.write.abaqus.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.abaqus.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.write.lsdyna.svg, assets/icons/deck.write.lsdyna.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.lsdyna.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.write.nastran.svg, assets/icons/deck.write.nastran.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.nastran.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/diagnostics.bug_report.svg, assets/icons/diagnostics.bug_report.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/diagnostics.bug_report.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/edit.redo.svg, assets/icons/edit.redo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.redo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/edit.undo.svg, assets/icons/edit.undo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.undo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/file.open.svg, assets/icons/file.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -37,6 +57,8 @@ is committed and listed here with its exports.
|
||||
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.datum.axis.svg, assets/icons/geometry.datum.axis.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.axis.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.datum.point.svg, assets/icons/geometry.datum.point.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.point.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.import.mesh.svg, assets/icons/geometry.import.mesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.import.mesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.midsurface.svg, assets/icons/geometry.midsurface.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.midsurface.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -48,6 +70,8 @@ is committed and listed here with its exports.
|
||||
| assets/icons/interaction.contact.general.svg, assets/icons/interaction.contact.general.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.general.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.contact.pair.svg, assets/icons/interaction.contact.pair.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.pair.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.fsi.svg, assets/icons/interaction.fsi.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.block.svg, assets/icons/mesh.block.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.block.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.mirror.svg, assets/icons/mesh.mirror.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.mirror.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.mass.create.svg, assets/icons/model.mass.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.mass.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.rigid_wall.svg, assets/icons/interaction.rigid_wall.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.rigid_wall.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.spring.create.svg, assets/icons/model.spring.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.spring.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -56,22 +80,22 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.element.add.svg, assets/icons/mesh.element.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.element.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.decompose.svg, assets/icons/mesh.hex.decompose.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.decompose.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.dominant.svg, assets/icons/mesh.hex.dominant.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.dominant.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.blocks.svg, assets/icons/mesh.hex.blocks.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.blocks.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.mapped.svg, assets/icons/mesh.hex.mapped.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.mapped.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.cells.svg, assets/icons/mesh.cells.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.cells.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.generate.svg, assets/icons/mesh.hex.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.octree.svg, assets/icons/mesh.hex.octree.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.octree.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.polycube.svg, assets/icons/mesh.hex.polycube.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.polycube.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.sweep.svg, assets/icons/mesh.hex.sweep.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.sweep.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.import.orphan.svg, assets/icons/mesh.import.orphan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.import.orphan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.layers.svg, assets/icons/mesh.layers.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.layers.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.seed.svg, assets/icons/mesh.size.seed.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.seed.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -141,6 +165,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/ui.sun.svg, assets/icons/ui.sun.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.sun.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.views.svg, assets/icons/ui.views.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.views.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.zoom.svg, assets/icons/ui.zoom.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.zoom.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/view.show_all.svg, assets/icons/view.show_all.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/view.show_all.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (a hexahedral-meshed cube with a banded colour fringe on its faces on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (the fringe cube alone; no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
@@ -173,13 +198,16 @@ is committed and listed here with its exports.
|
||||
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
|
||||
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
|
||||
| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
|
||||
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft/Cargo.toml (the run-time dependency lists of `[package.metadata.deb]` and `[package.metadata.generate-rpm]`) | SolidCraft `packaging/linux/nfpm.yaml` | MIT OR Apache-2.0 |
|
||||
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
|
||||
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -431,6 +431,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -661,6 +667,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -731,6 +747,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -744,7 +770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -757,7 +783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -846,6 +872,12 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ct-codecs"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -890,6 +922,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1019,7 +1052,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1237,7 +1270,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1245,24 +1278,29 @@ dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-ui-egui",
|
||||
"fieldcraft-update",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"rfd",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-mcp",
|
||||
"fieldcraft-update",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1271,7 +1309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1284,16 +1322,18 @@ dependencies = [
|
||||
"fieldcraft-parallel",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-solver",
|
||||
"fieldcraft-update",
|
||||
"log",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1304,7 +1344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1312,10 +1352,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde_json",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-engine",
|
||||
@@ -1323,12 +1363,12 @@ dependencies = [
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
@@ -1336,12 +1376,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1351,7 +1391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1362,7 +1402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"num-bigint",
|
||||
@@ -1374,7 +1414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1383,14 +1423,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1400,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1412,7 +1452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1423,7 +1463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1434,17 +1474,36 @@ dependencies = [
|
||||
"fieldcraft-geometry",
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
"log",
|
||||
"png",
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.7"
|
||||
name = "fieldcraft-update"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"minisign",
|
||||
"minisign-verify",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fieldcraft-engine",
|
||||
@@ -1459,6 +1518,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1631,8 +1700,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1819,6 +1890,31 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -1864,6 +1960,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1882,6 +1980,15 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -2101,7 +2208,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -2132,6 +2239,24 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586"
|
||||
dependencies = [
|
||||
"ct-codecs",
|
||||
"getrandom 0.4.3",
|
||||
"rpassword",
|
||||
"scrypt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2230,6 +2355,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -2624,6 +2761,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -2682,6 +2825,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peniko"
|
||||
version = "0.6.1"
|
||||
@@ -2946,6 +3099,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3145,6 +3307,20 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.2.0"
|
||||
@@ -3160,6 +3336,27 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -3207,12 +3404,95 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3222,6 +3502,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -3234,6 +3523,40 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scrypt"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
|
||||
dependencies = [
|
||||
"pbkdf2",
|
||||
"salsa20",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3489,8 +3812,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3498,6 +3821,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -3505,8 +3829,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3522,8 +3846,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3531,10 +3855,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -3542,18 +3866,20 @@ dependencies = [
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3564,18 +3890,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"png",
|
||||
"serde",
|
||||
"solidcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"solidcraft-geom",
|
||||
@@ -3619,6 +3946,12 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
@@ -3661,6 +3994,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3907,7 +4250,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-meshalgo"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"array-macro",
|
||||
"derive_more",
|
||||
@@ -3957,7 +4300,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-shapeops"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"itertools 0.13.0",
|
||||
@@ -3972,7 +4315,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-stepio"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"truck-geometry",
|
||||
@@ -4012,6 +4355,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -4053,6 +4402,40 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.48.1"
|
||||
@@ -4072,6 +4455,12 @@ dependencies = [
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.28.0"
|
||||
@@ -4336,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4372,6 +4761,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4860,7 +5258,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -4986,7 +5384,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5117,6 +5515,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
@@ -5131,6 +5535,21 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.7"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -27,6 +27,7 @@ fieldcraft-testkit = { path = "crates/testkit" }
|
||||
fieldcraft-render = { path = "crates/render" }
|
||||
fieldcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
fieldcraft-mcp = { path = "crates/mcp" }
|
||||
fieldcraft-update = { path = "crates/update" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -34,9 +35,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
|
||||
# 3MF packages (docs/formats/tagged-mesh.md): the same versions SolidCraft uses.
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# 3MF packages (docs/formats/tagged-mesh.md). zip is ahead of SolidCraft (still on 3), so both are built.
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.42"
|
||||
|
||||
# content hashes and part checksums of .fieldcraft files (the version SolidCraft uses)
|
||||
sha2 = "0.11"
|
||||
@@ -58,13 +59,13 @@ rfd = "0.17"
|
||||
|
||||
# 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" }
|
||||
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
# Runs SolidCraft command scripts (the test corpus recipes); only behind fieldcraft-geometry's
|
||||
# `scripts` feature.
|
||||
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -128,10 +129,10 @@ debug = "line-tables-only"
|
||||
# reach a dependent workspace, so FieldCraft 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" }
|
||||
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
# egui-wgpu with SolidCraft's fixes (vendor/egui-wgpu there): MSAA clamped to what the device
|
||||
# supports and 1x on OpenGL adapters, so the requested 4x never fails pipeline validation at start.
|
||||
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
@@ -6,7 +6,7 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
|
||||
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,
|
||||
(generated by `cargo xtask licences`, in every release package). 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.
|
||||
|
||||
|
||||
@@ -98,17 +98,23 @@ Ready-to-solve samples: open one from **File ▸ Samples** (or the start screen)
|
||||
with `fieldcraft --sample NAME`, and press **Solve**. Each opens and solves within half a minute and is checked
|
||||
against a closed form or a reference solution ([docs/examples.md](docs/examples.md)).
|
||||
|
||||
| | | |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
| **Drop test** (`drop-test`): an aluminium bracket dropped 2 m onto a tilted floor; explicit dynamics, the strike's stress waves | **Rubber seal** (`rubber-seal`): a Mooney–Rivlin gasket ring squeezed 25 %; nonlinear static, large strain | **Heated bracket** (`heated-bracket`): a clamped steel bracket heated at one end; steady heat and thermal stress |
|
||||
|  | *No picture yet: the viewport does not draw flow results (M12).* |  |
|
||||
| **Hex-meshed bracket** (`bracket-hex`): the bracket split into 30 cells, meshed all-hex (hex20), von Mises | **Flow past a bracket** (`flow-past`): air past an L-bracket on the floor; lattice Boltzmann flow, the drag and the force on each face | **Bicycle crank arm** (`crank-arm`): an aluminium crank swept all-hex (hex20), a rider's weight on the pedal; root bending within 0.3 % of M/Z |
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/drop-test.png" alt="Drop test" width="100%"><br><b>Drop test</b> (<code>drop-test</code>): an aluminium bracket dropped 2 m onto a tilted floor; explicit dynamics, the strike's stress waves</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/rubber-seal.png" alt="Rubber seal" width="100%"><br><b>Rubber seal</b> (<code>rubber-seal</code>): a Mooney–Rivlin gasket ring squeezed 25 %; nonlinear static, large strain</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/heated-bracket.png" alt="Heated bracket" width="100%"><br><b>Heated bracket</b> (<code>heated-bracket</code>): a clamped steel bracket heated at one end; steady heat and thermal stress</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/bracket-hex.png" alt="Hex-meshed bracket" width="100%"><br><b>Hex-meshed bracket</b> (<code>bracket-hex</code>): the bracket split into 30 cells, meshed all-hex (hex20), von Mises</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/crank-arm.png" alt="Bicycle crank arm" width="100%"><br><b>Bicycle crank arm</b> (<code>crank-arm</code>): an aluminium crank meshed structured all-hex (hex20) from a partitioned half and its mirror image, a rider's weight on the pedal; root bending within 0.3 % of M/Z</td>
|
||||
<td width="33%" valign="top"><i>No picture yet: the viewport does not draw flow results (M12).</i><br><b>Flow past a bracket</b> (<code>flow-past</code>): air past an L-bracket on the floor; lattice Boltzmann flow, the drag and the force on each face</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo xtask ci # fmt, assets, licences, layers, clippy, tests, wasm
|
||||
cargo xtask ci # fmt, assets, layers, clippy, tests, wasm
|
||||
cargo xtask test --heavy # the heavy (ignored) tests and the doc tests
|
||||
cargo run -p fieldcraft-cli -- commands # every command and its parameters
|
||||
cargo run -p fieldcraft-cli -- run script.json # run a command script
|
||||
@@ -129,10 +135,21 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
|
||||
- [Releasing](docs/releasing.md): how a release is built, verified and published
|
||||
- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md)
|
||||
|
||||
## Privacy
|
||||
|
||||
FieldCraft sends nothing until you agree. The first start asks once whether to share anonymous
|
||||
usage (which tools you use, nothing from your models); Help › Settings… changes it, and
|
||||
`FIELDCRAFT_TELEMETRY=0` turns it off. File names, paths, geometry, model contents, parameter
|
||||
values and messages are never sent: [docs/privacy.md](docs/privacy.md) lists every event and
|
||||
property. Crash reports and the log stay on your computer; Help › Report a Bug opens a GitHub issue
|
||||
you review and submit yourself, with your session or model attached only if you tick them
|
||||
([docs/crash-reports.md](docs/crash-reports.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).
|
||||
are in [NOTICE](NOTICE); third-party licences in
|
||||
THIRD-PARTY-LICENSES.txt, which every release package carries (`cargo xtask licences` writes it).
|
||||
|
||||
FieldCraft 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
|
||||
|
||||
@@ -28,11 +28,11 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
|---|---|---|
|
||||
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
|
||||
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
|
||||
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
|
||||
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started |
|
||||
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing (node tools done: `mesh.node.merge` equivalences coincident nodes with sets and tags following the survivors and element collapses left out, previewed in its form; `mesh.check.duplicates` lists unmerged nodes, repeated elements, cracks and free edges and selects them; `mesh.renumber` compact, by body or in reverse Cuthill–McKee order, every reference following) and orphan-mesh import | started |
|
||||
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
|
||||
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
@@ -52,7 +52,44 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
|
||||
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
|
||||
|
||||
## 0.6.0: the UI/UX release
|
||||
|
||||
What landed for 0.6.0 (milestone 0.6.0 on GitHub):
|
||||
|
||||
- **Browser:** status checks in one aligned column, Interactions counting ties, a selected set
|
||||
highlighted in the viewport, seeds selectable, editable and deletable, definitions shown with
|
||||
units instead of raw JSON, one highlighted item per context menu, given names shown as typed,
|
||||
and a setup checklist that does not ask for a load on a displacement-driven model or for a
|
||||
support or load on a free body in explicit dynamics (#315, #335, #336, #352, #356, #361, #366).
|
||||
- **Results:** units on every legend, summary, probe and reaction sum; legend ranges that can be
|
||||
clamped; max and min markers in the viewport; an energies plot with axes and the current frame;
|
||||
the deformed shape kept when the field changes; a sensible auto scale for large strains; setup
|
||||
symbols off over contours; heat flow through each convection and flux load; probe and reaction
|
||||
results exported to CSV (#92, #191, #319, #321, #325–#332, #362).
|
||||
- **Forms, progress and jobs:** solves and meshers run as jobs with their step, stage, count and
|
||||
elapsed time and a Cancel that interrupts every solver and mesher; the control channel and MCP
|
||||
wait for a long solve; mesher forms report what they made; typed fields for label lists, point
|
||||
lists, multi-choices and number-or-auto, JSON fields with hints, defaults shown as set; forms
|
||||
without orphan sets on Cancel; unknown parameters refused (#169, #316, #322–#324, #337, #339,
|
||||
#340, #353, #357, #367, #371).
|
||||
- **Picking and preparation:** vertices pickable, selected edges visible over the mesh, a
|
||||
reference point placed by a pick or a hole's centre, Seed Edges switching the filter to edges,
|
||||
live previews and default names for datums and partitions, readable partition cell names, the
|
||||
Partitioned Mesh form with picks and per-cell methods, a mesh marked out of date when its seeds
|
||||
or partitions change, remeshing from the current mesh's settings (#338, #341–#349, #351).
|
||||
- **UI conveniences:** File › New (`file.new`), Help › Crash Reports, the I-beam sample, every
|
||||
sample with a Start-card thumbnail and a gallery capture, node tools (merge, duplicates, free
|
||||
edges, renumber), the mesh quality panel's histograms and worst elements, Mirror Mesh and Mesh
|
||||
Block on the ribbon, Rigid Wall in Interactions, readable journal entries and status messages,
|
||||
a sample's name on its document tab, the web build naming an unknown sample (#113, #143, #144,
|
||||
#200, #234, #300, #333, #334, #348, #350, #354, #355, #359, #360, #365).
|
||||
- **Mesh soundness:** tet improvement that no longer crowds one vertex, hex20 extrudes that pass
|
||||
the validity gate where hex8 does, biased seeds carried to the opposite edge of a mapped face,
|
||||
designs with a closed internal void and face names over 256 characters meshed (#197, #257,
|
||||
#311, #364, #374).
|
||||
- **Heat:** heat transfer across ties for bonded multi-body thermal models (#358).
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -12,4 +12,8 @@ workspace = true
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-mcp = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
fieldcraft-update = { workspace = true, features = ["mock"] }
|
||||
@@ -0,0 +1,80 @@
|
||||
//! `fieldcraft-cli crashes`: the crash reports the desktop app wrote on this computer
|
||||
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use fieldcraft_engine::crash;
|
||||
use serde_json::json;
|
||||
|
||||
pub const USAGE: &str = "usage: fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dir = crash::default_dir().ok_or("no crash folder on this system (set FIELDCRAFT_CRASH_DIR)")?;
|
||||
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let reports = crash::list(&dir);
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
return print_json(&json!({ "dir": dir.to_string_lossy(), "reports": reports }));
|
||||
}
|
||||
println!("crash reports in {}", dir.display());
|
||||
if reports.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for r in reports {
|
||||
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
|
||||
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
|
||||
let replay = if r.replayable { " [replayable]" } else { "" };
|
||||
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("show") => print_json(&crash::read(&dir, id()?)?),
|
||||
Some("replay") => replay(&dir, id()?),
|
||||
Some("dir") => {
|
||||
println!("{}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
Some("clear") => {
|
||||
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(v: &serde_json::Value) -> Result<(), String> {
|
||||
println!("{}", serde_json::to_string_pretty(v).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a report's commands on its model and say whether the crash happens again. Files the
|
||||
/// commands write go to a scratch folder, never over the user's files.
|
||||
fn replay(dir: &Path, id: &str) -> Result<(), String> {
|
||||
let report = crash::read(dir, id)?;
|
||||
let scratch = std::env::temp_dir().join(format!("fieldcraft-replay-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
|
||||
if report["kind"] == "unclean_exit" {
|
||||
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
|
||||
}
|
||||
println!("replaying on {}", report["model_path"].as_str().unwrap_or("a new model"));
|
||||
let r = crash::replay(&report, &scratch);
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let r = r?;
|
||||
for (i, (cmd, outcome)) in r.steps.iter().enumerate() {
|
||||
match outcome {
|
||||
Ok(()) => println!(" {:>3}. {cmd}: ok", i + 1),
|
||||
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
|
||||
}
|
||||
}
|
||||
let said = |k: &str| report[k].as_str().unwrap_or_default().to_string();
|
||||
if r.reproduced {
|
||||
println!("reproduced: the same internal error ({}) at {}", said("message"), said("location"));
|
||||
} else if let Some((l, m)) = r.panics.first() {
|
||||
println!("an internal error happened, but not the reported one: {m} at {l}");
|
||||
println!("(the report says: {} at {})", said("message"), said("location"));
|
||||
} else {
|
||||
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,10 +2,15 @@
|
||||
//! fieldcraft-cli run <script.json>
|
||||
//! fieldcraft-cli features
|
||||
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
//! fieldcraft-cli update --check | --install
|
||||
//! fieldcraft-cli --version
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod crashes;
|
||||
mod update;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -28,6 +33,14 @@ usage:
|
||||
--automation-read-root DIR files may be read only inside DIR
|
||||
--automation-write-root DIR files may be written only inside DIR (with either root,
|
||||
the access a root left out is not granted)
|
||||
fieldcraft-cli crashes [list|show <id>|replay <id>|dir|clear [<id>]]
|
||||
the crash reports the desktop app wrote on this
|
||||
computer (docs/crash-reports.md): list them (--json),
|
||||
show one, replay one (does the crash happen again?),
|
||||
print their folder, delete them
|
||||
fieldcraft-cli update --check is a newer FieldCraft published? (app.update.check; JSON)
|
||||
fieldcraft-cli update --install download the latest release, verify its signature and
|
||||
checksum, and install it over this copy (docs/updates.md)
|
||||
fieldcraft-cli --version print the version
|
||||
";
|
||||
|
||||
@@ -52,6 +65,8 @@ fn main() -> ExitCode {
|
||||
Ok(())
|
||||
}
|
||||
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
|
||||
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
|
||||
Some("update") => update::run(args.get(1..).unwrap_or(&[])),
|
||||
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
|
||||
Some("-V" | "--version") => {
|
||||
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `fieldcraft-cli update --check | --install` (docs/updates.md): the same check as the app's
|
||||
//! Help › Check for Updates (`app.update.check`), and installing the latest release over this
|
||||
//! copy of the CLI's install, verified against the release key first. `FIELDCRAFT_UPDATE_HOST`
|
||||
//! points both at a local test server.
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_update::install::{self, Finish};
|
||||
use fieldcraft_update::net::Client;
|
||||
use fieldcraft_update::stage::{self, Options};
|
||||
use serde_json::json;
|
||||
|
||||
pub const USAGE: &str = "usage: fieldcraft-cli update --check | --install";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
|
||||
["--check"] => check(),
|
||||
["--install"] => install(),
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check() -> Result<(), String> {
|
||||
fieldcraft_engine::enable_update_checks(true);
|
||||
let report = Session::default().execute("app.update.check", &json!({})).map_err(|e| e.to_string())?;
|
||||
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install() -> Result<(), String> {
|
||||
let client = Client::from_env();
|
||||
let check = client.check().map_err(|e| e.to_string())?;
|
||||
if !check.newer() {
|
||||
println!("FieldCraft {} is up to date (the latest release is {}).", check.current, check.latest.version);
|
||||
return Ok(());
|
||||
}
|
||||
let here = install::here();
|
||||
let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates"));
|
||||
eprintln!("Downloading FieldCraft {} for {}…", check.latest.version, here.describe());
|
||||
let progress = |done: u64, total: u64| {
|
||||
if total > 0 && done == total {
|
||||
eprintln!("Downloaded {} MB; verifying…", total / (1024 * 1024));
|
||||
}
|
||||
};
|
||||
let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress };
|
||||
let staged = stage::stage(&client, &check.latest, &here, &options).map_err(|e| e.to_string())?;
|
||||
match install::plan(&staged, false).map_err(|e| e.to_string())? {
|
||||
Finish::Swap(plan) => {
|
||||
stage::apply(&plan).map_err(|e| e.to_string())?;
|
||||
println!("Installed FieldCraft {} ({}). A running FieldCraft app uses it from its next start.", staged.version, here.describe());
|
||||
}
|
||||
Finish::Package { file, command } => {
|
||||
println!("Downloaded and verified {}.\nInstall it with the system's package manager:\n {command}", file.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `fieldcraft-cli crashes` on a crash folder of its own (`FIELDCRAFT_CRASH_DIR`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const ID: &str = "20261014-101500-00000000deadbeef";
|
||||
|
||||
/// A replayable report: a model with two nodes and a replay that adds a third.
|
||||
fn folder() -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-cli-crashes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let r = d.join(ID);
|
||||
std::fs::create_dir_all(&r).unwrap();
|
||||
let mut s = Session::default();
|
||||
for l in 1..=2 {
|
||||
s.execute("mesh.node.add", &json!({"label": l, "xyz": [l, 0, 0]})).unwrap();
|
||||
}
|
||||
std::fs::write(r.join("model.fieldcraft"), s.file_bytes(None).unwrap()).unwrap();
|
||||
let replay = json!({"commands": [
|
||||
{"command": "mesh.node.add", "params": {"label": 3, "xyz": [3, 0, 0]}},
|
||||
{"command": "file.save", "params": {"path": d.join("overwritten.fieldcraft")}},
|
||||
]});
|
||||
std::fs::write(r.join("replay.json"), replay.to_string()).unwrap();
|
||||
let report = json!({
|
||||
"format": "fieldcraft-crash/1", "id": ID, "kind": "unclean_exit", "signature": "00000000deadbeef", "count": 2,
|
||||
"first_seen": "2026-10-14T10:15:00Z", "message": "FieldCraft ended without a clean exit while running `mesh.node.add`",
|
||||
"location": "unclean exit in mesh.node.add", "command": {"command": "mesh.node.add", "params": {}},
|
||||
"app": {"version": "9.9.9"}, "backtrace": [],
|
||||
});
|
||||
std::fs::write(r.join("report.json"), report.to_string()).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn cli(dir: &Path, args: &[&str]) -> (String, bool) {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_CRASH_DIR", dir).output().unwrap();
|
||||
(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)), out.status.success())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_list_show_replay_and_clear() {
|
||||
let d = folder();
|
||||
let (out, ok) = cli(&d, &["crashes"]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains(ID) && out.contains("unclean_exit ×2 in mesh.node.add") && out.contains("[replayable]"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "list", "--json"]);
|
||||
assert!(ok);
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["reports"][0]["id"], ID);
|
||||
let (out, ok) = cli(&d, &["crashes", "show", ID]);
|
||||
assert!(ok);
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert!(v["model_path"].as_str().unwrap().ends_with("model.fieldcraft"));
|
||||
let (out, ok) = cli(&d, &["crashes", "replay", ID]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("1. mesh.node.add: ok"), "{out}");
|
||||
// A command that writes a file is refused outside the scratch folder: the replay never
|
||||
// writes over the user's files.
|
||||
assert!(out.contains("2. file.save: ") && out.contains("outside the write root"), "{out}");
|
||||
assert!(!d.join("overwritten.fieldcraft").exists());
|
||||
assert!(out.contains("did not reproduce"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "show", "../etc"]);
|
||||
assert!(!ok && out.contains("no crash report"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "dir"]);
|
||||
assert!(ok && out.trim() == d.to_string_lossy(), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "clear"]);
|
||||
assert!(ok && out.contains("deleted 1 report(s)"), "{out}");
|
||||
let (out, _) = cli(&d, &["crashes"]);
|
||||
assert!(out.contains("(none)"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "nope"]);
|
||||
assert!(!ok && out.contains("usage"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `fieldcraft-cli update --check` against a local release server (FIELDCRAFT_UPDATE_HOST).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use fieldcraft_update::mock::Server;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn cli(host: &str, args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_UPDATE_HOST", host).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_reports_the_latest_release() {
|
||||
let server = Server::start().unwrap();
|
||||
server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/jyviko/fieldcraft/releases/tag/v99.0.0", "assets": []}));
|
||||
let out = cli(&server.url(), &["update", "--check"]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let report: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(report["current"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!((report["latest"].as_str(), report["newer"].as_bool(), report["signed"].as_bool()), (Some("99.0.0"), Some(true), Some(false)));
|
||||
// A test build is not a release package: it checks but would not install.
|
||||
assert_eq!(report["asset"], Value::Null);
|
||||
assert_eq!(server.requests(), 1);
|
||||
|
||||
// The same version: not newer, and --install has nothing to do.
|
||||
server.release("latest", &json!({"tag_name": format!("v{}", env!("CARGO_PKG_VERSION")), "assets": []}));
|
||||
let out = cli(&server.url(), &["update", "--check"]);
|
||||
assert_eq!(serde_json::from_slice::<Value>(&out.stdout).unwrap()["newer"], false);
|
||||
let out = cli(&server.url(), &["update", "--install"]);
|
||||
assert!(out.status.success() && String::from_utf8_lossy(&out.stdout).contains("is up to date"), "{out:?}");
|
||||
|
||||
// Unreachable: an error, not a crash.
|
||||
let out = cli("http://127.0.0.1:1", &["update", "--check"]);
|
||||
assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("could not reach"), "{out:?}");
|
||||
assert!(!cli(&server.url(), &["update"]).status.success());
|
||||
}
|
||||
@@ -29,6 +29,6 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "RequestCache", "Response"] }
|
||||
# Autosaves kept in the browser's local storage, which holds text.
|
||||
base64 = "0.22"
|
||||
@@ -0,0 +1,75 @@
|
||||
//! A new deployment of the web app: every half hour the page's own address is fetched past the browser's cache, and when it loads another build
|
||||
//! of the app than the one running (Trunk names each build's files by their content hash), the
|
||||
//! status bar offers "Reload to update" (docs/updates.md). Reloading asks first when the model
|
||||
//! has unsaved changes, as closing the page does.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
/// How often the page looks for a new deployment (seconds).
|
||||
pub const INTERVAL: f64 = 30.0 * 60.0;
|
||||
|
||||
/// Watches for a new deployment.
|
||||
pub struct Watch {
|
||||
/// The build this page runs.
|
||||
running: Option<String>,
|
||||
/// A fetch found another build.
|
||||
found: Rc<Cell<bool>>,
|
||||
/// App time of the last look.
|
||||
pub looked_at: f64,
|
||||
}
|
||||
|
||||
impl Watch {
|
||||
pub fn start() -> Watch {
|
||||
let html = web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element()).map(|e| e.outer_html()).unwrap_or_default();
|
||||
let running = crate::build_in_page(&html).map(str::to_string);
|
||||
if running.is_none() {
|
||||
log::info!("deploy: no hashed build in the page (a development server?); not watching");
|
||||
}
|
||||
Watch { running, found: Rc::default(), looked_at: 0.0 }
|
||||
}
|
||||
|
||||
/// Whether a newer deployment was found.
|
||||
pub fn found(&self) -> bool {
|
||||
self.found.get()
|
||||
}
|
||||
|
||||
/// Fetch the page again and compare its build with the running one.
|
||||
pub fn look(&self) {
|
||||
let (Some(running), Some(window)) = (self.running.clone(), web_sys::window()) else { return };
|
||||
if self.found.get() {
|
||||
return;
|
||||
}
|
||||
let url = window.location().pathname().unwrap_or_else(|_| "./".into());
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_cache(web_sys::RequestCache::NoStore);
|
||||
let promise = window.fetch_with_str_and_init(&url, &init);
|
||||
let found = self.found.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let text = async {
|
||||
let response: web_sys::Response = wasm_bindgen_futures::JsFuture::from(promise).await.ok()?.dyn_into().ok()?;
|
||||
if !response.ok() {
|
||||
return None;
|
||||
}
|
||||
wasm_bindgen_futures::JsFuture::from(response.text().ok()?).await.ok()?.as_string()
|
||||
};
|
||||
// Offline or refused: look again next time.
|
||||
if let Some(html) = text.await
|
||||
&& let Some(deployed) = crate::build_in_page(&html)
|
||||
&& deployed != running
|
||||
{
|
||||
log::info!("deploy: {deployed} is deployed (running {running})");
|
||||
found.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the page (the new deployment loads).
|
||||
pub fn reload() {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.location().reload();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
|
||||
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
|
||||
//! a download;
|
||||
//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
|
||||
//! - autosaves for recovery and the preferences are kept in the browser's local storage
|
||||
//! ([`storage`], [`usage`]), and anonymous usage (when agreed) goes out with `fetch`;
|
||||
//! - no control channel (a page can't listen on a socket);
|
||||
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
|
||||
//! that they run.
|
||||
@@ -17,9 +18,13 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod deploy;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod storage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod usage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -42,6 +47,25 @@ fn sample_in_query(query: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The build of the app a page loads: the content-hashed name Trunk gives its WebAssembly
|
||||
/// (`fieldcraft-web-<hash>_bg.wasm`). A new deployment loads another one.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn build_in_page(html: &str) -> Option<&str> {
|
||||
const HEAD: &str = "fieldcraft-web-";
|
||||
const TAIL: &str = "_bg.wasm";
|
||||
let mut rest = html;
|
||||
while let Some(at) = rest.find(HEAD) {
|
||||
let candidate = rest.get(at..)?;
|
||||
let hash = candidate.get(HEAD.len()..)?;
|
||||
let len = hash.bytes().take_while(u8::is_ascii_alphanumeric).count();
|
||||
if len > 0 && hash.get(len..)?.starts_with(TAIL) {
|
||||
return candidate.get(..HEAD.len() + len + TAIL.len());
|
||||
}
|
||||
rest = candidate.get(HEAD.len()..)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The name a file from the computer is kept under in the page: its last path part, without
|
||||
/// characters a path would read as folders.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
@@ -64,6 +88,18 @@ mod tests {
|
||||
assert_eq!(sample_in_query("?samples=x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_running_build_is_found_in_the_page() {
|
||||
let page = r#"<link rel="modulepreload" href="/fieldcraft/fieldcraft-web-1a2b3c.js"><link rel="preload" href="/fieldcraft/fieldcraft-web-1a2b3c_bg.wasm" as="fetch">"#;
|
||||
assert_eq!(build_in_page(page), Some("fieldcraft-web-1a2b3c_bg.wasm"));
|
||||
let next = page.replace("1a2b3c", "9f8e7d");
|
||||
assert_ne!(build_in_page(page), build_in_page(&next));
|
||||
// A development page (no hash) and other text: nothing to compare.
|
||||
assert_eq!(build_in_page(r#"<script src="fieldcraft-web.js"></script>"#), None);
|
||||
assert_eq!(build_in_page("fieldcraft-web-"), None);
|
||||
assert_eq!(build_in_page(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_stay_in_their_folder() {
|
||||
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Anonymous usage in the browser (docs/privacy.md): the same events and the same consent as the
|
||||
//! desktop app, the choice kept in the browser's local storage with the other preferences. Queued
|
||||
//! events are posted with `fetch` every half minute and when the page is closed or left (`keepalive`, so
|
||||
//! a closing page still sends them); a failed post is dropped silently.
|
||||
//!
|
||||
//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`: a build without one asks nothing
|
||||
//! and sends nothing.
|
||||
|
||||
use fieldcraft_engine::usage;
|
||||
use fieldcraft_ui_egui::files::PrefsStore;
|
||||
use fieldcraft_ui_egui::privacy::UsageHost;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY");
|
||||
/// Where the preferences are kept in local storage.
|
||||
const PREFS_KEY: &str = "fieldcraft-prefs";
|
||||
/// How often queued events are sent (seconds).
|
||||
pub const INTERVAL: f64 = 30.0;
|
||||
|
||||
fn key() -> Option<&'static str> {
|
||||
KEY.map(str::trim).filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
/// The preferences in local storage (navigation, privacy).
|
||||
pub fn prefs_store() -> PrefsStore {
|
||||
PrefsStore {
|
||||
load: Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten().map(String::into_bytes)),
|
||||
save: Box::new(|bytes| {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
|
||||
local_storage().ok_or("browser storage is turned off")?.set_item(PREFS_KEY, text).map_err(crate::web::js_err)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window needs to ask and record, when the build has a key.
|
||||
pub fn host() -> Option<UsageHost> {
|
||||
// The browser's clock for command durations (the plain wasm target has none).
|
||||
usage::set_clock(js_sys::Date::now);
|
||||
key().is_some().then(|| UsageHost {
|
||||
platform: "web",
|
||||
os: "web",
|
||||
arch: "wasm32",
|
||||
new_id: Box::new(|| web_sys::window()?.crypto().ok().map(|c| c.random_uuid())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Post what is queued.
|
||||
pub fn send() {
|
||||
let (Some(key), Some(window)) = (key(), web_sys::window()) else { return };
|
||||
let url = format!("{}/batch/", usage::DEFAULT_HOST);
|
||||
while let Some(batch) = usage::take_batch() {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("POST");
|
||||
init.set_body(&wasm_bindgen::JsValue::from_str(&batch.body(key)));
|
||||
// The Fetch standard's `keepalive` (a closing page still sends it); web-sys has no setter.
|
||||
let _ = js_sys::Reflect::set(&init, &"keepalive".into(), &true.into());
|
||||
let headers = web_sys::Headers::new().ok();
|
||||
if let Some(h) = &headers {
|
||||
let _ = h.set("Content-Type", "application/json");
|
||||
init.set_headers(h);
|
||||
}
|
||||
let promise = window.fetch_with_str_and_init(&url, &init);
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Offline or refused: the batch is dropped.
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
log::debug!("usage: not sent: {}", crate::web::js_err(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// When the page is closed or left: the session's length, then send.
|
||||
pub fn send_when_hidden(window: &web_sys::Window) {
|
||||
let on_hide = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(|| {
|
||||
usage::closing();
|
||||
send();
|
||||
});
|
||||
if let Err(e) = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref()) {
|
||||
log::warn!("usage: {}", crate::web::js_err(e));
|
||||
}
|
||||
on_hide.forget();
|
||||
}
|
||||
@@ -74,6 +74,7 @@ pub fn start() {
|
||||
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
crate::usage::send_when_hidden(&window);
|
||||
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
@@ -86,6 +87,10 @@ pub fn start() {
|
||||
Box::new(|cc| {
|
||||
let inbox = Inbox::default();
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
|
||||
if let Some(host) = crate::usage::host() {
|
||||
app = app.with_usage(host);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
||||
@@ -102,7 +107,8 @@ pub fn start() {
|
||||
if recoverable > 0 {
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
||||
}
|
||||
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false }))
|
||||
let deploy = crate::deploy::Watch::start();
|
||||
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false, sent_at: 0.0, deploy }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -188,6 +194,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
|
||||
// The recent list holds the page's files, which go with the page.
|
||||
recent_path: None,
|
||||
// Preferences are kept in the browser's local storage.
|
||||
prefs: Some(crate::usage::prefs_store()),
|
||||
// egui opens links itself in the browser.
|
||||
open_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +212,10 @@ struct Shell {
|
||||
autosaved_at: f64,
|
||||
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
||||
storage_error: bool,
|
||||
/// App time usage events were last sent.
|
||||
sent_at: f64,
|
||||
/// Looks for a new deployment of the app.
|
||||
deploy: crate::deploy::Watch,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -267,7 +281,19 @@ impl eframe::App for Shell {
|
||||
self.open_arrived();
|
||||
self.app.logic(ctx);
|
||||
self.autosave(ctx);
|
||||
if self.app.now - self.sent_at >= crate::usage::INTERVAL {
|
||||
self.sent_at = self.app.now;
|
||||
crate::usage::send();
|
||||
}
|
||||
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
||||
if self.app.now - self.deploy.looked_at >= crate::deploy::INTERVAL || self.app.now < self.deploy.looked_at {
|
||||
self.deploy.looked_at = self.app.now;
|
||||
self.deploy.look();
|
||||
}
|
||||
if self.deploy.found() && !self.app.reload_offered() {
|
||||
self.app.offer_reload(Box::new(crate::deploy::reload));
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(crate::deploy::INTERVAL));
|
||||
}
|
||||
|
||||
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fieldcraft"
|
||||
description = "FieldCraft desktop app"
|
||||
description = "Finite element modeling: meshing, model setup, solver decks and a linear solver"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,12 +13,98 @@ workspace = true
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Usage analytics (docs/privacy.md): an HTTPS POST with rustls on ring, checked against the
|
||||
# operating system's certificates (no bundled root list), and a random install id.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
getrandom = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
# The Windows executable's icon resource (build.rs).
|
||||
embed-resource = "3.0.12"
|
||||
|
||||
# Linux .deb and .rpm packages (cargo-deb, cargo-generate-rpm), built by packaging/linux/package.sh
|
||||
# from the release binaries it has already built and stripped; it sets the version and the file
|
||||
# names. The file list matches the tar.gz's (package.sh's staged tree; the release workflow
|
||||
# compares them).
|
||||
# The binaries link only glibc and libgcc_s. Windowing (Wayland, X11, xkbcommon), the GPU (Vulkan
|
||||
# or EGL) and the file dialogs (the XDG desktop portal) are opened at run time, so they are no ELF
|
||||
# NEEDED entries and the tools cannot find them: they are listed here.
|
||||
[package.metadata.deb]
|
||||
name = "fieldcraft"
|
||||
maintainer = "FieldCraft contributors <jyviko@users.noreply.github.com>"
|
||||
copyright = "FieldCraft contributors"
|
||||
section = "science"
|
||||
priority = "optional"
|
||||
extended-description = """\
|
||||
FieldCraft turns geometry into analysis-ready finite element models: volume \
|
||||
meshes, materials, sections, boundary conditions, loads and steps. It writes \
|
||||
Abaqus input, Nastran bulk data and LS-DYNA keyword decks, and solves linear \
|
||||
static and modal problems with its own solver. Includes fieldcraft-cli, a \
|
||||
headless command runner and MCP server."""
|
||||
depends = "$auto, libxkbcommon0, libxkbcommon-x11-0, libwayland-client0, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libvulkan1 | libegl1"
|
||||
recommends = "mesa-vulkan-drivers, libegl-mesa0, xdg-desktop-portal"
|
||||
assets = [
|
||||
["target/release/fieldcraft", "usr/bin/", "755"],
|
||||
["target/release/fieldcraft-cli", "usr/bin/", "755"],
|
||||
["../../packaging/linux/io.github.jyviko.fieldcraft.desktop", "usr/share/applications/", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_16x16.png", "usr/share/icons/hicolor/16x16/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_32x32.png", "usr/share/icons/hicolor/32x32/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_32x32@2x.png", "usr/share/icons/hicolor/64x64/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_128x128.png", "usr/share/icons/hicolor/128x128/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_256x256.png", "usr/share/icons/hicolor/256x256/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_512x512.png", "usr/share/icons/hicolor/512x512/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_512x512@2x.png", "usr/share/icons/hicolor/1024x1024/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../README.md", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../LICENSE-MIT", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../LICENSE-APACHE", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../NOTICE", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../THIRD-PARTY-LICENSES.txt", "usr/share/doc/fieldcraft/", "644"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
name = "fieldcraft"
|
||||
license = "MIT OR Apache-2.0"
|
||||
url = "https://github.com/jyviko/fieldcraft"
|
||||
assets = [
|
||||
{ source = "target/release/fieldcraft", dest = "/usr/bin/fieldcraft", mode = "755" },
|
||||
{ source = "target/release/fieldcraft-cli", dest = "/usr/bin/fieldcraft-cli", mode = "755" },
|
||||
{ source = "../../packaging/linux/io.github.jyviko.fieldcraft.desktop", dest = "/usr/share/applications/io.github.jyviko.fieldcraft.desktop", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_16x16.png", dest = "/usr/share/icons/hicolor/16x16/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_32x32@2x.png", dest = "/usr/share/icons/hicolor/64x64/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_256x256.png", dest = "/usr/share/icons/hicolor/256x256/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_512x512@2x.png", dest = "/usr/share/icons/hicolor/1024x1024/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../README.md", dest = "/usr/share/doc/fieldcraft/README.md", mode = "644", doc = true },
|
||||
{ source = "../../LICENSE-MIT", dest = "/usr/share/doc/fieldcraft/LICENSE-MIT", mode = "644", doc = true },
|
||||
{ source = "../../LICENSE-APACHE", dest = "/usr/share/doc/fieldcraft/LICENSE-APACHE", mode = "644", doc = true },
|
||||
{ source = "../../NOTICE", dest = "/usr/share/doc/fieldcraft/NOTICE", mode = "644", doc = true },
|
||||
{ source = "../../THIRD-PARTY-LICENSES.txt", dest = "/usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt", mode = "644", doc = true },
|
||||
]
|
||||
# The binaries' ELF dependencies (glibc, libgcc_s) are found automatically; these are opened at run
|
||||
# time. Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL
|
||||
# and openSUSE.
|
||||
require-sh = false
|
||||
|
||||
[package.metadata.generate-rpm.requires]
|
||||
"libxkbcommon.so.0()(64bit)" = "*"
|
||||
"libxkbcommon-x11.so.0()(64bit)" = "*"
|
||||
"libwayland-client.so.0()(64bit)" = "*"
|
||||
"libX11.so.6()(64bit)" = "*"
|
||||
"libX11-xcb.so.1()(64bit)" = "*"
|
||||
"libxcb.so.1()(64bit)" = "*"
|
||||
"libXcursor.so.1()(64bit)" = "*"
|
||||
"libXi.so.6()(64bit)" = "*"
|
||||
"(libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))" = ""
|
||||
|
||||
[package.metadata.generate-rpm.recommends]
|
||||
mesa-vulkan-drivers = "*"
|
||||
xdg-desktop-portal = "*"
|
||||
@@ -115,6 +115,11 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
// A request waiting for a job (a solve, a mesher): its reply comes when the job
|
||||
// ends, however long it runs; the job always answers, even when cancelled.
|
||||
if r.get(fieldcraft_ui_egui::control::PENDING).is_some() {
|
||||
r = rrx.recv().unwrap_or_else(|_| json!({"ok": false, "error": "the job ended without a reply"}));
|
||||
}
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod control_server;
|
||||
mod update;
|
||||
mod usage;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::{self, Autosaver};
|
||||
use fieldcraft_engine::{Session, crash, logfile};
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
|
||||
const USAGE: &str =
|
||||
@@ -47,6 +49,10 @@ struct App {
|
||||
autosaver: Option<Autosaver>,
|
||||
/// App time of the last autosave check.
|
||||
autosaved_at: f64,
|
||||
/// The first frame was drawn (the crash journal says the app is up).
|
||||
running: bool,
|
||||
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
|
||||
usage: Option<usage::Sender>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -61,6 +67,10 @@ impl App {
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if !self.running {
|
||||
self.running = true;
|
||||
crash::activity("running");
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
self.autosaved_at = self.app.now;
|
||||
@@ -76,6 +86,16 @@ impl eframe::App for App {
|
||||
{
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
self.app.usage_closing();
|
||||
if let Some(u) = self.usage.take() {
|
||||
u.finish();
|
||||
}
|
||||
// An update the user chose to install: swapped in now that the window has closed.
|
||||
if let Some(staged) = self.app.take_staged_update() {
|
||||
update::finish(&staged);
|
||||
}
|
||||
log::info!("FieldCraft closed");
|
||||
crash::end_session();
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
@@ -95,9 +115,57 @@ fn services() -> Services {
|
||||
})),
|
||||
// Beside the recovery folder in the per-user data folder.
|
||||
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
|
||||
prefs: fieldcraft_io::recovery::default_dir()
|
||||
.and_then(|d| d.parent().map(|p| fieldcraft_ui_egui::files::PrefsStore::file(p.join("prefs.json")))),
|
||||
open_url: Some(Box::new(open_url)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a web link in the default browser, with the system's own opener: `open` (macOS),
|
||||
/// `xdg-open` (Linux and the BSDs), `explorer` (Windows). The link is one argument, never
|
||||
/// passed through a shell. Only https links are opened.
|
||||
fn open_url(url: &str) -> Result<(), String> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(format!("not a web link: {}", url.chars().take(80).collect::<String>()));
|
||||
}
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else if cfg!(windows) {
|
||||
"explorer"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let mut child = std::process::Command::new(opener).arg(url).spawn().map_err(|e| format!("{opener}: {e}"))?;
|
||||
// Reaped off the window thread; its exit status says nothing (`explorer` exits with 1 even
|
||||
// when it opened the link).
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Crash reports and the log file (docs/crash-reports.md): reports of earlier runs that crashed
|
||||
/// come back, for the window to offer. Both live in the app's data folder (a temporary one in
|
||||
/// runs driven by automation); `FIELDCRAFT_CRASH_DIR` moves the crash folder.
|
||||
fn start_reporting() -> Vec<crash::Summary> {
|
||||
// First the crash reports: an unclean exit's report takes the dead run's lines from the log.
|
||||
let crashed = crash::default_dir().map(|dir| crash::install(dir, crash::Build::current())).unwrap_or_default();
|
||||
match fieldcraft_io::recovery::data_dir().map(|d| logfile::install(&d.join("logs"), log::LevelFilter::Info)) {
|
||||
Some(Ok(path)) => eprintln!("fieldcraft: log {}", path.display()),
|
||||
Some(Err(e)) => eprintln!("fieldcraft: no log file: {e}"),
|
||||
None => {}
|
||||
}
|
||||
let b = crash::Build::current();
|
||||
log::info!(
|
||||
"FieldCraft {} started ({}), crash reports in {}",
|
||||
b.version,
|
||||
b.version_line(),
|
||||
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
|
||||
);
|
||||
crash::activity("startup");
|
||||
crashed
|
||||
}
|
||||
|
||||
/// Command-line options.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
@@ -149,6 +217,12 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if args.control.is_some() {
|
||||
// A run driven by automation keeps its autosaves (and recent files and preferences, which
|
||||
// live beside them), its crash reports and its log in a temporary folder, never the user's.
|
||||
fieldcraft_io::recovery::use_automation_dir();
|
||||
}
|
||||
let crashed = start_reporting();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FieldCraft")
|
||||
@@ -171,12 +245,26 @@ fn main() -> eframe::Result {
|
||||
"FieldCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services());
|
||||
let mut session = Session::default();
|
||||
// The window's model: its commands go into the crash journal.
|
||||
session.crash_journal = crash::enabled();
|
||||
let mut app = ModelApp::new(session).with_services(services());
|
||||
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
|
||||
let usage = match usage::host(args.control.is_some()) {
|
||||
Some(host) => {
|
||||
app = app.with_usage(host);
|
||||
usage::start()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// Updates (docs/updates.md): checked at most daily when on, installed only on a click.
|
||||
fieldcraft_engine::enable_update_checks(true);
|
||||
app = app.with_updates(update::host(cc.egui_ctx.clone(), args.control.is_some()));
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
eprintln!("fieldcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
|
||||
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
if let Some(port) = args.control {
|
||||
@@ -196,7 +284,8 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
|
||||
});
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 }))
|
||||
app.announce_crashes(&crashed);
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The desktop app's side of updates (docs/updates.md): it runs the window's update jobs
|
||||
//! ([`fieldcraft_ui_egui::updates`]) on background threads with [`fieldcraft_update`], and swaps
|
||||
//! a staged release in after the window has closed ([`finish`]).
|
||||
//!
|
||||
//! Automatic checks are off in runs driven by automation (`--control`) and under CI, so test
|
||||
//! and agent runs never reach GitHub on their own; `FIELDCRAFT_UPDATE_HOST` (a local test
|
||||
//! server) turns them back on. Help › Check for Updates works in every run.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use fieldcraft_ui_egui::updates::{Event, Job, UpdateHost};
|
||||
use fieldcraft_update::install::{self, Finish, Install};
|
||||
use fieldcraft_update::net::{Client, Which};
|
||||
use fieldcraft_update::stage::{self, Options};
|
||||
use fieldcraft_update::{Release, Staged};
|
||||
|
||||
/// What the window needs for updates.
|
||||
pub fn host(ctx: egui::Context, automation: bool) -> UpdateHost {
|
||||
let here = install::here();
|
||||
log::info!("updates: {}", here.describe());
|
||||
stage::clean_stale(&here);
|
||||
let env = |k: &str| std::env::var_os(k).is_some_and(|v| !v.is_empty());
|
||||
let automatic = env(fieldcraft_update::HOST_ENV) || !(automation || env("CI"));
|
||||
let (tx, events) = mpsc::channel::<Event>();
|
||||
let unsupported = here.unsupported();
|
||||
let start = move |job: Job| {
|
||||
let (tx, ctx, here) = (tx.clone(), ctx.clone(), here.clone());
|
||||
let spawned = std::thread::Builder::new().name("fieldcraft update".into()).spawn(move || {
|
||||
let send = |e: Event| {
|
||||
let _ = tx.send(e);
|
||||
ctx.request_repaint();
|
||||
};
|
||||
let client = Client::from_env();
|
||||
match job {
|
||||
Job::Check { manual } => send(Event::Checked { manual, result: client.check().map_err(|e| e.to_string()) }),
|
||||
Job::Stage(release) => send(Event::Staged(stage_release(&client, &release, &here, &send))),
|
||||
Job::Reinstall => {
|
||||
let staged = client
|
||||
.release(&Which::Version(fieldcraft_update::current()))
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|r| stage_release(&client, &r, &here, &send));
|
||||
send(Event::Staged(staged));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("updates: no thread: {e}");
|
||||
}
|
||||
};
|
||||
UpdateHost { unsupported, automatic, start: Box::new(start), events, now: Box::new(now), open_file: Box::new(open_file) }
|
||||
}
|
||||
|
||||
/// Download, verify and stage `release`, reporting each whole percent.
|
||||
fn stage_release(client: &Client, release: &Release, here: &Install, send: &dyn Fn(Event)) -> Result<Staged, String> {
|
||||
let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates"));
|
||||
let last = Cell::new(u64::MAX);
|
||||
let progress = |done: u64, total: u64| {
|
||||
let step = done.saturating_mul(100).checked_div(total).unwrap_or(done / (1024 * 1024));
|
||||
if last.replace(step) != step {
|
||||
send(Event::Progress(done, total));
|
||||
}
|
||||
};
|
||||
let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress };
|
||||
let staged = stage::stage(client, release, here, &options).map_err(|e| e.to_string());
|
||||
match &staged {
|
||||
Ok(s) => log::info!("updates: FieldCraft {} verified and staged at {}", s.version, s.payload.display()),
|
||||
Err(e) => log::warn!("updates: FieldCraft {} not staged: {e}", release.version),
|
||||
}
|
||||
staged
|
||||
}
|
||||
|
||||
/// Swap `staged` in and start it: the window has closed, its autosave is closed.
|
||||
pub fn finish(staged: &Staged) {
|
||||
match install::plan(staged, true) {
|
||||
Ok(Finish::Swap(plan)) => match stage::apply(&plan) {
|
||||
Ok(()) => log::info!("updates: FieldCraft {} installed ({})", staged.version, staged.install.describe()),
|
||||
Err(e) => log::error!("updates: FieldCraft {} not installed, the previous version is kept: {e}", staged.version),
|
||||
},
|
||||
Ok(Finish::Package { .. }) => {}
|
||||
Err(e) => log::error!("updates: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch.
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Open a downloaded package with the system's handler for it (Linux: the software installer).
|
||||
fn open_file(path: &Path) -> Result<(), String> {
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else if cfg!(windows) {
|
||||
"explorer"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let mut child = std::process::Command::new(opener).arg(path).spawn().map_err(|e| format!("{opener}: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Sending the anonymous usage events (docs/privacy.md) to PostHog Cloud EU: a background
|
||||
//! thread posts the queued events ([`fieldcraft_engine::usage`]) in batches every half minute and
|
||||
//! when the window closes. A failed post (offline, a slow network) drops its batch silently;
|
||||
//! nothing waits on it but the thread.
|
||||
//!
|
||||
//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`, which only the release workflow
|
||||
//! sets: local, development and fork builds have none and send nothing.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fieldcraft_engine::usage;
|
||||
use fieldcraft_ui_egui::privacy::UsageHost;
|
||||
|
||||
/// The PostHog project key the release build was made with.
|
||||
const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY");
|
||||
/// How often queued events are sent.
|
||||
const INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// The longest one post may take.
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How long closing the window waits for the last batch.
|
||||
const LAST_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
fn key() -> Option<&'static str> {
|
||||
KEY.map(str::trim).filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
/// What the window needs to ask and record, when this run may share usage: the build has a key,
|
||||
/// `FIELDCRAFT_TELEMETRY` is not `0`, and neither CI nor `--control` automation runs it (unless
|
||||
/// `FIELDCRAFT_TELEMETRY=1`).
|
||||
pub fn host(automation: bool) -> Option<UsageHost> {
|
||||
let telemetry = std::env::var("FIELDCRAFT_TELEMETRY").ok();
|
||||
let ci = std::env::var_os("CI").is_some_and(|v| !v.is_empty());
|
||||
usage::allowed(key().is_some(), telemetry.as_deref(), automation, ci).then(|| UsageHost {
|
||||
platform: "desktop",
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
new_id: Box::new(new_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// A random (version 4) UUID from the operating system's random source.
|
||||
fn new_id() -> Option<String> {
|
||||
let mut b = [0u8; 16];
|
||||
getrandom::fill(&mut b).ok()?;
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
|
||||
Some(format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]))
|
||||
}
|
||||
|
||||
/// The sending thread; [`Sender::finish`] sends what is left and stops it.
|
||||
pub struct Sender {
|
||||
stop: mpsc::Sender<()>,
|
||||
finished: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
/// Start the sending thread (only when the build has a key). `FIELDCRAFT_TELEMETRY_HOST`
|
||||
/// replaces PostHog's host, for checking the payload against a local listener.
|
||||
pub fn start() -> Option<Sender> {
|
||||
let key = key()?;
|
||||
let host = std::env::var("FIELDCRAFT_TELEMETRY_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| usage::DEFAULT_HOST.into());
|
||||
let url = format!("{}/batch/", host.trim_end_matches('/'));
|
||||
// rustls's process-wide crypto (ring); another part of the app may have installed it first.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
|
||||
.build()
|
||||
.into();
|
||||
let (stop, stopped) = mpsc::channel::<()>();
|
||||
let (done, finished) = mpsc::channel::<()>();
|
||||
let spawned = std::thread::Builder::new().name("fieldcraft usage".into()).spawn(move || {
|
||||
loop {
|
||||
let last = !matches!(stopped.recv_timeout(INTERVAL), Err(mpsc::RecvTimeoutError::Timeout));
|
||||
while let Some(batch) = usage::take_batch() {
|
||||
if let Err(e) = agent.post(&url).header("Content-Type", "application/json").send(batch.body(key)) {
|
||||
// Offline or refused: this batch is dropped, the rest waits for the next try.
|
||||
log::debug!("usage: {} event(s) not sent: {e}", batch.events.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if last {
|
||||
let _ = done.send(());
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => Some(Sender { stop, finished }),
|
||||
Err(e) => {
|
||||
log::warn!("usage: no sending thread: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sender {
|
||||
/// Send what is queued (waiting briefly) and stop.
|
||||
pub fn finish(self) {
|
||||
let _ = self.stop.send(());
|
||||
let _ = self.finished.recv_timeout(LAST_WAIT);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn install_ids_are_random_version_4_uuids() {
|
||||
let (a, b) = (super::new_id().unwrap(), super::new_id().unwrap());
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.len(), 36);
|
||||
assert_eq!(a.as_bytes()[14], b'4');
|
||||
assert!(matches!(a.as_bytes()[19], b'8' | b'9' | b'a' | b'b'), "{a}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>diagnostics.bug_report</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 8.5 L12 8.5 C14.209 8.5 16 10.291 16 12.5 L16 15.5 C16 17.709 14.209 19.5 12 19.5 L12 19.5 C9.791 19.5 8 17.709 8 15.5 L8 12.5 C8 10.291 9.791 8.5 12 8.5 Z" fill="none" stroke="#dde2ea" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.9 5.8 C9.9 4.64 10.84 3.7 12 3.7 C13.16 3.7 14.1 4.64 14.1 5.8 C14.1 6.96 13.16 7.9 12 7.9 C10.84 7.9 9.9 6.96 9.9 5.8 Z" fill="none" stroke="#dde2ea" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 10.5 L12 18" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 11.5 L4.8 9.8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 14.5 L4.4 14.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.3 17.4 L5 19.4" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 11.5 L19.2 9.8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 14.5 L19.6 14.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.7 17.4 L19 19.4" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>geometry.datum.axis</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M7 17 L19 5" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20 4 L15 5.2 M20 4 L18.8 9" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.5 18.5 L2.5 21.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
|
||||
<path d="M8.8 12 C8.8 10.233 10.233 8.8 12 8.8 C13.767 8.8 15.2 10.233 15.2 12 C15.2 13.767 13.767 15.2 12 15.2 C10.233 15.2 8.8 13.767 8.8 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.6 12 C10.6 11.227 11.227 10.6 12 10.6 C12.773 10.6 13.4 11.227 13.4 12 C13.4 12.773 12.773 13.4 12 13.4 C11.227 13.4 10.6 12.773 10.6 12 Z" fill="#f0b44a"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>geometry.datum.point</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 16 L9 10 L21 10 L15 16 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L12 11 M8 7 L16 7" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.2 7 C10.2 6.006 11.006 5.2 12 5.2 C12.994 5.2 13.8 6.006 13.8 7 C13.8 7.994 12.994 8.8 12 8.8 C11.006 8.8 10.2 7.994 10.2 7 Z" fill="#f0b44a"/>
|
||||
<path d="M12 12.5 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.block</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M4 6 L16 3 L20 15 L8 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 5 L12.33 16.33 M12 4 L16.67 17.67 M5.33 10.33 L17.33 7 M6.67 14.67 L18.67 11" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 559 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.blocks</title>
|
||||
<title>mesh.cells</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
Before Width: | Height: | Size: 1008 B After Width: | Height: | Size: 1003 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.mapped</title>
|
||||
<title>mesh.hex.generate</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 884 B |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.sweep</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 8 L12 17 L3 17 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 8 L7.5 17 M3 12.5 L12 12.5" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L8 4 L21 4 L21 13 L12 17 M12 8 L21 4" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 13 L20 10 M18.17 12.63 L20 10 L16.8 9.88" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 825 B |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.mirror</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 2 L12 22" fill="none" stroke="#4f9ce8" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 1.5"/>
|
||||
<path d="M10 5 L3 7 L3 17 L10 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 12 L10 12 M6.5 6 L6.5 18" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 5 L21 7 L21 17 L14 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 12 L21 12 M17.5 6 L17.5 18" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.node.relax</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 12 C3 7.029 7.029 3 12 3 C16.971 3 21 7.029 21 12 C21 16.971 16.971 21 12 21 C7.029 21 3 16.971 3 12 Z" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 2"/>
|
||||
<path d="M5 12 L19 12 M12 5 L12 19" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.6 12 C9.6 10.675 10.675 9.6 12 9.6 C13.325 9.6 14.4 10.675 14.4 12 C14.4 13.325 13.325 14.4 12 14.4 C10.675 14.4 9.6 13.325 9.6 12 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1017 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.size.seed</title>
|
||||
<title>mesh.seed.edge</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 19 C7 5 17 5 21 19" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.7 19 C1.7 18.282 2.282 17.7 3 17.7 C3.718 17.7 4.3 18.282 4.3 19 C4.3 19.718 3.718 20.3 3 20.3 C2.282 20.3 1.7 19.718 1.7 19 Z" fill="#4f9ce8"/>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
{"shapes": [{"rect": [8, 8.5, 8, 11], "radius": 4, "stroke": "line", "width": 1.6}, {"circle": [12, 5.8, 2.1], "stroke": "line", "width": 1.6}, {"line": [12, 10.5, 12, 18], "stroke": "accent", "width": 1.5}, {"line": [8, 11.5, 4.8, 9.8], "stroke": "line"}, {"line": [8, 14.5, 4.4, 14.5], "stroke": "line"}, {"line": [8.3, 17.4, 5, 19.4], "stroke": "line"}, {"line": [16, 11.5, 19.2, 9.8], "stroke": "line"}, {"line": [16, 14.5, 19.6, 14.5], "stroke": "line"}, {"line": [15.7, 17.4, 19, 19.4], "stroke": "line"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M7 17 L19 5", "stroke": "accent", "width": 2}, {"d": "M20 4 L15 5.2 M20 4 L18.8 9", "stroke": "accent", "width": 2}, {"d": "M5.5 18.5 L2.5 21.5", "stroke": "line", "dash": [1.5, 2]}, {"circle": [12, 12, 3.2], "fill": "soft", "stroke": "line", "width": 1.25}, {"circle": [12, 12, 1.4], "fill": "warm", "stroke": null}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 16 L9 10 L21 10 L15 16 Z", "fill": "soft", "stroke": "line", "width": 1.25}, {"d": "M12 3 L12 11 M8 7 L16 7", "stroke": "accent", "width": 2}, {"circle": [12, 7, 1.8], "fill": "warm", "stroke": null}, {"d": "M12 12.5 L12 20", "stroke": "line", "dash": [1.5, 2]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M4 6 L16 3 L20 15 L8 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M8 5 L12.33 16.33 M12 4 L16.67 17.67 M5.33 10.33 L17.33 7 M6.67 14.67 L18.67 11", "stroke": "accent", "width": 1}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"shapes": [{"rect": [3, 8, 9, 9], "fill": "soft", "stroke": "line"}, {"d": "M7.5 8 L7.5 17 M3 12.5 L12 12.5", "stroke": "line", "width": 1}, {"d": "M3 8 L8 4 L21 4 L21 13 L12 17 M12 8 L21 4", "stroke": "line", "width": 1.25}, {"d": "M14 13 L20 10 M18.17 12.63 L20 10 L16.8 9.88", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M12 2 L12 22", "stroke": "accent", "width": 1.25, "dash": [2, 1.5]}, {"d": "M10 5 L3 7 L3 17 L10 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 12 L10 12 M6.5 6 L6.5 18", "stroke": "line", "width": 1}, {"d": "M14 5 L21 7 L21 17 L14 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M14 12 L21 12 M17.5 6 L17.5 18", "stroke": "line", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [12, 12, 9], "stroke": "line", "width": 1.25, "dash": [2, 2]}, {"d": "M5 12 L19 12 M12 5 L12 19", "stroke": "line", "width": 1.0}, {"d": "M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5", "stroke": "line", "width": 1.0}, {"circle": [12, 12, 2.4], "fill": "accent", "stroke": "accent"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M2.5 13 C5.5 7.5 18.5 7.5 21.5 13 C18.5 18.5 5.5 18.5 2.5 13 Z", "fill": "soft", "stroke": "line"}, {"circle": [12, 13, 2.6], "fill": "accent", "stroke": "accent", "width": 1}, {"d": "M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5", "stroke": "warm", "width": 1.5}]}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>view.show_all</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M2.5 13 C5.5 7.5 18.5 7.5 21.5 13 C18.5 18.5 5.5 18.5 2.5 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.4 13 C9.4 11.564 10.564 10.4 12 10.4 C13.436 10.4 14.6 11.564 14.6 13 C14.6 14.436 13.436 15.6 12 15.6 C10.564 15.6 9.4 14.436 9.4 13 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -25,6 +25,9 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# app.update.check (docs/updates.md); the browser build has no update check.
|
||||
fieldcraft-update = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
png = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
@@ -47,6 +47,7 @@ impl Autosaver {
|
||||
let bytes = s.file_bytes(None)?;
|
||||
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
|
||||
self.saved = Some(s.revision());
|
||||
crate::crash::autosaved(s, &self.slot.file());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ mod tests {
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed[0]["id"], id);
|
||||
assert_eq!(listed[0]["nodes"], 1);
|
||||
assert!(listed[0]["age"].as_u64().is_some_and(|a| a < 60), "{listed}");
|
||||
let mut next = Session::default();
|
||||
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
|
||||
assert_eq!(r["nodes"], 1);
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Bug report drafts (Help › Report a Bug, and Report it after a crash): a GitHub new-issue link
|
||||
//! that fills in the bug form (`.github/ISSUE_TEMPLATE/bug.yml`), and the files to attach.
|
||||
//!
|
||||
//! Nothing here sends anything. The user reviews the draft, then their browser opens the issue
|
||||
//! form, which they submit or not.
|
||||
//!
|
||||
//! What a draft holds by default: the version, the OS, what went wrong (the user's words, the
|
||||
//! last error or the crash's message) and, for a crash, where it happened. The user's home
|
||||
//! folder is shown as `~`. Only with **Share my session** does it also carry the commands that
|
||||
//! built the model (the replayable journal, which can name the user's files), and only with
|
||||
//! **Include the model** as well the model file. A session too long for the link (GitHub
|
||||
//! refuses long new-issue links) goes out as a file to attach and on the clipboard instead; the
|
||||
//! model file is always one to attach.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::crash::without_home;
|
||||
|
||||
/// The issue form.
|
||||
pub const ISSUE_URL: &str = "https://github.com/jyviko/fieldcraft/issues/new";
|
||||
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
|
||||
pub const MAX_URL: usize = 7000;
|
||||
/// The session file a draft attaches.
|
||||
pub const SESSION_FILE: &str = "fieldcraft-session.json";
|
||||
/// The model file a draft attaches.
|
||||
pub const MODEL_FILE: &str = "model.fieldcraft";
|
||||
/// Backtrace frames a crash's draft quotes.
|
||||
const FRAMES: usize = 12;
|
||||
/// A field's text is cut at this many bytes.
|
||||
const MAX_FIELD: usize = 3000;
|
||||
|
||||
/// What to put in a draft.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Input {
|
||||
pub title: String,
|
||||
/// What happened: the user's words, or the last error.
|
||||
pub observed: String,
|
||||
/// A crash report (`crash::read`), when reporting one.
|
||||
pub crash: Option<Value>,
|
||||
/// Attach the replayable commands.
|
||||
pub share_session: bool,
|
||||
/// The commands (each `{command, params}`) that rebuild the model from `model` (or from an
|
||||
/// empty model when there is none).
|
||||
pub commands: Vec<Value>,
|
||||
/// Attach the model file too (only with `share_session`).
|
||||
pub include_model: bool,
|
||||
pub model: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A file the user attaches to the issue.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
/// What it is, for the review.
|
||||
pub what: String,
|
||||
}
|
||||
|
||||
/// A draft ready for review.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub title: String,
|
||||
/// The bug form's fields by id (`observed`, `reproduction`, `evidence`, `version`), as the
|
||||
/// link fills them.
|
||||
pub fields: Vec<(&'static str, String)>,
|
||||
/// The link that opens the filled form.
|
||||
pub url: String,
|
||||
/// Files to attach (the session when too long for the link, the model).
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// Text to put on the clipboard (the session, when it is an attachment).
|
||||
pub clipboard: Option<String>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// The draft as JSON (attachments by name, size and what they are).
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"title": self.title,
|
||||
"fields": self.fields.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<serde_json::Map<String, Value>>(),
|
||||
"url": self.url,
|
||||
"attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::<Vec<_>>(),
|
||||
"clipboard": self.clipboard.as_ref().map(String::len),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `v0.5.8 (abc1234), macos aarch64`.
|
||||
pub fn version_line(version: &str, commit: &str) -> String {
|
||||
let commit = if commit.is_empty() { String::new() } else { format!(" ({commit})") };
|
||||
format!("v{version}{commit}, {} {}", std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// Percent-encode `s` for a query value (RFC 3986 unreserved characters stay).
|
||||
pub fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cut(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = n;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}\n[cut: {} bytes in all]", s.get(..end).unwrap_or_default(), s.len())
|
||||
}
|
||||
|
||||
fn link(title: &str, fields: &[(&'static str, String)]) -> String {
|
||||
let mut url = format!("{ISSUE_URL}?template=bug.yml&title={}", encode(title));
|
||||
for (k, v) in fields {
|
||||
if !v.is_empty() {
|
||||
url.push_str(&format!("&{k}={}", encode(v)));
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// What a crash report says, for the evidence field: its kind, message, place, command (its id;
|
||||
/// its parameters only with the session) and the top of its backtrace.
|
||||
fn crash_evidence(report: &Value, share: bool) -> String {
|
||||
let s = |k: &str| report.get(k).and_then(Value::as_str).unwrap_or("?");
|
||||
let mut out =
|
||||
format!("Crash report `{}` ({}), seen {} time(s)\n\n", s("id"), s("kind"), report.get("count").and_then(Value::as_u64).unwrap_or(1));
|
||||
out.push_str(&format!("Message: {}\nAt: {}\n", without_home(s("message")), without_home(s("location"))));
|
||||
if let Some(c) = report.get("command").filter(|c| !c.is_null()) {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("?");
|
||||
match c.get("params").filter(|_| share) {
|
||||
Some(p) => out.push_str(&format!("Command: `{id}` {}\n", without_home(&p.to_string()))),
|
||||
None => out.push_str(&format!("Command: `{id}`\n")),
|
||||
}
|
||||
}
|
||||
if let Some(a) = report.get("last_activity").and_then(Value::as_str) {
|
||||
out.push_str(&format!("Last activity: {a}\n"));
|
||||
}
|
||||
let frames: Vec<&str> = report.get("backtrace").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).take(FRAMES).collect();
|
||||
if !frames.is_empty() {
|
||||
out.push_str("\nBacktrace (top):\n```\n");
|
||||
for f in frames {
|
||||
out.push_str(&without_home(f));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("```\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The draft for `input`, by the [`version_line`] `version`.
|
||||
pub fn draft(input: &Input, version: &str) -> Draft {
|
||||
let crash = input.crash.as_ref();
|
||||
let mut title = input.title.trim().to_string();
|
||||
if title.is_empty() {
|
||||
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
|
||||
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
|
||||
Some(_) => "Internal error".to_string(),
|
||||
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
|
||||
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
|
||||
None => "Bug report".to_string(),
|
||||
},
|
||||
};
|
||||
if let Some(c) = crash.and_then(|c| c.get("command")).and_then(|c| c.get("command")).and_then(Value::as_str) {
|
||||
title.push_str(&format!(" in {c}"));
|
||||
}
|
||||
}
|
||||
let title = cut(&without_home(&title), 200);
|
||||
let mut observed = without_home(input.observed.trim());
|
||||
if observed.is_empty()
|
||||
&& let Some(m) = crash.and_then(|c| c.get("message")).and_then(Value::as_str)
|
||||
{
|
||||
observed = without_home(m);
|
||||
}
|
||||
let observed = cut(&observed, MAX_FIELD);
|
||||
let share = input.share_session;
|
||||
let mut evidence = crash.map(|c| crash_evidence(c, share)).unwrap_or_default();
|
||||
let mut attachments = Vec::new();
|
||||
let mut clipboard = None;
|
||||
let mut reproduction = String::new();
|
||||
// The session: the replayable script, from the attached model when there is one.
|
||||
let model = input.model.as_ref().filter(|_| share && input.include_model);
|
||||
let script = share.then(|| {
|
||||
let mut steps = Vec::new();
|
||||
if model.is_some() {
|
||||
steps.push(json!({"command": "file.open", "params": {"path": MODEL_FILE}}));
|
||||
}
|
||||
steps.extend(input.commands.iter().cloned());
|
||||
Value::Array(steps)
|
||||
});
|
||||
if let Some(m) = model {
|
||||
attachments.push(Attachment { name: MODEL_FILE.into(), bytes: m.clone(), what: format!("the model ({} KB)", m.len().div_ceil(1024)) });
|
||||
}
|
||||
if let Some(script) = &script {
|
||||
let n = script.as_array().map_or(0, Vec::len);
|
||||
reproduction = format!("Replay the attached session ({n} command(s)): `fieldcraft-cli run {SESSION_FILE}`");
|
||||
if model.is_some() {
|
||||
reproduction.push_str(&format!(" in a folder holding `{MODEL_FILE}` too"));
|
||||
} else if crash.is_some_and(|c| c.get("files").and_then(|f| f.get("model")).is_some()) {
|
||||
reproduction.push_str(" (the crash replays from the model, which is not attached)");
|
||||
}
|
||||
reproduction.push('.');
|
||||
}
|
||||
let inline = |evidence: &str, script: &Value| {
|
||||
let text = serde_json::to_string_pretty(script).unwrap_or_default();
|
||||
format!("{evidence}\nSession ({SESSION_FILE}):\n```json\n{text}\n```\n")
|
||||
};
|
||||
let fields = |evidence: String| {
|
||||
vec![("observed", observed.clone()), ("reproduction", reproduction.clone()), ("evidence", evidence), ("version", version.to_string())]
|
||||
};
|
||||
// The session in the link when it fits, else a file to attach (also on the clipboard).
|
||||
let mut url = String::new();
|
||||
if let Some(script) = &script {
|
||||
let candidate = fields(inline(&evidence, script));
|
||||
let u = link(&title, &candidate);
|
||||
if u.len() <= MAX_URL {
|
||||
evidence = inline(&evidence, script);
|
||||
url = u;
|
||||
} else {
|
||||
let text = serde_json::to_string_pretty(script).unwrap_or_default();
|
||||
evidence.push_str(&format!(
|
||||
"\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
|
||||
));
|
||||
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
|
||||
clipboard = Some(text);
|
||||
}
|
||||
}
|
||||
if model.is_some() {
|
||||
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
|
||||
}
|
||||
if url.is_empty() || model.is_some() {
|
||||
let mut f = fields(evidence.clone());
|
||||
url = link(&title, &f);
|
||||
// Still too long (a very long message): cut the longest field until it fits.
|
||||
while url.len() > MAX_URL {
|
||||
let Some((i, len)) = f.iter().enumerate().map(|(i, (_, v))| (i, v.len())).max_by_key(|(_, l)| *l) else { break };
|
||||
if len < 200 {
|
||||
break;
|
||||
}
|
||||
if let Some((_, v)) = f.get_mut(i) {
|
||||
*v = cut(v, len.saturating_sub(url.len() - MAX_URL + 64).min(len / 2 + 1));
|
||||
}
|
||||
url = link(&title, &f);
|
||||
}
|
||||
evidence = f.get(2).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
}
|
||||
let fields = fields(evidence);
|
||||
// The fields as the link carries them (a cut field included).
|
||||
let fields = if url == link(&title, &fields) { fields } else { decode_fields(&url) };
|
||||
Draft { title, fields, url, attachments, clipboard }
|
||||
}
|
||||
|
||||
/// Write a draft's attachments to `bug-reports/<time>/` in the app's data folder (the user
|
||||
/// attaches them from there); returns the folder.
|
||||
pub fn save_attachments(draft: &Draft) -> Result<std::path::PathBuf, String> {
|
||||
let root = fieldcraft_io::recovery::data_dir().ok_or("no data folder on this system")?;
|
||||
let dir = root.join("bug-reports").join(crate::crash::stamp_now());
|
||||
fieldcraft_io::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
for a in &draft.attachments {
|
||||
fieldcraft_io::fs::write_atomic(&dir.join(&a.name), &a.bytes, false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The fields a link carries (for the review, when a field had to be cut).
|
||||
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
|
||||
let query = url.split_once('?').map_or("", |(_, q)| q);
|
||||
["observed", "reproduction", "evidence", "version"]
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
let v = query.split('&').find_map(|kv| kv.strip_prefix(k).and_then(|r| r.strip_prefix('='))).unwrap_or_default();
|
||||
(k, decode(v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
match (c, hex) {
|
||||
(b'%', Some(v)) => {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn home() -> String {
|
||||
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/someone".into())
|
||||
}
|
||||
|
||||
fn crash() -> Value {
|
||||
json!({
|
||||
"id": "20261014-101500-00000000deadbeef", "kind": "command_panic", "count": 2,
|
||||
"message": format!("index out of bounds in {}/model.fieldcraft", home()),
|
||||
"location": "crates/mesh/src/tet.rs:42",
|
||||
"command": {"command": "mesh.tet.generate", "params": {"body": "Plate", "path": format!("{}/secret/plate.step", home())}},
|
||||
"backtrace": ["fieldcraft_mesh::tet::insert (crates/mesh/src/tet.rs:42)"],
|
||||
"files": {"model": "model.fieldcraft", "replay": "replay.json"},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_sharing_a_draft_names_no_paths_commands_or_model() {
|
||||
let input = Input {
|
||||
observed: format!("cannot open {}/parts/bracket.solidcraft", home()),
|
||||
crash: Some(crash()),
|
||||
commands: vec![json!({"command": "geometry.open", "params": {"path": format!("{}/parts/bracket.solidcraft", home())}})],
|
||||
model: Some(b"model bytes".to_vec()),
|
||||
include_model: true,
|
||||
..Default::default()
|
||||
};
|
||||
let d = draft(&input, "v1.2.3, testos x");
|
||||
assert!(d.url.starts_with("https://github.com/jyviko/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
|
||||
assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
|
||||
let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
|
||||
assert!(!all.contains(&home()), "no home folder: {all}");
|
||||
assert!(!all.contains("secret") && !all.contains("geometry.open"), "no parameters or commands: {all}");
|
||||
assert!(all.contains("~/parts/bracket.solidcraft"), "the error, its home shown as ~: {all}");
|
||||
assert!(all.contains("mesh.tet.generate") && all.contains("tet.rs:42") && all.contains("v1.2.3, testos x"), "{all}");
|
||||
assert_eq!(d.title, "Internal error in mesh.tet.generate");
|
||||
assert_eq!(decode_fields(&d.url), d.fields, "the review shows what the link carries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_session_goes_in_the_link() {
|
||||
let input = Input {
|
||||
title: "Solve fails".into(),
|
||||
share_session: true,
|
||||
commands: vec![json!({"command": "file.sample", "params": {"name": "cantilever"}}), json!({"command": "solve.run", "params": {}})],
|
||||
..Default::default()
|
||||
};
|
||||
let d = draft(&input, "v1");
|
||||
assert!(d.attachments.is_empty() && d.clipboard.is_none());
|
||||
let evidence = &d.fields.iter().find(|(k, _)| *k == "evidence").unwrap().1;
|
||||
assert!(evidence.contains("\"command\": \"solve.run\""), "{evidence}");
|
||||
assert!(d.url.len() <= MAX_URL);
|
||||
assert!(decode(&d.url).contains("file.sample"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_session_and_the_model_are_attachments() {
|
||||
let commands: Vec<Value> = (0..400).map(|i| json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": [i, 0, 0]}})).collect();
|
||||
let input =
|
||||
Input { share_session: true, include_model: true, commands, model: Some(vec![7; 3000]), crash: Some(crash()), ..Default::default() };
|
||||
let d = draft(&input, "v1");
|
||||
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
|
||||
let names: Vec<&str> = d.attachments.iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(names, [SESSION_FILE, MODEL_FILE]);
|
||||
let session: Value = serde_json::from_slice(&d.attachments[0].bytes).unwrap();
|
||||
assert_eq!(session[0], json!({"command": "file.open", "params": {"path": MODEL_FILE}}), "the replay opens the attached model");
|
||||
assert_eq!(session.as_array().unwrap().len(), 401);
|
||||
assert_eq!(d.clipboard.as_deref().map(str::as_bytes), Some(d.attachments[0].bytes.as_slice()));
|
||||
let text = decode(&d.url);
|
||||
assert!(text.contains("too long for this form") && text.contains(MODEL_FILE) && !text.contains("\"label\": 399"), "{text}");
|
||||
// With the session shared, the crash's command parameters are shown (home as ~).
|
||||
assert!(text.contains("~/secret/plate.step"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_message_is_cut_to_fit_the_link() {
|
||||
let input = Input { observed: "é".repeat(20_000), ..Default::default() };
|
||||
let d = draft(&input, "v1");
|
||||
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
|
||||
assert!(d.fields[0].1.contains("[cut:"));
|
||||
assert_eq!(decode_fields(&d.url), d.fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_round_trips() {
|
||||
let s = "a b&c=d/é\n```json";
|
||||
assert_eq!(decode(&encode(s)), s);
|
||||
assert_eq!(encode("a b&"), "a%20b%26");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A cancelled job ([`fieldcraft_model::progress::Progress::cancel`]) stops every solver and
|
||||
//! mesher promptly with `cancelled` and leaves the model as it was.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fieldcraft_geometry::sample::Plate;
|
||||
use fieldcraft_model::progress::{self, Progress};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::sample::impact;
|
||||
|
||||
/// How long a cancelled command may keep running: one increment, time step or pass, far
|
||||
/// below the minutes the runs below would take uncancelled.
|
||||
const PROMPT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Run `command` on `s` the way a job does and cancel it once it reports the stage `stage`
|
||||
/// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving
|
||||
/// the model and the journal as they were.
|
||||
fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
|
||||
let (model, journal) = (s.model.clone(), s.journal.len());
|
||||
let p = Progress::new();
|
||||
if stage.is_none() {
|
||||
p.cancel();
|
||||
}
|
||||
let (r, waited) = std::thread::scope(|scope| {
|
||||
let worker = {
|
||||
let p = p.clone();
|
||||
let s = &mut *s;
|
||||
let params = params.clone();
|
||||
scope.spawn(move || progress::scoped(Some(p), || s.execute(command, ¶ms)))
|
||||
};
|
||||
let mut at = Instant::now();
|
||||
if let Some(stage) = stage {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
let now = p.snapshot();
|
||||
if now.stage.starts_with(stage) && now.done > 0.0 {
|
||||
at = Instant::now();
|
||||
p.cancel();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot());
|
||||
}
|
||||
let r = worker.join().unwrap();
|
||||
(r, at.elapsed())
|
||||
});
|
||||
let e = r.expect_err(&format!("{command}: ran to the end although cancelled"));
|
||||
assert!(e.is_cancelled(), "{command}: {e}");
|
||||
assert_eq!(e.to_string(), "cancelled", "{command}");
|
||||
assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel");
|
||||
assert!(s.model == model, "{command}: the model changed");
|
||||
assert_eq!(s.journal.len(), journal, "{command}: journalled");
|
||||
}
|
||||
|
||||
/// A sample's script run on a fresh session.
|
||||
fn session(script: &Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.run_script(script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn plate() -> (crate::tests::TempDir, Session) {
|
||||
let (dir, path) = crate::tests::sample_design_file(&Plate::default());
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_stops_between_time_steps() {
|
||||
// A hundred times as long as the sample: hundreds of thousands of increments.
|
||||
let mut script = impact();
|
||||
for c in script.as_array_mut().unwrap() {
|
||||
if c["command"] == "model.step.create" {
|
||||
let p = &mut c["params"]["procedure"];
|
||||
(p["end_time"], p["output_interval"], p["history_interval"]) = (json!(4e-3), json!(4e-4), json!(2e-5));
|
||||
}
|
||||
}
|
||||
let mut s = session(&script);
|
||||
cancel(&mut s, "solve.explicit", json!({}), Some("Time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_stops_between_lattice_steps() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "flow-past"})).unwrap();
|
||||
cancel(&mut s, "solve.fluid", json!({}), Some("Fluid steps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonlinear_stops_between_increments() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "rubber-seal"})).unwrap();
|
||||
cancel(&mut s, "solve.nonlinear", json!({}), Some("Load factor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_heat_stops_between_increments() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "heated-bracket"})).unwrap();
|
||||
s.execute(
|
||||
"model.step.create",
|
||||
&json!({"name": "Warm", "procedure": {"type": "heat_transfer", "transient": {"end_time": 1e5, "increment": 1}},
|
||||
"boundary_conditions": ["Heater"], "loads": ["Air"]}),
|
||||
)
|
||||
.unwrap();
|
||||
cancel(&mut s, "solve.heat", json!({"step": "Warm"}), Some("Time increment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_frequency_and_solve_all_stop_at_the_factorization() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
cancel(&mut s, "solve.static", json!({}), None);
|
||||
cancel(&mut s, "solve.frequency", json!({}), None);
|
||||
cancel(&mut s, "solve.run", json!({}), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshers_stop_and_leave_no_mesh() {
|
||||
let (_dir, mut s) = plate();
|
||||
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 0.4}), Some("Refinement round"));
|
||||
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 2.5}), None);
|
||||
cancel(&mut s, "mesh.hex.generate", json!({"body": "Body1", "size": 2.5}), None);
|
||||
cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None);
|
||||
assert!(s.model.nodes().is_empty());
|
||||
}
|
||||
@@ -155,6 +155,10 @@ pub struct Capability {
|
||||
pub status: Status,
|
||||
/// Related commands in the entry's drop-down (edit, delete…).
|
||||
pub more: &'static [&'static str],
|
||||
/// Commands the entry's own form covers (an option of it does the same), or that act on what
|
||||
/// it makes from the browser's item menu (edit, delete): claimed by the entry for the command
|
||||
/// search and the ribbon's stage, but not listed in the ribbon.
|
||||
pub covers: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
@@ -194,7 +198,7 @@ impl Capability {
|
||||
}
|
||||
|
||||
const fn ready(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[] }
|
||||
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
const fn ready_more(
|
||||
@@ -208,12 +212,23 @@ const fn ready_more(
|
||||
Capability { more, ..ready(id, label, tab, panel, tip) }
|
||||
}
|
||||
|
||||
const fn covering(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
tab: Tab,
|
||||
panel: &'static str,
|
||||
tip: &'static str,
|
||||
covers: &'static [&'static str],
|
||||
) -> Capability {
|
||||
Capability { covers, ..ready(id, label, tab, panel, tip) }
|
||||
}
|
||||
|
||||
const fn soon(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, milestone: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[] }
|
||||
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
const fn deck(id: &'static str, label: &'static str, format: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[] }
|
||||
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
use Tab::*;
|
||||
@@ -223,7 +238,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
// ---- Geometry ----
|
||||
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
|
||||
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
|
||||
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"),
|
||||
ready_more(
|
||||
"geometry.bodies",
|
||||
"Bodies",
|
||||
Geometry,
|
||||
"Inspect",
|
||||
"List the open design's bodies, volumes and named faces, edges and vertices",
|
||||
&["geometry.centre"],
|
||||
),
|
||||
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
|
||||
ready(
|
||||
"mesh.surface.check",
|
||||
@@ -232,12 +254,50 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Inspect",
|
||||
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
|
||||
),
|
||||
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
|
||||
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
|
||||
// Edit and delete of datums and partitions are on the browser's item menu, not the ribbon.
|
||||
covering(
|
||||
"geometry.datum",
|
||||
"Datum Plane",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis",
|
||||
&["geometry.datum.edit", "geometry.datum.delete", "geometry.datum.suppress"],
|
||||
),
|
||||
covering(
|
||||
"geometry.datum.axis",
|
||||
"Datum Axis",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"A datum axis: principal axis, two points or datum points, point and direction",
|
||||
&["geometry.datum.axis.edit"],
|
||||
),
|
||||
covering(
|
||||
"geometry.datum.point",
|
||||
"Datum Point",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane",
|
||||
&["geometry.datum.point.edit"],
|
||||
),
|
||||
covering(
|
||||
"geometry.partition",
|
||||
"Partition",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
|
||||
&["geometry.partition.delete", "geometry.partition.suppress"],
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
|
||||
soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"),
|
||||
ready_more(
|
||||
"mesh.seed.edge",
|
||||
"Seed Edges",
|
||||
Mesh,
|
||||
"Size",
|
||||
"Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them",
|
||||
&["mesh.seed.clear", "mesh.seed.list"],
|
||||
),
|
||||
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
|
||||
ready(
|
||||
"mesh.tet.generate",
|
||||
@@ -256,30 +316,31 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged",
|
||||
),
|
||||
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
|
||||
ready_more(
|
||||
"mesh.hex.mapped",
|
||||
"Mapped Hex",
|
||||
covering(
|
||||
"mesh.hex.generate",
|
||||
"Hex Mesh",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"Mapped hex mesh of a six-sided block (curved faces allowed)",
|
||||
&["mesh.hex.generate"],
|
||||
"Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down)",
|
||||
&["mesh.hex.sweep", "mesh.hex.mapped"],
|
||||
),
|
||||
ready_more(
|
||||
"mesh.hex.sweep",
|
||||
"Sweep Hex",
|
||||
covering(
|
||||
"mesh.cells",
|
||||
"Partitioned Mesh",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target",
|
||||
&["mesh.hex.generate"],
|
||||
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
|
||||
&["mesh.hex.blocks"],
|
||||
),
|
||||
ready(
|
||||
"mesh.hex.blocks",
|
||||
"Block Hex",
|
||||
ready("mesh.block", "Mesh Block", Mesh, "Structured Hex", "A mapped block of hex, wedge or quad elements between corner points"),
|
||||
soon(
|
||||
"mesh.hex.decompose",
|
||||
"Decompose",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once",
|
||||
"M5",
|
||||
"Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh)",
|
||||
),
|
||||
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
|
||||
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
|
||||
ready(
|
||||
"mesh.hex.octree",
|
||||
@@ -293,16 +354,25 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
|
||||
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
|
||||
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more(
|
||||
"mesh.element.add",
|
||||
"Element",
|
||||
"mesh.node.relax",
|
||||
"Relax Nodes",
|
||||
Mesh,
|
||||
"Improve",
|
||||
"Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
|
||||
&["mesh.node.relax.apply"],
|
||||
),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]),
|
||||
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
|
||||
ready_more(
|
||||
"mesh.node.merge",
|
||||
"Merge Nodes",
|
||||
Mesh,
|
||||
"Edit",
|
||||
"Add an element on existing nodes, or a mapped block of them",
|
||||
&["mesh.element.remove", "mesh.block"],
|
||||
"Merge coincident nodes, keeping sets, loads and boundary conditions on the survivors; check for duplicates and cracks, renumber",
|
||||
&["mesh.check.duplicates", "mesh.renumber"],
|
||||
),
|
||||
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
|
||||
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
|
||||
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
|
||||
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
|
||||
@@ -372,7 +442,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
Loads,
|
||||
"Boundary Conditions",
|
||||
"Fixed, displacement or symmetry condition on a set or reference point",
|
||||
&["model.boundary_condition.edit", "model.boundary_condition.delete"],
|
||||
&["model.boundary_condition.edit", "model.boundary_condition.delete", "model.boundary_condition.symmetry"],
|
||||
),
|
||||
ready_more(
|
||||
"model.load.create",
|
||||
@@ -430,13 +500,13 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics and nonlinear static steps)",
|
||||
&["interaction.contact.edit", "interaction.contact.delete"],
|
||||
),
|
||||
ready_more(
|
||||
// Its edit and delete rows are General Contact's: they act on contacts of either kind.
|
||||
ready(
|
||||
"interaction.contact.pair",
|
||||
"Contact Pair",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps",
|
||||
&["interaction.contact.edit", "interaction.contact.delete"],
|
||||
),
|
||||
ready(
|
||||
"interaction.contact.find",
|
||||
@@ -445,7 +515,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Contact",
|
||||
"Find the parts of the mesh that touch, and make contact pairs of them",
|
||||
),
|
||||
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"),
|
||||
ready_more(
|
||||
"interaction.rigid_wall",
|
||||
"Rigid Wall",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"A fixed rigid plane a set's nodes cannot pass, with friction, in an explicit dynamics step",
|
||||
&["interaction.rigid_wall.delete"],
|
||||
),
|
||||
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
|
||||
ready_more(
|
||||
"model.spring.create",
|
||||
@@ -511,7 +588,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Displacement, rotation, reaction and stress at nodes, element section forces, a mode shape, or an explicit field frame (or click the model)",
|
||||
&["solve.history"],
|
||||
),
|
||||
ready("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set"),
|
||||
ready_more("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set", &["results.export.csv"]),
|
||||
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
|
||||
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
|
||||
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
|
||||
@@ -521,8 +598,10 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
||||
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
||||
// ---- Tools ----
|
||||
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
|
||||
ready("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model"),
|
||||
// Renaming starts from a definition's item menu (Rename…); the search finds it here too.
|
||||
ready_more("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary", &["model.rename"]),
|
||||
// New starts from the File menu; the search finds it here too.
|
||||
ready_more("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model", &["file.new"]),
|
||||
ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"),
|
||||
ready("file.save", "Save", Tools, "File", "Save the model to its .fieldcraft file"),
|
||||
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .fieldcraft file"),
|
||||
@@ -534,8 +613,25 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Open a model autosaved by an app that crashed or closed with unsaved changes",
|
||||
&["file.recovery.list", "file.recovery.discard"],
|
||||
),
|
||||
// Hide, show and isolate act on the browser's items, from their eyes and item menus.
|
||||
covering(
|
||||
"view.show_all",
|
||||
"Show All",
|
||||
Tools,
|
||||
"View",
|
||||
"Show every item hidden in the viewport (hide, show and isolate items from the browser)",
|
||||
&["view.hide", "view.show", "view.isolate"],
|
||||
),
|
||||
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
|
||||
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
|
||||
ready_more(
|
||||
"diagnostics.bug_report",
|
||||
"Report a Bug",
|
||||
Tools,
|
||||
"Help",
|
||||
"Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it",
|
||||
&["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_reports.delete", "diagnostics.crash_test", "app.update.check"],
|
||||
),
|
||||
];
|
||||
|
||||
/// The catalogue entry with this id.
|
||||
@@ -544,9 +640,12 @@ pub fn find(id: &str) -> Option<&'static Capability> {
|
||||
}
|
||||
|
||||
/// The entry that claims a command: the one running it, else the one listing it among its
|
||||
/// related commands. Every registered command has one (a test checks it).
|
||||
/// related commands or covering it. Every registered command has one (a test checks it).
|
||||
pub fn entry_of(command: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command)))
|
||||
CATALOGUE
|
||||
.iter()
|
||||
.find(|c| c.command() == Some(command))
|
||||
.or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command) || c.covers.contains(&command)))
|
||||
}
|
||||
|
||||
/// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script,
|
||||
@@ -589,7 +688,8 @@ pub fn features_page() -> String {
|
||||
let preset = c.preset();
|
||||
let main =
|
||||
c.command().map(|id| if preset.as_object().is_some_and(|o| !o.is_empty()) { format!("`{id} {preset}`") } else { format!("`{id}`") });
|
||||
let commands: Vec<String> = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect();
|
||||
let commands: Vec<String> =
|
||||
main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).chain(c.covers.iter().map(|id| format!("(`{id}`)"))).collect();
|
||||
let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", "));
|
||||
}
|
||||
}
|
||||
@@ -619,6 +719,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each command has one place in the ribbon: no two menu rows (an entry or a drop-down row)
|
||||
/// run the same command with the same preset, and a command an entry's form covers is no row.
|
||||
#[test]
|
||||
fn no_command_appears_twice_across_ribbon_menus() {
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
for c in CATALOGUE {
|
||||
let entry = c.command().map(|id| format!("{id} {}", c.preset()));
|
||||
for row in entry.into_iter().chain(c.more.iter().map(|id| format!("{id} {{}}"))) {
|
||||
assert!(!rows.contains(&row), "{}: `{row}` is in the ribbon twice", c.id);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
for c in CATALOGUE {
|
||||
for m in c.covers {
|
||||
assert!(!rows.iter().any(|r| r.split(' ').next() == Some(m)), "{}: `{m}` is covered by its form and also a ribbon row", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ribbon_follows_each_command_to_its_stage() {
|
||||
assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh));
|
||||
@@ -633,7 +752,7 @@ mod tests {
|
||||
assert_eq!(stage_tab("no.such"), None);
|
||||
// A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown.
|
||||
for c in CATALOGUE {
|
||||
for command in c.command().into_iter().chain(c.more.iter().copied()) {
|
||||
for command in c.command().into_iter().chain(c.more.iter().copied()).chain(c.covers.iter().copied()) {
|
||||
assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab);
|
||||
}
|
||||
}
|
||||
@@ -647,9 +766,9 @@ mod tests {
|
||||
assert!(find_command(command).is_some(), "{}: `{command}` is not a registered command", c.id);
|
||||
claimed.push(command);
|
||||
} else {
|
||||
assert!(c.more.is_empty(), "{}: a planned entry has no commands", c.id);
|
||||
assert!(c.more.is_empty() && c.covers.is_empty(), "{}: a planned entry has no commands", c.id);
|
||||
}
|
||||
for m in c.more {
|
||||
for m in c.more.iter().chain(c.covers) {
|
||||
assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id);
|
||||
claimed.push(m);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub const BLOCK: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Plate", "kind": "hex8|hex20|tet4|tet10|wedge6", "corners": [[x, y, z] × 8] (hex8 order: 1-2-3-4 counter-clockwise seen from 5-6-7-8), "divisions": [n12, n14, n15] (elements along 1→2, 1→4, 1→5), "bias": [1, 1, 1] (optional: last over first element size along each direction), "edges": [{"corners": [1, 2], "arc": {"center": [x, y, z]}} | {"corners": [2, 3], "ellipse": {"center": [x, y, z], "axes": [[a], [b]] (perpendicular)}} | {"corners": [5, 6], "name": "tip_edge"}] (optional: curved edges, the shorter arc between the corners, and edge names), "faces": {"1": "bottom", "4": "tip"} (optional: names of block faces, numbered as hex8 faces: 1 = 1-2-3-4, 2 = 5-6-7-8, 3 = 1-2-6-5, 4 = 2-3-7-6, 5 = 3-4-8-7, 6 = 4-1-5-8), "vertices": {"1": "A"} (optional: names of corners), "merge": distance (optional, default 1e-6 of the block's diagonal; 0 never joins)} — adds a mapped mesh of the block (transfinite interpolation of its edges; quadratic nodes on the curves), uses existing nodes within `merge` of new ones so blocks that share a face join (the shared face must have the same elements on both sides, or nothing changes), and tags the elements with the body and element faces, edges and nodes with the named faces, edges and vertices. 2D: "kind": "quad4|quad8|tri3|tri6", four "corners" at z = 0 (counter-clockwise from +z), "divisions": [n12, n14], "bias": [r, r], "face": "area" (the face of the body the elements fill, default `area`), edges and vertices numbered among the four corners; for plane stress, plane strain and axisymmetric sections"#,
|
||||
form: Some(super::forms::BLOCK),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: block_mesh,
|
||||
};
|
||||
|
||||
@@ -108,7 +109,7 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let region = match (planar, &p.face) {
|
||||
(true, face) => GeometryRef::face(p.body.clone(), face.clone().unwrap_or_else(|| "area".into()))?,
|
||||
(false, None) => GeometryRef::body(p.body.clone())?,
|
||||
(false, Some(_)) => return Err(EngineError::Params("`face` names the region of 2D elements; a solid block fills its body".into())),
|
||||
(false, Some(_)) => return Err(EngineError::invalid("face", "names the region of 2D elements; a solid block fills its body")),
|
||||
};
|
||||
if planar && !p.faces.is_empty() {
|
||||
return Err(EngineError::Params("a 2D patch has no faces to name: name its edges".into()));
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
//! Meshing a partitioned part cell by cell (`mesh.cells`): each cell (a body of the open design,
|
||||
//! typically made by `geometry.partition`) with its own method, all into one mesh, and the cells
|
||||
//! joined across the partitions that divide them.
|
||||
//!
|
||||
//! **Sweeping together (#288).** The cells of one body of the design whose method may sweep
|
||||
//! (`auto`, `hex`, `swept`, `extrude`, `revolve`) are first meshed together
|
||||
//! ([`hex_mesh_swept_cells`]): one cross-section with every cell's cut faces imprinted, one
|
||||
//! interval assignment and one set of layers, so cells meet node for node across every cut — a
|
||||
//! cut that ends on another included — with no edge seeds, and their nodes are shared. When they
|
||||
//! do not sweep together in one frame (the reply says why, per cell, as `not_swept_together`),
|
||||
//! each cell is meshed alone as below.
|
||||
//!
|
||||
//! **Joining.** Two cells on either side of a partition `P` meet on faces named
|
||||
//! `partition:P:a` and `partition:P:b`. Where the element faces of the two sides match node for
|
||||
//! node (the same divisions and kind on both sides, e.g. a mesh and its mirror image) the
|
||||
//! coincident nodes are merged and the mesh is conforming there. Elsewhere — a hex cell next to a
|
||||
//! tet cell, or two cells meshed with different divisions — the two surfaces are tied
|
||||
//! (`interaction.tie`; dual mortar constraints in the implicit analyses, so a constant stress
|
||||
//! crosses the interface exactly). Each tie goes one way, by a fixed order of the cells (hex cells
|
||||
//! main to tet cells), and each cell's tied cut faces make one tie, so no node is a secondary
|
||||
//! node of two of them and no interface is tied both ways: either would over-constrain it (#389).
|
||||
//! The tie is the transition between hex and tet cells; pyramid elements are not used, because
|
||||
//! they need their own element formulation in every solver and deck, while the tie works for any
|
||||
//! pair of meshes and keeps each cell's mesh as its method made it. The reply lists every
|
||||
//! interface and how it was joined.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::cdt::CdtOptions;
|
||||
use fieldcraft_mesh::hex::{Cell, CellsMesh, HexMesh, HexMethod, HexOptions, hex_mesh_swept_cells};
|
||||
use fieldcraft_mesh::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, add_cells, add_hex, hex_body};
|
||||
use super::mesh::{BodyTets, add_tets, mesh_body, quality_options};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const CELLS: CommandSpec = CommandSpec {
|
||||
id: "mesh.cells",
|
||||
label: "Partitioned Mesh",
|
||||
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, they are tied: each cell's tied cut faces make one tie, `tie_<cell>` (`/` as `_`; sets `<tie>_main`, the facing cells' cut faces, and `<tie>_secondary`, the cell's own), hex cells main to tet cells and otherwise by name, so a node is a secondary node of one such tie only; the ties an earlier run made on these cells are replaced. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied`"#,
|
||||
form: Some(super::forms::CELLS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: cells,
|
||||
};
|
||||
|
||||
/// Distance (relative to the cells' bounding-box diagonal) within which interface nodes coincide.
|
||||
const COINCIDENT: f64 = 1e-7;
|
||||
|
||||
/// A cell's method.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Method {
|
||||
Auto,
|
||||
Hex(HexMethod),
|
||||
Tet,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
fn parse(v: &Value) -> Result<Method> {
|
||||
let name = v.as_str().unwrap_or_default();
|
||||
Ok(match name {
|
||||
"auto" => Method::Auto,
|
||||
"hex" => Method::Hex(HexMethod::Auto),
|
||||
"tet" => Method::Tet,
|
||||
_ => match HexMethod::from_name(name).filter(|m| *m != HexMethod::Auto) {
|
||||
Some(m) => Method::Hex(m),
|
||||
None => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"method `{}` is not one of auto, hex, swept, extrude, sweep, revolve, mapped, tet",
|
||||
name.chars().take(40).collect::<String>()
|
||||
)));
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A meshed cell.
|
||||
enum Meshed<'a> {
|
||||
Hex(Input<'a>, HexMesh),
|
||||
Tet(BodyTets),
|
||||
}
|
||||
|
||||
fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("default").and_then(Value::as_str) == Some("blocks") {
|
||||
if p.get("methods").is_some_and(|m| m.as_object().is_none_or(|o| !o.is_empty())) {
|
||||
return Err(EngineError::param(
|
||||
"methods",
|
||||
crate::schema::ProblemKind::Invalid("must be empty with \"default\": \"blocks\" (every cell a block)".into()),
|
||||
));
|
||||
}
|
||||
return super::hex::hex_blocks(s, p);
|
||||
}
|
||||
let size = super::hex::number(p, "size")?;
|
||||
let quadratic = match p.get("order") {
|
||||
None => false,
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(1) => false,
|
||||
Some(2) => true,
|
||||
_ => return Err(EngineError::invalid("order", "must be 1 or 2")),
|
||||
},
|
||||
};
|
||||
let default = p.get("default").map(Method::parse).transpose()?.unwrap_or(Method::Auto);
|
||||
let methods: BTreeMap<String, Method> = match p.get("methods") {
|
||||
None => BTreeMap::new(),
|
||||
Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok((k.clone(), Method::parse(v)?))).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(EngineError::invalid("methods", "must map cell names to methods")),
|
||||
};
|
||||
let design = s.design.clone().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let names: Vec<String> = match p.get("bodies") {
|
||||
None => design.bodies.iter().map(|b| b.name.clone()).collect(),
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
};
|
||||
if names.is_empty() || names.len() > fieldcraft_mesh::hex::MAX_CELLS {
|
||||
return Err(EngineError::Params(format!("{} cells; mesh.cells meshes 1 to {}", names.len(), fieldcraft_mesh::hex::MAX_CELLS)));
|
||||
}
|
||||
for n in methods.keys() {
|
||||
if !names.contains(n) {
|
||||
return Err(EngineError::Params(format!("`methods` names `{n}`, which is not a cell being meshed")));
|
||||
}
|
||||
}
|
||||
// One size and tolerance from the cells' joint extent, as mesh.hex.blocks does.
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
let mut shapes = Vec::with_capacity(names.len());
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
if names.get(..i).is_some_and(|b| b.contains(n)) {
|
||||
return Err(EngineError::Params(format!("cell `{n}` is listed twice")));
|
||||
}
|
||||
let shape = design.body(n).ok_or_else(|| EngineError::Params(format!("no body `{n}`")))?;
|
||||
let m = shape.measure()?;
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(m.bbox_min[k]);
|
||||
hi[k] = hi[k].max(m.bbox_max[k]);
|
||||
}
|
||||
shapes.push(shape);
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt();
|
||||
if !(diagonal > 0.0 && diagonal.is_finite()) {
|
||||
return Err(EngineError::Params("the cells have no extent".into()));
|
||||
}
|
||||
let size = Some(size.unwrap_or(diagonal / 25.0));
|
||||
let tolerance = super::hex::number(p, "tolerance")?.unwrap_or(1e-3 * diagonal);
|
||||
// The tet mesher's parameters: the same size and tolerance, its own order names.
|
||||
let mut tet_params = json!({"size": size, "tolerance": tolerance, "order": if quadratic { "quadratic" } else { "linear" }});
|
||||
for key in ["weld", "weld_distance", "feature_angle"] {
|
||||
if let (Some(v), Some(o)) = (p.get(key), tet_params.as_object_mut()) {
|
||||
o.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let mut hex_params = tet_params.clone();
|
||||
if let Some(o) = hex_params.as_object_mut() {
|
||||
o.remove("order");
|
||||
}
|
||||
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
|
||||
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
|
||||
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
let method_of = |n: &str| methods.get(n).copied().unwrap_or(default);
|
||||
// The cells of one body that may sweep are swept together first (#288): one cross-section
|
||||
// and one set of layers for all of them, so they meet node for node across their cuts. Cells
|
||||
// that do not sweep together are meshed one by one, as below.
|
||||
let mut groups: BTreeMap<(String, HexMethod), Vec<usize>> = BTreeMap::new();
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
let sweep = match method_of(&shape.name) {
|
||||
Method::Auto | Method::Hex(HexMethod::Auto | HexMethod::Swept) => Some(HexMethod::Swept),
|
||||
Method::Hex(m @ (HexMethod::Extrude | HexMethod::Revolve)) => Some(m),
|
||||
Method::Hex(_) | Method::Tet => None,
|
||||
};
|
||||
if let (Some(m), Some(source)) = (sweep, design.source_of(&shape.name)) {
|
||||
groups.entry((source.to_string(), m)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut joint: Vec<Option<usize>> = vec![None; shapes.len()];
|
||||
let mut not_together: BTreeMap<usize, String> = BTreeMap::new();
|
||||
let mut swept: Vec<(String, Vec<usize>, Vec<Input>, CellsMesh)> = Vec::new();
|
||||
for ((source, m), members) in groups {
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let inputs: Vec<Input> = members
|
||||
.iter()
|
||||
.filter_map(|&i| shapes.get(i))
|
||||
.map(|shape| Ok(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }))
|
||||
.collect::<Result<_>>()?;
|
||||
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds)? {
|
||||
Ok(cm) => {
|
||||
for &i in &members {
|
||||
if let Some(slot) = joint.get_mut(i) {
|
||||
*slot = Some(swept.len());
|
||||
}
|
||||
}
|
||||
swept.push((source, members, inputs, cm));
|
||||
}
|
||||
Err(why) => {
|
||||
for &i in &members {
|
||||
not_together.insert(i, why.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut meshed: Vec<(Meshed, Value)> = Vec::with_capacity(shapes.len());
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
if joint.get(i).copied().flatten().is_some() {
|
||||
continue;
|
||||
}
|
||||
let method = method_of(&shape.name);
|
||||
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
|
||||
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
|
||||
let b = input()?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
|
||||
Ok((b, mesh))
|
||||
};
|
||||
let tet = |s: &Session| mesh_body(s, &tet_params, input()?, &cdt, &tet_options, quality, true);
|
||||
let (cell, mut info) = match method {
|
||||
Method::Hex(m) => {
|
||||
let (b, mesh) = hex(m)?;
|
||||
let info = json!({"method": mesh.report.method.name()});
|
||||
(Meshed::Hex(b, mesh), info)
|
||||
}
|
||||
Method::Tet => (Meshed::Tet(tet(s)?), json!({"method": "tet"})),
|
||||
Method::Auto => match hex(HexMethod::Auto) {
|
||||
Ok((b, mesh)) => {
|
||||
let info = json!({"method": mesh.report.method.name()});
|
||||
(Meshed::Hex(b, mesh), info)
|
||||
}
|
||||
Err(e) => (Meshed::Tet(tet(s)?), json!({"method": "tet", "why": e.to_string()})),
|
||||
},
|
||||
};
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("body".into(), json!(shape.name));
|
||||
if let Some(why) = not_together.get(&i) {
|
||||
o.insert("not_swept_together".into(), json!(why));
|
||||
}
|
||||
}
|
||||
meshed.push((cell, info));
|
||||
}
|
||||
|
||||
// Replace the cells' meshes (replace.rs), each after the labels before it; other bodies
|
||||
// and the definitions stay.
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, names.iter().map(String::as_str));
|
||||
let mut report: Vec<(usize, Value)> = Vec::with_capacity(shapes.len());
|
||||
let mut interfaces = Vec::new();
|
||||
for (source, members, inputs, cm) in &swept {
|
||||
add_cells(model, inputs, cm)?;
|
||||
let m = &cm.mesh;
|
||||
for (c, (&i, b)) in members.iter().zip(inputs).enumerate() {
|
||||
let elements: Vec<usize> = (0..m.element_count()).filter(|&e| cm.element_cell.get(e).is_some_and(|&x| x as usize == c)).collect();
|
||||
let nodes: BTreeSet<u32> = elements.iter().filter_map(|&e| m.element(e)).flatten().copied().collect();
|
||||
report.push((
|
||||
i,
|
||||
json!({"body": b.name, "method": m.report.method.name(), "swept_together": source, "description": m.report.description, "kind": m.kind.name(), "elements": elements.len(), "nodes": nodes.len()}),
|
||||
));
|
||||
}
|
||||
interfaces.extend(swept_interfaces(&design, inputs, cm));
|
||||
}
|
||||
let mut k = 0;
|
||||
for (i, _) in shapes.iter().enumerate() {
|
||||
if joint.get(i).copied().flatten().is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((cell, mut info)) = meshed.get_mut(k).map(|(c, v)| (c, std::mem::take(v))) else { break };
|
||||
k += 1;
|
||||
let (kind, elements, nodes) = match &*cell {
|
||||
Meshed::Hex(b, m) => {
|
||||
add_hex(model, b, m)?;
|
||||
(m.kind, m.element_count(), m.nodes.len())
|
||||
}
|
||||
Meshed::Tet(t) => {
|
||||
add_tets(model, &t.name, &t.face_names, &t.mesh)?;
|
||||
(t.mesh.kind, t.mesh.elements.len(), t.mesh.points.len())
|
||||
}
|
||||
};
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("kind".into(), json!(kind.name()));
|
||||
o.insert("elements".into(), json!(elements));
|
||||
o.insert("nodes".into(), json!(nodes));
|
||||
}
|
||||
report.push((i, info));
|
||||
}
|
||||
report.sort_by_key(|r| r.0);
|
||||
let report: Vec<Value> = report
|
||||
.into_iter()
|
||||
.map(|(_, mut info)| {
|
||||
let quality = info.get("body").and_then(Value::as_str).map(|b| cell_quality(model, b));
|
||||
if let (Some(o), Some(q)) = (info.as_object_mut(), quality) {
|
||||
o.insert("quality".into(), q);
|
||||
}
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
let tet: BTreeSet<String> = report
|
||||
.iter()
|
||||
.filter(|r| r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("tet")))
|
||||
.filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from))
|
||||
.collect();
|
||||
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})))
|
||||
}
|
||||
|
||||
/// Sweep the cells `inputs` together ([`hex_mesh_swept_cells`]) with `method` (extrude,
|
||||
/// revolve, or swept: either). `Ok(Err(why))` when they do not sweep together; limits and a
|
||||
/// cancel are errors.
|
||||
fn swept_together(
|
||||
p: &Value,
|
||||
inputs: &[Input],
|
||||
method: HexMethod,
|
||||
quadratic: bool,
|
||||
size: Option<f64>,
|
||||
tolerance: f64,
|
||||
seeds: &[fieldcraft_mesh::seeds::EdgeSeed],
|
||||
) -> Result<std::result::Result<CellsMesh, String>> {
|
||||
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
|
||||
let mut surfaces = Vec::with_capacity(inputs.len());
|
||||
for b in inputs {
|
||||
let options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
match BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &options) {
|
||||
Ok(surface) => surfaces.push(surface),
|
||||
Err(e) => return Ok(Err(format!("body `{}`: {e}", b.name))),
|
||||
}
|
||||
}
|
||||
let cells: Vec<Cell> = surfaces.iter().zip(inputs).map(|(surface, b)| Cell { name: &b.name, surface, faces: &b.surfaces }).collect();
|
||||
let options = HexOptions {
|
||||
method,
|
||||
quadratic,
|
||||
chordal_tolerance: tolerance,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: seeds.to_vec(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
match hex_mesh_swept_cells(&cells, &UniformSize(h), &options) {
|
||||
Ok(cm) => Ok(Ok(cm)),
|
||||
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
|
||||
Err(e @ fieldcraft_mesh::Error::Limit(_)) => Err(EngineError::Params(e.to_string())),
|
||||
Err(e) => Ok(Err(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The interfaces of cells swept together: each pair of cells whose elements share faces, with
|
||||
/// the partition between them and the nodes they share (one mesh: merged by construction).
|
||||
fn swept_interfaces(design: &fieldcraft_geometry::Design, inputs: &[Input], cm: &CellsMesh) -> Vec<Value> {
|
||||
let m = &cm.mesh;
|
||||
let mut owner: BTreeMap<[u32; 4], u32> = BTreeMap::new();
|
||||
let mut pairs: BTreeMap<(u32, u32), BTreeSet<u32>> = BTreeMap::new();
|
||||
for e in 0..m.element_count() {
|
||||
let (Some(h), Some(&c)) = (m.element(e), cm.element_cell.get(e)) else { continue };
|
||||
for local in fieldcraft_model::ElementKind::Hex8.faces() {
|
||||
let mut key = [0u32; 4];
|
||||
for (slot, &i) in key.iter_mut().zip(local.iter()) {
|
||||
*slot = h.get(i).copied().unwrap_or(0);
|
||||
}
|
||||
key.sort_unstable();
|
||||
match owner.insert(key, c) {
|
||||
Some(o) if o != c => pairs.entry((o.min(c), o.max(c))).or_default().extend(key),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The partitions whose cut faces a cell has, with the side: two cells meet across a
|
||||
// partition they are on opposite sides of.
|
||||
let cuts = |c: u32| -> BTreeSet<(String, String)> {
|
||||
let name = inputs.get(c as usize).map_or("", |b| b.name.as_str());
|
||||
let faces = design.body(name).map(|b| b.faces().iter().map(|f| f.name.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let (p, side) = f.strip_prefix("partition:")?.rsplit_once(':')?;
|
||||
let side = side.split_once('#').map_or(side, |(s, _)| s);
|
||||
Some((p.to_string(), side.to_string()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|((a, b), nodes)| {
|
||||
let (na, nb) = (inputs.get(a as usize).map_or("", |x| x.name.as_str()), inputs.get(b as usize).map_or("", |x| x.name.as_str()));
|
||||
let theirs = cuts(b);
|
||||
let partition =
|
||||
cuts(a).into_iter().find(|(p, side)| theirs.iter().any(|(q, other)| q == p && other != side)).map(|(p, _)| p).unwrap_or_default();
|
||||
json!({"partition": partition, "cells": [na, nb], "joined": "merged", "nodes": nodes.len(), "swept_together": true})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cell's mesh in numbers: its volume, smallest and mean scaled Jacobian and largest edge
|
||||
/// ratio.
|
||||
fn cell_quality(model: &Model, body: &str) -> Value {
|
||||
let (mut volume, mut min, mut sum, mut n, mut ratio) = (0.0, f64::INFINITY, 0.0, 0usize, None::<f64>);
|
||||
for label in model.body_elements(body) {
|
||||
let Some(e) = model.element(label) else { continue };
|
||||
let xyz: Vec<[f64; 3]> = e.nodes.iter().filter_map(|&l| model.node(l).map(|x| x.xyz)).collect();
|
||||
let Ok(q) = fieldcraft_mesh::quality::element_quality(e.kind, &xyz) else { continue };
|
||||
volume += q.size;
|
||||
if let Some(r) = q.get(fieldcraft_mesh::quality::Metric::EdgeRatio) {
|
||||
ratio = Some(ratio.map_or(r, |x| x.max(r)));
|
||||
}
|
||||
if let Some(sj) = q.get(fieldcraft_mesh::quality::Metric::ScaledJacobian) {
|
||||
min = min.min(sj);
|
||||
sum += sj;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let min = if n > 0 { Some(min) } else { None };
|
||||
json!({"volume": volume, "min_scaled_jacobian": min, "mean_scaled_jacobian": if n > 0 { Some(sum / n as f64) } else { None }, "max_edge_ratio": ratio})
|
||||
}
|
||||
|
||||
/// One side of an interface: a cell's element faces on one side of a partition.
|
||||
struct Side {
|
||||
body: String,
|
||||
/// The face name (`partition:P:a`), which selects its pieces too.
|
||||
face: String,
|
||||
faces: Vec<ElementFace>,
|
||||
nodes: BTreeSet<u32>,
|
||||
lo: [f64; 3],
|
||||
hi: [f64; 3],
|
||||
}
|
||||
|
||||
/// The element faces of each cell tagged with a partition's cut faces, by partition and side.
|
||||
fn sides(model: &Model, cells: &[String]) -> BTreeMap<String, [Vec<Side>; 2]> {
|
||||
let c = model.classification();
|
||||
// Tag → (partition, side), for cut faces of the meshed cells.
|
||||
let mut cut: BTreeMap<u32, (String, usize, String)> = BTreeMap::new();
|
||||
for (t, r) in c.entities().iter().enumerate() {
|
||||
let Entity::Face(name) = &r.entity else { continue };
|
||||
if !cells.contains(&r.body) {
|
||||
continue;
|
||||
}
|
||||
let base = name.split_once('#').map_or(name.as_str(), |(b, _)| b);
|
||||
let Some(rest) = base.strip_prefix("partition:") else { continue };
|
||||
let Some((partition, side)) = rest.rsplit_once(':') else { continue };
|
||||
let side = match side {
|
||||
"a" => 0,
|
||||
"b" => 1,
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(t) = u32::try_from(t) {
|
||||
cut.insert(t, (partition.to_string(), side, r.body.clone()));
|
||||
}
|
||||
}
|
||||
let mut grouped: BTreeMap<(String, usize, String), Vec<ElementFace>> = BTreeMap::new();
|
||||
for (&element, tags) in c.elements() {
|
||||
for (&face, t) in &tags.faces {
|
||||
if let Some(key) = cut.get(t) {
|
||||
grouped.entry(key.clone()).or_default().push(ElementFace { element, face });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: BTreeMap<String, [Vec<Side>; 2]> = BTreeMap::new();
|
||||
for ((partition, side, body), faces) in grouped {
|
||||
let nodes: BTreeSet<u32> = faces.iter().filter_map(|f| model.face_nodes(*f)).flatten().collect();
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for n in nodes.iter().filter_map(|&n| model.node(n)) {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(n.xyz[k]);
|
||||
hi[k] = hi[k].max(n.xyz[k]);
|
||||
}
|
||||
}
|
||||
let face = format!("partition:{partition}:{}", if side == 0 { "a" } else { "b" });
|
||||
if let Some(slot) = out.entry(partition).or_insert_with(|| [Vec::new(), Vec::new()]).get_mut(side) {
|
||||
slot.push(Side { body, face, faces, nodes, lo, hi });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join the cells across every partition: merge where the two sides match node for node, tie
|
||||
/// where they do not. Returns the interfaces.
|
||||
///
|
||||
/// Ties go one way between any two cells, by a fixed order of the cells (hex before tet, then by
|
||||
/// name; the earlier one is the main side), so no two ties hold one interface both ways, and
|
||||
/// each cell's tied cut faces make one tie, `tie_<cell>` (its secondary surface; the main one is
|
||||
/// the facing cells' cut faces), so a node where two of them meet is a secondary node once.
|
||||
/// The ties of an earlier run on these cells are replaced.
|
||||
fn join(model: &mut Model, cells: &[String], tet: &BTreeSet<String>, tol: f64) -> Result<Vec<Value>> {
|
||||
remove_cut_ties(model, cells)?;
|
||||
let rank = |cell: &str| (tet.contains(cell), cell.to_string());
|
||||
let mut out = Vec::new();
|
||||
// Per secondary cell: its cut faces and the facing cells' ones, as (body, face).
|
||||
let mut tied: BTreeMap<String, (BTreeSet<(String, String)>, BTreeSet<(String, String)>)> = BTreeMap::new();
|
||||
let mut tied_pairs: Vec<Value> = Vec::new();
|
||||
for (partition, [a_sides, b_sides]) in sides(model, cells) {
|
||||
for a in &a_sides {
|
||||
for b in &b_sides {
|
||||
// Faces on one plane overlap when their boxes share an area, not just an edge.
|
||||
let shared = (0..3).filter(|&i| a.lo[i].max(b.lo[i]) < a.hi[i].min(b.hi[i]) - tol).count();
|
||||
let touching = (0..3).all(|i| a.lo[i] <= b.hi[i] + tol && b.lo[i] <= a.hi[i] + tol);
|
||||
if !(touching && shared >= 2) {
|
||||
continue;
|
||||
}
|
||||
if let Some(map) = matching(model, a, b, tol) {
|
||||
let count = map.len();
|
||||
model.merge_nodes(&map)?;
|
||||
out.push(json!({"partition": partition, "cells": [a.body, b.body], "joined": "merged", "nodes": count}));
|
||||
continue;
|
||||
}
|
||||
let (main, secondary) = if rank(&a.body) <= rank(&b.body) { (a, b) } else { (b, a) };
|
||||
let entry = tied.entry(secondary.body.clone()).or_default();
|
||||
entry.0.insert((secondary.body.clone(), secondary.face.clone()));
|
||||
entry.1.insert((main.body.clone(), main.face.clone()));
|
||||
tied_pairs.push(json!({
|
||||
"partition": partition,
|
||||
"cells": [a.body, b.body],
|
||||
"joined": "tied",
|
||||
"main": main.body,
|
||||
"secondary": secondary.body,
|
||||
"faces": [a.faces.len(), b.faces.len()],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (cell, (own, facing)) in tied {
|
||||
let base = tie_name(&cell);
|
||||
let mut name = base.clone();
|
||||
let mut k = 1;
|
||||
while model.tie(&name).is_some() || model.set(&format!("{name}_main")).is_some() || model.set(&format!("{name}_secondary")).is_some() {
|
||||
k += 1;
|
||||
name = format!("{base}_{k}");
|
||||
}
|
||||
let set = |faces: &BTreeSet<(String, String)>| -> Result<Set> {
|
||||
Ok(Set::of_geometry(faces.iter().map(|(b, f)| GeometryRef::face(b.as_str(), f.as_str())).collect::<fieldcraft_model::Result<_>>()?))
|
||||
};
|
||||
let (main_set, secondary_set) = (format!("{name}_main"), format!("{name}_secondary"));
|
||||
model.add_set(&main_set, set(&facing)?)?;
|
||||
model.add_set(&secondary_set, set(&own)?)?;
|
||||
model.add_tie(&name, Tie::new(&main_set, &secondary_set))?;
|
||||
names.insert(cell, name);
|
||||
}
|
||||
for mut pair in tied_pairs {
|
||||
let name = pair.get("secondary").and_then(Value::as_str).and_then(|c| names.get(c)).cloned();
|
||||
if let (Some(o), Some(name)) = (pair.as_object_mut(), name) {
|
||||
o.insert("tie".into(), json!(name));
|
||||
}
|
||||
out.push(pair);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The name of the tie that holds `cell`'s tied cut faces.
|
||||
fn tie_name(cell: &str) -> String {
|
||||
let name = format!("tie_{cell}");
|
||||
fieldcraft_model::suggest_name(&name).unwrap_or(name)
|
||||
}
|
||||
|
||||
/// Remove the ties an earlier run of mesh.cells made on `cells` (and their two sets): ties
|
||||
/// between sets `<tie>_main` and `<tie>_secondary` that hold only cut faces of these cells.
|
||||
fn remove_cut_ties(model: &mut Model, cells: &[String]) -> Result<()> {
|
||||
let cut_faces_of_cells = |set: &str| {
|
||||
model.set(set).is_some_and(|s| {
|
||||
!s.geometry.is_empty()
|
||||
&& s.nodes.is_empty()
|
||||
&& s.elements.is_empty()
|
||||
&& s.faces.is_empty()
|
||||
&& s.edges.is_empty()
|
||||
&& s.geometry.iter().all(|g| cells.contains(&g.body) && matches!(&g.entity, Entity::Face(f) if f.starts_with("partition:")))
|
||||
})
|
||||
};
|
||||
let stale: Vec<(String, String, String)> = model
|
||||
.ties()
|
||||
.iter()
|
||||
.filter(|(n, t)| t.main == format!("{n}_main") && t.secondary == format!("{n}_secondary"))
|
||||
.filter(|(_, t)| cut_faces_of_cells(&t.main) && cut_faces_of_cells(&t.secondary))
|
||||
.map(|(n, t)| (n.clone(), t.main.clone(), t.secondary.clone()))
|
||||
.collect();
|
||||
for (tie, main, secondary) in stale {
|
||||
model.remove_tie(&tie)?;
|
||||
for set in [main, secondary] {
|
||||
if model.set_users(&set).is_empty() {
|
||||
model.remove_set(&set)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When the two sides' nodes coincide one to one (within `tol`) and their faces are the same
|
||||
/// in number, the merge: each node of side `b` to the node of side `a` at its place (or the other
|
||||
/// way round, so the lower label survives).
|
||||
fn matching(model: &Model, a: &Side, b: &Side, tol: f64) -> Option<BTreeMap<u32, u32>> {
|
||||
if a.nodes.len() != b.nodes.len() || a.faces.len() != b.faces.len() {
|
||||
return None;
|
||||
}
|
||||
let both: BTreeSet<u32> = a.nodes.union(&b.nodes).copied().collect();
|
||||
let map = fieldcraft_mesh::join::coincident_nodes(model, Some(&both), tol).ok()?;
|
||||
// Every pair joins one node of each side, and every node of either side is in a pair.
|
||||
let one_to_one = map.len() == a.nodes.len()
|
||||
&& map.iter().all(|(from, to)| (a.nodes.contains(from) && b.nodes.contains(to)) || (b.nodes.contains(from) && a.nodes.contains(to)));
|
||||
one_to_one.then_some(map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sample plate (40 × 30 × 10, a Ø10 hole at (15, 15)) open and cut at x = 30, right of
|
||||
/// the hole: `Body1/a` holds the hole, `Body1/b` is a 10 × 30 × 10 block.
|
||||
fn cut_plate() -> (crate::tests::TempDir, Session) {
|
||||
let (dir, path) = crate::tests::sample_design_file(&fieldcraft_geometry::sample::Plate::default());
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_and_tet_cells_are_tied_and_matching_hex_cells_merged() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
// A tet cell next to a hex cell: tied, the hex cell main.
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["cells"][0]["kind"], "tet4", "{r}");
|
||||
assert_eq!(r["cells"][1]["kind"], "hex8", "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "tied", "{r}");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
assert_eq!((i["main"].as_str(), i["secondary"].as_str(), i["tie"].as_str()), (Some("Body1/b"), Some("Body1/a"), Some("tie_Body1_a")), "{r}");
|
||||
for set in ["tie_Body1_a_main", "tie_Body1_a_secondary"] {
|
||||
assert!(s.model.resolve_set(set).unwrap().faces.len() > 4, "{set}");
|
||||
}
|
||||
// Both cells hex at one size: the plane faces match node for node and merge.
|
||||
let r = s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "merged", "{r}");
|
||||
let separate = r["cells"].as_array().unwrap().iter().map(|c| c["nodes"].as_u64().unwrap()).sum::<u64>();
|
||||
assert_eq!(r["nodes"].as_u64().unwrap() + i["nodes"].as_u64().unwrap(), separate, "{r}");
|
||||
// Rerunning replaces the tie it made rather than adding another.
|
||||
s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
}
|
||||
|
||||
/// The half crank arm (cut on its symmetry plane only) has no cell that maps or sweeps as
|
||||
/// the design gives it; the extrusion decomposes its cross-section into blocks itself, alike
|
||||
/// through mesh.hex.generate (auto) and mesh.cells, with no seeds.
|
||||
#[test]
|
||||
fn the_half_crank_extrudes_in_blocks_without_partitions() {
|
||||
let bytes = fieldcraft_geometry::script_design(crate::sample::CRANK_ARM).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Sym"})).unwrap();
|
||||
let r = s.execute("mesh.hex.generate", &json!({"body": "CrankArm/a", "size": 3})).unwrap();
|
||||
let body = &r["bodies"][0];
|
||||
assert_eq!(body["method"], "extrude", "{r}");
|
||||
assert!(body["description"].as_str().unwrap().contains(" 0 free)"), "{r}");
|
||||
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.37, "{r}");
|
||||
let r = s.execute("mesh.cells", &json!({"bodies": ["CrankArm/a"], "default": "extrude", "size": 3})).unwrap();
|
||||
assert_eq!(r["cells"][0]["method"], "extrude", "{r}");
|
||||
assert_eq!(r["elements"], body["elements"], "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_falls_back_to_tet_and_says_why() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "mapped", "methods": {"Body1/a": "auto"}, "size": 3})).unwrap();
|
||||
// The holed cell is not a mapped block, but auto sweeps it.
|
||||
assert!(r["cells"][0]["method"].as_str().is_some(), "{r}");
|
||||
assert_eq!(r["cells"][1]["method"], "mapped", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_change_nothing() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"default": "pyramid"}),
|
||||
json!({"methods": {"Nope": "tet"}}),
|
||||
json!({"methods": ["tet"]}),
|
||||
json!({"bodies": []}),
|
||||
json!({"bodies": ["Body1/a", "Body1/a"]}),
|
||||
json!({"bodies": ["Nope"]}),
|
||||
json!({"order": 3}),
|
||||
json!({"size": -1}),
|
||||
json!({"default": "mapped", "bodies": ["Body1/a"]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.cells", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert!(s.model.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A 40 × 10 × 10 bar in uniaxial tension, cut in the middle: one half hex, the other tet,
|
||||
/// tied. With ν = 0, symmetry conditions on three faces and the end load spread by consistent
|
||||
/// weights, the stress is uniform, which both elements and the dual mortar tie carry exactly:
|
||||
/// the end moves F L / (E A).
|
||||
#[test]
|
||||
fn a_tied_hex_tet_bar_in_tension_stretches_as_the_formula_says() {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let bbox = s.execute("geometry.bodies", &json!({})).unwrap()[0]["bbox"].clone();
|
||||
let lo: Vec<f64> = bbox[0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let hi: Vec<f64> = bbox[1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let (axis, length) = (0..3).map(|k| (k, hi[k] - lo[k])).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap();
|
||||
assert!((length - 40.0).abs() < 1e-9);
|
||||
let mid = (lo[axis] + hi[axis]) / 2.0;
|
||||
let mut normal = [0.0; 3];
|
||||
normal[axis] = 1.0;
|
||||
let mut point = [0.0; 3];
|
||||
point[axis] = mid;
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "point_normal", "point": point, "normal": normal}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "Cut", "datum": "Mid"})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Bar/a": "mapped", "Bar/b": "tet"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"][0]["joined"], "tied", "{r}");
|
||||
|
||||
let m = &s.model;
|
||||
let on = |k: usize, v: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| (n.xyz[k] - v).abs() < 1e-9).map(|(l, _)| *l).collect() };
|
||||
let others: Vec<usize> = (0..3).filter(|k| *k != axis).collect();
|
||||
let end: Vec<Value> = m
|
||||
.elements()
|
||||
.iter()
|
||||
.flat_map(|(&e, el)| (1..=el.kind.faces().len() as u8).map(move |f| (e, f)))
|
||||
.filter(|&(e, f)| {
|
||||
m.face_nodes(ElementFace { element: e, face: f })
|
||||
.is_some_and(|ns| ns.iter().all(|n| (m.node(*n).unwrap().xyz[axis] - hi[axis]).abs() < 1e-9))
|
||||
})
|
||||
.map(|(e, f)| json!({"element": e, "face": f}))
|
||||
.collect();
|
||||
let plane = |k: usize| ["x", "y", "z"][k];
|
||||
let (young, area, force) = (70_000.0, 100.0, 1000.0);
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "nodes": on(axis, lo[axis])}},
|
||||
{"command": "model.set.create", "params": {"name": "side1", "nodes": on(others[0], lo[others[0]])}},
|
||||
{"command": "model.set.create", "params": {"name": "side2", "nodes": on(others[1], lo[others[1]])}},
|
||||
{"command": "model.set.create", "params": {"name": "end", "faces": end}},
|
||||
{"command": "model.material.create", "params": {"name": "Al", "elastic": {"type": "isotropic", "young": young, "poisson": 0}}},
|
||||
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Al", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s0", "type": "symmetry", "target": {"set": "root"}, "plane": plane(axis)}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s1", "type": "symmetry", "target": {"set": "side1"}, "plane": plane(others[0])}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s2", "type": "symmetry", "target": {"set": "side2"}, "plane": plane(others[1])}},
|
||||
{"command": "model.load.create", "params": {"name": "Pull", "type": "force", "target": {"set": "end"}, "vector": normal.map(|n| n * force)}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["s0", "s1", "s2"], "loads": ["Pull"]}},
|
||||
]);
|
||||
let tip = on(axis, hi[axis]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip})).unwrap();
|
||||
let expected = force * length / (young * area);
|
||||
let probed = probe["nodes"].as_array().unwrap();
|
||||
assert!(probed.len() > 4, "{probe}");
|
||||
for n in probed {
|
||||
let u = n["displacement"][axis].as_f64().unwrap();
|
||||
assert!((u - expected).abs() < 1e-6 * expected, "{u} vs {expected}: {n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A box cut by planes into cells, `methods` per cell, a heat source in every cell and
|
||||
/// convection on its outer faces: the heat flows of the steady solve.
|
||||
fn cut_box_heat(cuts: &[(&str, f64)], methods: Value, size: f64) -> (Value, Value) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10, "body_name": "Box"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
for (i, (plane, offset)) in cuts.iter().enumerate() {
|
||||
let name = format!("D{i}");
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": plane, "offset": offset}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{i}"), "datum": name})).unwrap();
|
||||
}
|
||||
let r = s.execute("mesh.cells", &json!({"methods": methods, "default": "hex", "size": size})).unwrap();
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let outer: Vec<Value> = bodies
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|b| {
|
||||
b["faces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|f| !f["name"].as_str().unwrap().starts_with("partition:"))
|
||||
.map(|f| json!({"body": b["name"], "face": f["name"]}))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": s.model.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "skin", "geometry": outer}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "all", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Air"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
let h = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
(r, h)
|
||||
}
|
||||
|
||||
/// A tet cell among hex cells (#389, #288): the hex cells sweep together and share their
|
||||
/// nodes, the tet cell is tied once (one tie for all its cut faces, the hex cells main), and
|
||||
/// the heat balances to round-off.
|
||||
#[test]
|
||||
fn a_tet_cell_among_swept_hex_cells_is_tied_once_and_the_heat_balances() {
|
||||
let (r, h) = cut_box_heat(&[("yz", 10.0), ("yz", 20.0), ("zx", 10.0)], json!({"Box/b/a/a": "tet"}), 2.5);
|
||||
let interfaces = r["interfaces"].as_array().unwrap();
|
||||
let tied: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "tied").collect();
|
||||
assert_eq!(tied.len(), 3, "{r}");
|
||||
assert!(tied.iter().all(|i| i["secondary"] == "Box/b/a/a" && i["tie"] == "tie_Box_b_a_a"), "{r}");
|
||||
let merged: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "merged").collect();
|
||||
assert_eq!(merged.len(), 4, "{r}");
|
||||
// Box/a/a and Box/a/b meet across y = 10, the third cut.
|
||||
assert!(merged.iter().any(|i| i["cells"] == json!(["Box/a/a", "Box/a/b"]) && i["partition"] == "P2"), "{r}");
|
||||
let flows = &h["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(h["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{h}");
|
||||
}
|
||||
|
||||
/// Cells cut from one box by planes, one of them (`y = 10`) cutting only the cell left of
|
||||
/// `x = 10`, so a cut ends on another (a T-junction): with no seeds the cells sweep together
|
||||
/// and every interface merges node for node (#288), and the mesh fills the box.
|
||||
#[test]
|
||||
fn cells_of_a_body_sweep_together_and_merge_without_seeds() {
|
||||
let (_dir, mut s) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10, "corner": [0, 0, 0], "body_name": "Box"}},
|
||||
]));
|
||||
super::super::datum::tests::cut_at(&mut s, &[(0, 10.0), (0, 21.0)]);
|
||||
s.execute("geometry.datum", &json!({"name": "Y", "by": {"type": "offset", "plane": "zx", "offset": 7.5}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "PY", "datum": "Y", "bodies": ["Box/a"]})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "auto", "size": 2.0})).unwrap();
|
||||
let cells = r["cells"].as_array().unwrap();
|
||||
assert_eq!(cells.len(), 4, "{r}");
|
||||
assert!(cells.iter().all(|c| c["swept_together"] == "Box" && c["kind"] == "hex8"), "{r}");
|
||||
let interfaces = r["interfaces"].as_array().unwrap();
|
||||
assert_eq!(interfaces.len(), 4, "{r}");
|
||||
assert!(interfaces.iter().all(|i| i["joined"] == "merged"), "{r}");
|
||||
assert!(s.model.ties().is_empty());
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
let volume = q["volume"].as_f64().unwrap();
|
||||
assert!((volume - 6000.0).abs() < 1e-6, "{q}");
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
|
||||
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes.
|
||||
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes;
|
||||
//! `interaction.rigid_wall*`: rigid walls, which belong to an explicit dynamics step (its
|
||||
//! `rigid_walls`, also edited in the step's form).
|
||||
//!
|
||||
//! A contact's surfaces are sets of element faces, or of element edges in a 2D model
|
||||
//! ([`fieldcraft_model::contact`]). The commands
|
||||
@@ -12,13 +14,15 @@
|
||||
//! touch there. Each touching pair is reported with the faces of each side that are close to the
|
||||
//! other; with `create`, each becomes two face sets and a contact pair.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_fem::contact::{Grid, bounds, closest_point};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Set, Tie};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Procedure, RigidWall, Set, Step, Tie};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The options every contact takes, from the parameters.
|
||||
@@ -33,7 +37,89 @@ fn contact(p: &Value, surfaces: ContactSurfaces) -> Result<Contact> {
|
||||
|
||||
/// A required name-valued parameter.
|
||||
fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("`{key}` must be a set name")))
|
||||
p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::param(key, ProblemKind::NotAName))
|
||||
}
|
||||
|
||||
pub const RIGID_WALL: CommandSpec = CommandSpec {
|
||||
id: "interaction.rigid_wall",
|
||||
label: "Rigid Wall",
|
||||
params: r#"{"step": "Impact" (an explicit dynamics step; default: the model's only one), "set": "front" (the nodes the wall stops), "point": [x, y, z] (on the wall), "normal": [nx, ny, nz] (toward the side the nodes stay on), "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)} — a fixed rigid plane the set's nodes cannot pass, added to the step's rigid walls (the same list as model.step.create's `rigid_walls`). Replies the step and the wall's index in it"#,
|
||||
form: Some(super::forms::RIGID_WALL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step, Requirement::Set],
|
||||
run: rigid_wall,
|
||||
};
|
||||
|
||||
pub const RIGID_WALL_DELETE: CommandSpec = CommandSpec {
|
||||
id: "interaction.rigid_wall.delete",
|
||||
label: "Delete Rigid Wall",
|
||||
params: r#"{"step": "Impact", "index": 0} — removes a rigid wall from an explicit dynamics step (its index in the step's `rigid_walls`, from 0)"#,
|
||||
form: Some(super::forms::RIGID_WALL_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: rigid_wall_delete,
|
||||
};
|
||||
|
||||
/// The explicit dynamics step `step` names, or the model's only one when it names none.
|
||||
fn explicit_step(m: &Model, p: &Value) -> Result<Step> {
|
||||
let explicit = |s: &&Step| matches!(s.procedure, Procedure::ExplicitDynamic(_));
|
||||
let step = match p.get("step") {
|
||||
None | Some(Value::Null) => {
|
||||
let mut all = m.steps().iter().filter(explicit);
|
||||
match (all.next(), all.next()) {
|
||||
(Some(s), None) => s,
|
||||
(None, _) => {
|
||||
return Err(EngineError::Params(
|
||||
"the model has no explicit dynamics step, where rigid walls act: create one (Steps › Step, Dynamic, explicit)".into(),
|
||||
));
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(EngineError::param("step", ProblemKind::Invalid("name the explicit dynamics step: the model has several".into())));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let n = text(p, "step")?;
|
||||
let s = m.steps().iter().find(|s| s.name == n).ok_or_else(|| EngineError::Params(format!("no step `{n}`")))?;
|
||||
if !explicit(&s) {
|
||||
return Err(EngineError::Params(format!("step `{n}` is not an explicit dynamics step: rigid walls act only there")));
|
||||
}
|
||||
s
|
||||
}
|
||||
};
|
||||
Ok(step.clone())
|
||||
}
|
||||
|
||||
fn rigid_wall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut step = explicit_step(&s.model, p)?;
|
||||
let set = text(p, "set")?.to_string();
|
||||
if s.model.set(&set).is_none() {
|
||||
return Err(EngineError::Params(format!("no set `{set}`")));
|
||||
}
|
||||
let wall = RigidWall {
|
||||
set,
|
||||
point: super::req(p, "point")?,
|
||||
normal: super::req(p, "normal")?,
|
||||
friction: opt::<Friction>(p, "friction")?.unwrap_or(Friction::NONE),
|
||||
};
|
||||
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
|
||||
e.rigid_walls.push(wall);
|
||||
let index = e.rigid_walls.len() - 1;
|
||||
let name = step.name.clone();
|
||||
s.model.replace_step(step)?;
|
||||
Ok(json!({"step": name, "index": index}))
|
||||
}
|
||||
|
||||
fn rigid_wall_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut step = explicit_step(&s.model, p)?;
|
||||
let index: usize = super::req(p, "index")?;
|
||||
let Procedure::ExplicitDynamic(e) = &mut step.procedure else { return Err(EngineError::Params("not an explicit dynamics step".into())) };
|
||||
if index >= e.rigid_walls.len() {
|
||||
return Err(EngineError::param("index", ProblemKind::Invalid(format!("step `{}` has {} rigid walls", step.name, e.rigid_walls.len()))));
|
||||
}
|
||||
e.rigid_walls.remove(index);
|
||||
s.model.replace_step(step)?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub const PAIR: CommandSpec = CommandSpec {
|
||||
@@ -46,6 +132,7 @@ pub const PAIR: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_PAIR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surfaces = ContactSurfaces::Pair { main: text(p, "main")?.to_string(), secondary: text(p, "secondary")?.to_string() };
|
||||
@@ -64,6 +151,7 @@ pub const GENERAL: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_GENERAL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surface = match p.get("surface") {
|
||||
@@ -81,6 +169,7 @@ pub const EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "impact", "surfaces": {"type": "pair", "main": "plate", "secondary": "ball"} | {"type": "general", "surface": "skin" (optional)}, "friction": {…}, "penalty_scale": 1, "initial_penetration": "adjust|refuse"} — replaces the contact of that name (the model's form; see interaction.contact.pair)"#,
|
||||
form: Some(super::forms::CONTACT_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params("the parameters of a contact are a JSON object".into()))?;
|
||||
@@ -97,6 +186,7 @@ pub const DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "impact"}"#,
|
||||
form: Some(super::forms::CONTACT_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_contact(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -118,6 +208,7 @@ pub const TIE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue", "main": "plate" (a set of element faces), "secondary": "rib" (another), "position_tolerance": 0.1 (optional: how far a secondary node may lie from the main surface and still be tied; default half the main element's thickness)} — bonds the secondary surface to the main one across non-matching meshes: each secondary node follows the main surface under it (dual mortar constraints in the implicit analyses; a constant stress crosses the tie exactly); fails if the name is taken"#,
|
||||
form: Some(super::forms::TIE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.add_tie(n, tie(p)?)?;
|
||||
@@ -131,6 +222,7 @@ pub const TIE_EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue", "main": "plate", "secondary": "rib", "position_tolerance": 0.1} — replaces the tie of that name (see interaction.tie)"#,
|
||||
form: Some(super::forms::TIE_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.replace_tie(n, tie(p)?)?;
|
||||
@@ -144,6 +236,7 @@ pub const TIE_DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "glue"}"#,
|
||||
form: Some(super::forms::TIE_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_tie(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -159,6 +252,7 @@ pub const FIND: CommandSpec = CommandSpec {
|
||||
),
|
||||
form: Some(super::forms::CONTACT_FIND),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: find,
|
||||
};
|
||||
|
||||
@@ -312,6 +406,36 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Rigid walls go into the explicit step (the model's only one, or the one named) and come
|
||||
/// out by index; a model without an explicit step, or a step of another kind, is refused.
|
||||
#[test]
|
||||
fn rigid_walls_belong_to_the_explicit_step() {
|
||||
let mut s = Session::default();
|
||||
super::super::define::tests::defined_bar(&mut s);
|
||||
let wall = json!({"set": "end", "point": [3, 0, 0], "normal": [-1, 0, 0], "friction": {"static": 0.2}});
|
||||
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "no explicit step yet");
|
||||
let mut on_bend = wall.clone();
|
||||
on_bend["step"] = json!("Bend");
|
||||
assert!(s.execute("interaction.rigid_wall", &on_bend).is_err(), "a static step");
|
||||
s.execute("model.step.create", &json!({"name": "Hit", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
|
||||
let r = s.execute("interaction.rigid_wall", &wall).unwrap();
|
||||
assert_eq!(r, json!({"step": "Hit", "index": 0}));
|
||||
let walls = |s: &Session| match &s.model.step("Hit").unwrap().procedure {
|
||||
Procedure::ExplicitDynamic(e) => e.rigid_walls.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
assert_eq!(walls(&s).len(), 1);
|
||||
assert_eq!(walls(&s)[0].set, "end");
|
||||
for bad in [json!({"set": "nope", "point": [0, 0, 0], "normal": [1, 0, 0]}), json!({"set": "end", "point": [0, 0, 0], "normal": [0, 0, 0]})] {
|
||||
assert!(s.execute("interaction.rigid_wall", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 1})).is_err());
|
||||
s.execute("interaction.rigid_wall.delete", &json!({"step": "Hit", "index": 0})).unwrap();
|
||||
assert!(walls(&s).is_empty());
|
||||
s.execute("model.step.create", &json!({"name": "Hit2", "procedure": {"type": "explicit_dynamic", "end_time": 1e-4}})).unwrap();
|
||||
assert!(s.execute("interaction.rigid_wall", &wall).is_err(), "two explicit steps: name one");
|
||||
}
|
||||
|
||||
/// Two unit cubes of 2×2×2 hex8 each, the second resting on the first (separate nodes), and
|
||||
/// a third cube far away.
|
||||
fn stacked(s: &mut Session) {
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
//! Datum planes, axes and points, and partitions of the open design by datum planes.
|
||||
//!
|
||||
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A datum point is
|
||||
//! placed by coordinates, at a mesh vertex, along a design edge, at a design face's centre or
|
||||
//! where a datum axis crosses a datum plane; planes and axes can be made through datum points.
|
||||
//! Each takes the coordinates of what it is made from when it is made (a later edit of a datum
|
||||
//! point leaves the planes and axes made through it where they are). A partition cuts the
|
||||
//! design in memory (`fieldcraft_geometry::partition`); the model's partitions are applied again,
|
||||
//! in order, whenever the design is opened (`geometry.open`, `file.open`, samples), so an edit
|
||||
//! of the design followed by a re-open replays them.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::{Cut, Design};
|
||||
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
|
||||
use fieldcraft_model::{Datum, Model, Partition};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const DATUM_PLANE: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum",
|
||||
label: "Datum Plane",
|
||||
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]} | {"type": "datum_points", "a": "P1", "b": "P2", "c": "P3"} | {"type": "normal_to_axis", "axis": "Spindle", "point"?: "P1"}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points or three datum points (normal (b − a) × (c − a)), through a point with a normal, or normal to a datum axis through a datum point (the axis's point by default). Partitions (geometry.partition) cut by it; shown in the viewport"#,
|
||||
form: Some(super::forms::DATUM_PLANE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: datum_plane,
|
||||
};
|
||||
|
||||
pub const DATUM_AXIS: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.axis",
|
||||
label: "Datum Axis",
|
||||
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]} | {"type": "datum_points", "a": "P1", "b": "P2"}} — a named datum axis, shown in the viewport"#,
|
||||
form: Some(super::forms::DATUM_AXIS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: datum_axis,
|
||||
};
|
||||
|
||||
pub const DATUM_POINT: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.point",
|
||||
label: "Datum Point",
|
||||
params: r#"{"name": "P1", "by": {"type": "coordinates", "point": [x, y, z]} | {"type": "vertex", "body": "Body1", "vertex": "F1:top|F2:side:l1|F2:side:l2"} | {"type": "edge", "body": "Body1", "edge": "F1:top|F2:side", "fraction"?: 0.5} | {"type": "face_centre", "body": "Body1", "face": "F2:top"} | {"type": "axis_plane", "axis": "Spindle", "plane": "Mid"}} — a named datum point, shown in the viewport as a small cross: at coordinates, at a vertex of the open design (its persistent name, as geometry.bodies lists it and Select: Vertices picks it) or of the mesh (as its geometry tags name it), a fraction along an edge of the open design (0.5: its midpoint), at the area centre of a face of the open design, or where a datum axis crosses a datum plane. Datum planes and axes can be made through datum points"#,
|
||||
form: Some(super::forms::DATUM_POINT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: datum_point,
|
||||
};
|
||||
|
||||
pub const DATUM_EDIT: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.edit",
|
||||
label: "Edit Datum Plane",
|
||||
params: r#"{"name": "Mid", "by": …} — redefines a datum plane (the parameters of geometry.datum); the partitions that cut by it cut again"#,
|
||||
form: Some(super::forms::DATUM_PLANE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::DatumPlane],
|
||||
run: |s, p| datum_edit(s, p, "plane"),
|
||||
};
|
||||
|
||||
pub const DATUM_AXIS_EDIT: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.axis.edit",
|
||||
label: "Edit Datum Axis",
|
||||
params: r#"{"name": "Spindle", "by": …} — redefines a datum axis (the parameters of geometry.datum.axis)"#,
|
||||
form: Some(super::forms::DATUM_AXIS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::DatumAxis],
|
||||
run: |s, p| datum_edit(s, p, "axis"),
|
||||
};
|
||||
|
||||
pub const DATUM_POINT_EDIT: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.point.edit",
|
||||
label: "Edit Datum Point",
|
||||
params: r#"{"name": "P1", "by": …} — redefines a datum point (the parameters of geometry.datum.point); planes and axes made through it stay where they are"#,
|
||||
form: Some(super::forms::DATUM_POINT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::DatumPoint],
|
||||
run: |s, p| datum_edit(s, p, "point"),
|
||||
};
|
||||
|
||||
pub const DATUM_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.delete",
|
||||
label: "Delete Datum",
|
||||
params: r#"{"name": "Mid", "cascade"?: false} — deletes a datum plane, axis or point; one a partition cuts by is refused (naming the partitions), unless `cascade` deletes those partitions too (their cells lose their meshes: `replaced`)"#,
|
||||
form: Some(super::forms::DATUM_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Datum],
|
||||
run: datum_delete,
|
||||
};
|
||||
|
||||
pub const PARTITION: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition",
|
||||
label: "Partition",
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
|
||||
form: Some(super::forms::PARTITION),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design, Requirement::DatumPlane],
|
||||
run: partition,
|
||||
};
|
||||
|
||||
pub const DATUM_SUPPRESS: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.suppress",
|
||||
label: "Suppress Datum",
|
||||
params: r#"{"name": "Mid", "suppressed"?: true} — keeps the datum but leaves it out of the model (false brings it back): it cannot be an input to another datum, a partition, a mirror or a symmetry while suppressed, and the partitions that cut by it stop cutting (the design is cut again without them)"#,
|
||||
form: Some(super::forms::DATUM_SUPPRESS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Datum],
|
||||
run: datum_suppress,
|
||||
};
|
||||
|
||||
pub const PARTITION_SUPPRESS: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition.suppress",
|
||||
label: "Suppress Partition",
|
||||
params: r#"{"name": "Half", "suppressed"?: true} — keeps the partition but leaves it out (false brings it back): the design is cut again without it, so its cells merge back into the bodies they came from and lose their meshes (`replaced`)"#,
|
||||
form: Some(super::forms::PARTITION_SUPPRESS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Partition],
|
||||
run: partition_suppress,
|
||||
};
|
||||
|
||||
pub const PARTITION_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition.delete",
|
||||
label: "Delete Partition",
|
||||
params: r#"{"name": "Half"} — removes a partition; the design is cut again by the ones left, and the cells that are gone lose their meshes (`replaced`)"#,
|
||||
form: Some(super::forms::PARTITION_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Partition],
|
||||
run: partition_delete,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Principal {
|
||||
Yz,
|
||||
Zx,
|
||||
Xy,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum PlaneBy {
|
||||
Offset {
|
||||
plane: Principal,
|
||||
#[serde(default)]
|
||||
offset: f64,
|
||||
},
|
||||
ThreePoints {
|
||||
a: [f64; 3],
|
||||
b: [f64; 3],
|
||||
c: [f64; 3],
|
||||
},
|
||||
PointNormal {
|
||||
point: [f64; 3],
|
||||
normal: [f64; 3],
|
||||
},
|
||||
DatumPoints {
|
||||
a: String,
|
||||
b: String,
|
||||
c: String,
|
||||
},
|
||||
NormalToAxis {
|
||||
axis: String,
|
||||
#[serde(default)]
|
||||
point: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum AxisBy {
|
||||
Principal {
|
||||
axis: Axis,
|
||||
#[serde(default)]
|
||||
through: [f64; 3],
|
||||
},
|
||||
TwoPoints {
|
||||
a: [f64; 3],
|
||||
b: [f64; 3],
|
||||
},
|
||||
PointDirection {
|
||||
point: [f64; 3],
|
||||
direction: [f64; 3],
|
||||
},
|
||||
DatumPoints {
|
||||
a: String,
|
||||
b: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn half() -> f64 {
|
||||
0.5
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum PointBy {
|
||||
Coordinates {
|
||||
point: [f64; 3],
|
||||
},
|
||||
Vertex {
|
||||
body: String,
|
||||
vertex: String,
|
||||
},
|
||||
Edge {
|
||||
body: String,
|
||||
edge: String,
|
||||
#[serde(default = "half")]
|
||||
fraction: f64,
|
||||
},
|
||||
FaceCentre {
|
||||
body: String,
|
||||
face: String,
|
||||
},
|
||||
AxisPlane {
|
||||
axis: String,
|
||||
plane: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
/// The coordinates of datum point `name`.
|
||||
fn datum_point_at(m: &Model, name: &str) -> Result<[f64; 3]> {
|
||||
let d = m.usable_datum(name)?;
|
||||
d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind())))
|
||||
}
|
||||
|
||||
/// The point and unit direction of datum axis `name`.
|
||||
fn datum_axis_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> {
|
||||
let d = m.usable_datum(name)?;
|
||||
d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind())))
|
||||
}
|
||||
|
||||
/// The point and unit normal of datum plane `name`.
|
||||
pub(crate) fn datum_plane_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> {
|
||||
let d = m.usable_datum(name)?;
|
||||
d.plane().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a plane", d.kind())))
|
||||
}
|
||||
|
||||
/// A plane through three points; collinear points are refused relative to their spread.
|
||||
fn three_points(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Result<Datum> {
|
||||
let (u, v) = (sub(b, a), sub(c, a));
|
||||
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
||||
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
|
||||
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
|
||||
return Err(EngineError::Params("the three points must not lie on one line".into()));
|
||||
}
|
||||
Ok(Datum::Plane { point: a, normal: n })
|
||||
}
|
||||
|
||||
fn plane_datum(m: &Model, by: PlaneBy) -> Result<Datum> {
|
||||
Ok(match by {
|
||||
PlaneBy::Offset { plane, offset } => {
|
||||
if !offset.is_finite() {
|
||||
return Err(EngineError::param("offset", ProblemKind::NotANumber));
|
||||
}
|
||||
let normal = match plane {
|
||||
Principal::Yz => [1.0, 0.0, 0.0],
|
||||
Principal::Zx => [0.0, 1.0, 0.0],
|
||||
Principal::Xy => [0.0, 0.0, 1.0],
|
||||
};
|
||||
Datum::Plane { point: normal.map(|n| n * offset), normal }
|
||||
}
|
||||
PlaneBy::ThreePoints { a, b, c } => three_points(a, b, c)?,
|
||||
PlaneBy::PointNormal { point, normal } => Datum::Plane { point, normal },
|
||||
PlaneBy::DatumPoints { a, b, c } => three_points(datum_point_at(m, &a)?, datum_point_at(m, &b)?, datum_point_at(m, &c)?)?,
|
||||
PlaneBy::NormalToAxis { axis, point } => {
|
||||
let (on_axis, direction) = datum_axis_of(m, &axis)?;
|
||||
let point = match point {
|
||||
Some(p) => datum_point_at(m, &p)?,
|
||||
None => on_axis,
|
||||
};
|
||||
Datum::Plane { point, normal: direction }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn axis_datum(m: &Model, by: AxisBy) -> Result<Datum> {
|
||||
Ok(match by {
|
||||
AxisBy::Principal { axis, through } => {
|
||||
let direction = match axis {
|
||||
Axis::X => [1.0, 0.0, 0.0],
|
||||
Axis::Y => [0.0, 1.0, 0.0],
|
||||
Axis::Z => [0.0, 0.0, 1.0],
|
||||
};
|
||||
Datum::Axis { point: through, direction }
|
||||
}
|
||||
AxisBy::TwoPoints { a, b } => Datum::Axis { point: a, direction: sub(b, a) },
|
||||
AxisBy::PointDirection { point, direction } => Datum::Axis { point, direction },
|
||||
AxisBy::DatumPoints { a, b } => {
|
||||
let a = datum_point_at(m, &a)?;
|
||||
Datum::Axis { point: a, direction: sub(datum_point_at(m, &b)?, a) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Most mesh nodes a vertex is looked for among.
|
||||
const MAX_VERTEX_NODES: usize = 10_000_000;
|
||||
|
||||
fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
|
||||
let shape = |body: &str| -> Result<&fieldcraft_geometry::Shape> {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
design.body(body).ok_or_else(|| EngineError::Params(format!("the design has no body `{body}`")))
|
||||
};
|
||||
let point = match by {
|
||||
PointBy::Coordinates { point } => point,
|
||||
PointBy::Vertex { body, vertex } => {
|
||||
// A vertex of the open design by its persistent name, else one the mesh tags.
|
||||
let design_vertex = match s.design.as_deref().and_then(|d| d.body(&body)) {
|
||||
Some(shape) => shape.vertices()?.into_iter().find(|v| v.name == vertex).map(|v| v.point),
|
||||
None => None,
|
||||
};
|
||||
match design_vertex {
|
||||
Some(point) => point,
|
||||
None => {
|
||||
let m = &s.model;
|
||||
let at = fieldcraft_model::GeometryRef::new(body.clone(), fieldcraft_model::Entity::Vertex(vertex.clone()))?;
|
||||
let tags = m.classification().tags_selected_by(&at);
|
||||
let node = m
|
||||
.classification()
|
||||
.nodes()
|
||||
.iter()
|
||||
.take(MAX_VERTEX_NODES)
|
||||
.find(|(_, t)| tags.contains(t))
|
||||
.and_then(|(n, _)| m.node(*n))
|
||||
.ok_or_else(|| EngineError::Params(format!("body `{body}` has no vertex `{vertex}` (geometry.bodies lists its vertices)")))?;
|
||||
node.xyz
|
||||
}
|
||||
}
|
||||
}
|
||||
PointBy::Edge { body, edge, fraction } => {
|
||||
if !(0.0..=1.0).contains(&fraction) {
|
||||
return Err(EngineError::invalid("fraction", "is a number from 0 to 1".to_string()));
|
||||
}
|
||||
let edges = shape(&body)?.edges()?;
|
||||
let e = edges.iter().find(|e| e.name == edge).ok_or_else(|| EngineError::Params(format!("body `{body}` has no edge `{edge}`")))?;
|
||||
e.point_at(fraction).ok_or_else(|| EngineError::Params(format!("edge `{edge}` of body `{body}` has no points")))?
|
||||
}
|
||||
PointBy::FaceCentre { body, face } => shape(&body)?.face_centre(&face)?,
|
||||
PointBy::AxisPlane { axis, plane } => {
|
||||
let (p, d) = datum_axis_of(&s.model, &axis)?;
|
||||
let (q, n) = datum_plane_of(&s.model, &plane)?;
|
||||
let along = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
let across = along(d, n);
|
||||
// Unit vectors: the sine of the angle between the axis and the plane.
|
||||
if across.abs() < 1e-9 {
|
||||
return Err(EngineError::Params(format!("axis `{axis}` is parallel to plane `{plane}`: they do not cross")));
|
||||
}
|
||||
let t = along(sub(q, p), n) / across;
|
||||
[p[0] + t * d[0], p[1] + t * d[1], p[2] + t * d[2]]
|
||||
}
|
||||
};
|
||||
Ok(Datum::Point { point })
|
||||
}
|
||||
|
||||
/// The datum `by` defines for a datum of `kind` (`plane`, `axis` or `point`).
|
||||
fn datum_of(s: &Session, kind: &str, p: &Value) -> Result<Datum> {
|
||||
match kind {
|
||||
"plane" => plane_datum(&s.model, super::req(p, "by")?),
|
||||
"axis" => axis_datum(&s.model, super::req(p, "by")?),
|
||||
_ => point_datum(s, super::req(p, "by")?),
|
||||
}
|
||||
}
|
||||
|
||||
/// The datum a datum form's `params` define so far, for drawing it before OK; `None` when
|
||||
/// `command` makes no datum or the parameters do not define one yet. The model is not changed.
|
||||
pub fn preview(s: &Session, command: &str, params: &Value) -> Option<Datum> {
|
||||
let kind = match command {
|
||||
"geometry.datum" | "geometry.datum.edit" => "plane",
|
||||
"geometry.datum.axis" | "geometry.datum.axis.edit" => "axis",
|
||||
"geometry.datum.point" | "geometry.datum.point.edit" => "point",
|
||||
_ => return None,
|
||||
};
|
||||
datum_of(s, kind, params).ok()
|
||||
}
|
||||
|
||||
/// The parameters of the command that edits datum `name`, describing it as it is (a plane by
|
||||
/// its point and normal, an axis by its point and direction, a point by its coordinates); and
|
||||
/// that command.
|
||||
pub fn edit_params(name: &str, d: &Datum) -> (&'static str, Value) {
|
||||
match *d {
|
||||
Datum::Plane { point, normal } => (DATUM_EDIT.id, json!({"name": name, "by": {"type": "point_normal", "point": point, "normal": normal}})),
|
||||
Datum::Axis { point, direction } => {
|
||||
(DATUM_AXIS_EDIT.id, json!({"name": name, "by": {"type": "point_direction", "point": point, "direction": direction}}))
|
||||
}
|
||||
Datum::Point { point } => (DATUM_POINT_EDIT.id, json!({"name": name, "by": {"type": "coordinates", "point": point}})),
|
||||
}
|
||||
}
|
||||
|
||||
fn datum_json(name: &str, d: &Datum) -> Value {
|
||||
let mut v = serde_json::to_value(d).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn datum_create(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let datum = datum_of(s, kind, p)?;
|
||||
s.model.add_datum(name, datum)?;
|
||||
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
|
||||
}
|
||||
|
||||
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
datum_create(s, p, "plane")
|
||||
}
|
||||
|
||||
fn datum_axis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
datum_create(s, p, "axis")
|
||||
}
|
||||
|
||||
fn datum_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
datum_create(s, p, "point")
|
||||
}
|
||||
|
||||
/// Redefine datum `name` of `kind`; the partitions that cut by a plane cut again.
|
||||
fn datum_edit(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
match s.model.datum(name) {
|
||||
Some(d) if d.kind() == kind => {}
|
||||
Some(d) => return Err(EngineError::Params(format!("datum `{name}` is a datum {}, not a {kind}", d.kind()))),
|
||||
None => return Err(EngineError::Params(format!("no datum {kind} `{name}`"))),
|
||||
}
|
||||
let datum = datum_of(s, kind, p)?;
|
||||
s.model.replace_datum(name, datum)?;
|
||||
let cleared = if s.model.datum_users(name).is_empty() { super::replace::Cleared::default() } else { recut(s)? };
|
||||
let reply = datum_json(name, s.model.datum(name).unwrap_or(&datum));
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
fn datum_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let cascade: bool = super::opt(p, "cascade")?.unwrap_or(false);
|
||||
let partitions: Vec<String> = s.model.partitions().iter().filter(|x| x.datum == name).map(|x| x.name.clone()).collect();
|
||||
if cascade {
|
||||
for partition in &partitions {
|
||||
s.model.remove_partition(partition)?;
|
||||
}
|
||||
}
|
||||
s.model.remove_datum(name)?;
|
||||
let cleared = if cascade && !partitions.is_empty() { recut(s)? } else { super::replace::Cleared::default() };
|
||||
let reply = json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }});
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
/// The model's partitions as cuts of the geometry.
|
||||
fn cuts(model: &Model) -> Vec<Cut> {
|
||||
model
|
||||
.partitions()
|
||||
.iter()
|
||||
.filter(|p| model.partition_cuts(p))
|
||||
.filter_map(|p| {
|
||||
let (point, normal) = model.datum(&p.datum)?.plane()?;
|
||||
Some(Cut { name: p.name.clone(), point, normal, bodies: p.bodies.clone() })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `design` cut by `model`'s partitions (the design as opened when the model has none).
|
||||
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
Ok(design.partitioned(&cuts(model))?)
|
||||
}
|
||||
|
||||
/// Cut the session's design by its model's partitions again. The bodies that are gone (cells
|
||||
/// whose partition went, a body a new partition cut) take their meshes with them (#385): what
|
||||
/// went is in the returned [`super::replace::Cleared`], which the command reports.
|
||||
fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
|
||||
let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) };
|
||||
let cut = partitioned(d, &s.model)?;
|
||||
let gone: Vec<String> = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect();
|
||||
s.design = Some(Arc::new(cut));
|
||||
Ok(super::replace::clear(&mut s.model, gone.iter().map(String::as_str)))
|
||||
}
|
||||
|
||||
fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let datum: String = super::req(p, "datum")?;
|
||||
let bodies: Vec<String> = super::opt(p, "bodies")?.unwrap_or_default();
|
||||
datum_plane_of(&s.model, &datum)?;
|
||||
s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies, suppressed: false })?;
|
||||
let cleared = recut(s)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let feature = format!("partition {name}");
|
||||
let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect();
|
||||
if !errors.is_empty() {
|
||||
return Err(EngineError::Params(format!("partition `{name}`: {}", errors.join("; "))));
|
||||
}
|
||||
let report = design.cuts.iter().find(|c| c.name == name);
|
||||
let split = report.map(|r| r.split.clone()).unwrap_or_default();
|
||||
if split.is_empty() {
|
||||
return Err(EngineError::Params(format!("partition `{name}`: the plane divides no body of the design")));
|
||||
}
|
||||
let cells: Vec<String> = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
|
||||
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
|
||||
let mut warnings: Vec<String> = report.map(|r| r.warnings.clone()).unwrap_or_default();
|
||||
for cell in &cells {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) {
|
||||
warnings.push(format!(
|
||||
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// The cut shows as edges on the cells, so the plane steps out of the way (view state, #295):
|
||||
// selecting it in the browser or its eye shows it again.
|
||||
if s.view.hidden.len() < MAX_HIDDEN {
|
||||
s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum });
|
||||
}
|
||||
let mut reply = json!({
|
||||
"name": name,
|
||||
"split": split,
|
||||
"cells": cells,
|
||||
"cut_faces": faces,
|
||||
"bodies": bodies,
|
||||
});
|
||||
if !warnings.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.insert("warnings".into(), json!(warnings));
|
||||
}
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh:
|
||||
/// the defects (open edges, edges on three triangles) when it does not.
|
||||
fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, None)?;
|
||||
let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() };
|
||||
fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options)
|
||||
.map(|_| ())
|
||||
.map_err(|e| EngineError::Params(e.to_string()))
|
||||
}
|
||||
|
||||
fn datum_suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let suppressed = super::opt(p, "suppressed")?.unwrap_or(true);
|
||||
s.model.set_datum_suppressed(name, suppressed)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies})))
|
||||
}
|
||||
|
||||
fn partition_suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let suppressed = super::opt(p, "suppressed")?.unwrap_or(true);
|
||||
s.model.set_partition_suppressed(name, suppressed)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies})))
|
||||
}
|
||||
|
||||
fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
s.model.remove_partition(name)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(cleared.report_if_any(s, json!({"deleted": name, "bodies": bodies})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open.
|
||||
pub(crate) fn plate() -> (Session, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-partition-{}-{:?}", std::process::id(), std::thread::current().id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plate.solidcraft");
|
||||
let plate = fieldcraft_geometry::sample::Plate::default();
|
||||
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&plate).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
(s, dir)
|
||||
}
|
||||
|
||||
fn bodies(s: &Session) -> Vec<String> {
|
||||
s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datums_by_each_definition() {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
assert_eq!(r, json!({"name": "Mid", "type": "plane", "point": [15.0, 0.0, 0.0], "normal": [1.0, 0.0, 0.0]}));
|
||||
let r = s
|
||||
.execute("geometry.datum", &json!({"name": "Tri", "by": {"type": "three_points", "a": [0, 0, 1], "b": [1, 0, 1], "c": [0, 1, 1]}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
|
||||
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 3, 4]}})).unwrap();
|
||||
assert_eq!(s.model.datum("Tilt"), Some(&Datum::Plane { point: [0.0; 3], normal: [0.0, 0.6, 0.8] }));
|
||||
let r = s.execute("geometry.datum.axis", &json!({"name": "Z", "by": {"type": "principal", "axis": "z", "through": [1, 2, 0]}})).unwrap();
|
||||
assert_eq!(r, json!({"name": "Z", "type": "axis", "point": [1.0, 2.0, 0.0], "direction": [0.0, 0.0, 1.0]}));
|
||||
s.execute("geometry.datum.axis", &json!({"name": "D", "by": {"type": "two_points", "a": [0, 0, 0], "b": [0, 2, 0]}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "E", "by": {"type": "point_direction", "point": [0, 0, 0], "direction": [5, 0, 0]}}))
|
||||
.unwrap();
|
||||
assert_eq!(s.model.datums().len(), 6);
|
||||
s.execute("geometry.datum.delete", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.model.datums().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_datum_params_change_nothing() {
|
||||
let mut s = Session::default();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "by": {"type": "offset", "plane": "ab"}}),
|
||||
json!({"name": "A", "by": {"type": "offset", "plane": "xy", "offset": "x"}}),
|
||||
json!({"name": "A", "by": {"type": "three_points", "a": [0, 0, 0], "b": [1, 1, 1], "c": [2, 2, 2]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 0]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [1e300, 0, 0], "normal": [0, 0, 1]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 1], "extra": 1}}),
|
||||
json!({"name": "1A", "by": {"type": "offset", "plane": "xy"}}),
|
||||
json!({"name": "A", "by": {"type": "nope"}}),
|
||||
] {
|
||||
assert!(s.execute("geometry.datum", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"name": "A", "by": {"type": "two_points", "a": [1, 1, 1], "b": [1, 1, 1]}}),
|
||||
json!({"name": "A", "by": {"type": "principal", "axis": "w"}}),
|
||||
] {
|
||||
assert!(s.execute("geometry.datum.axis", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.model.datums().is_empty());
|
||||
assert!(s.journal.is_empty());
|
||||
}
|
||||
|
||||
/// Suppressing a partition merges its cells back; suppressing its datum does too and stops
|
||||
/// the datum being an input; unsuppressing cuts again; each is one undo step (#291).
|
||||
#[test]
|
||||
fn suppressed_partitions_and_datums_leave_the_design_uncut() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
let count = |s: &Session| s.design.as_ref().map_or(0, |d| d.bodies.len());
|
||||
assert_eq!(count(&s), 2);
|
||||
let r = s.execute("geometry.partition.suppress", &json!({"name": "Half"})).unwrap();
|
||||
assert_eq!((r["suppressed"].as_bool(), count(&s)), (Some(true), 1));
|
||||
s.execute("geometry.partition.suppress", &json!({"name": "Half", "suppressed": false})).unwrap();
|
||||
assert_eq!(count(&s), 2);
|
||||
s.execute("geometry.datum.suppress", &json!({"name": "Mid"})).unwrap();
|
||||
assert_eq!(count(&s), 1);
|
||||
let e = s.execute("geometry.partition", &json!({"name": "Again", "datum": "Mid"})).unwrap_err().to_string();
|
||||
assert!(e.contains("suppressed"), "{e}");
|
||||
let e = s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "axis_plane", "axis": "Mid", "plane": "Mid"}}));
|
||||
assert!(e.is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count(&s), 2, "undo cuts again");
|
||||
assert!(s.execute("geometry.datum.suppress", &json!({"name": "Nope"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partition_cuts_the_design_and_is_replayed_when_it_opens_again() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
let r = s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
let body = r["split"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(r["cells"], json!([format!("{body}/a"), format!("{body}/b")]));
|
||||
assert_eq!(r["cut_faces"], json!(4));
|
||||
// The datum is in use, and hidden: the cut shows as edges on the cells (#295). Its eye
|
||||
// shows it again.
|
||||
assert!(s.execute("geometry.datum.delete", &json!({"name": "Mid"})).is_err());
|
||||
assert!(!s.shows(ItemKind::Datum, "Mid"));
|
||||
s.execute("view.show", &json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
|
||||
assert!(s.shows(ItemKind::Datum, "Mid"));
|
||||
s.execute("view.hide", &json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
|
||||
|
||||
// A set on a cut face selects the cut face's pieces.
|
||||
let r = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let faces: Vec<&str> = r[0]["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert!(faces.contains(&"partition:Half:a#0"), "{faces:?}");
|
||||
|
||||
// Saved and opened again: the design is cut again by the model's partition.
|
||||
let file = dir.join("plate.fieldcraft");
|
||||
s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(bodies(&t), bodies(&s));
|
||||
assert!(!t.shows(ItemKind::Datum, "Mid"), "still hidden when opened again");
|
||||
// geometry.open again keeps the model, so its partition.
|
||||
let design = dir.join("plate.solidcraft");
|
||||
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["bodies"], json!(bodies(&s)));
|
||||
// A new model drops the partitions with the rest of the model.
|
||||
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap(), "model": "new"})).unwrap();
|
||||
assert_eq!(r["bodies"], json!([body]));
|
||||
|
||||
// Undo brings the whole design back; deleting the partition does too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bodies(&s), std::slice::from_ref(&body));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(bodies(&s).len(), 2);
|
||||
s.execute("geometry.partition.delete", &json!({"name": "Half"})).unwrap();
|
||||
assert_eq!(bodies(&s), [body]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datum_points_by_each_definition_and_planes_and_axes_through_them() {
|
||||
let (mut s, dir) = plate();
|
||||
let point = |s: &Session, n: &str| s.model.datum(n).and_then(Datum::point).unwrap();
|
||||
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
|
||||
s.execute("geometry.datum.point", &json!({"name": "O", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
|
||||
s.execute("geometry.datum.point", &json!({"name": "X", "by": {"type": "coordinates", "point": [10, 0, 0]}})).unwrap();
|
||||
s.execute("geometry.datum.point", &json!({"name": "Y", "by": {"type": "coordinates", "point": [0, 10, 0]}})).unwrap();
|
||||
// The plate's edge along x at y = 0, z = 0, its midpoint and a quarter along it.
|
||||
let body = s.design.as_ref().unwrap().bodies[0].clone();
|
||||
let edge = body.edges().unwrap().into_iter().find(|e| e.points.iter().all(|p| p[1].abs() < 1e-9 && p[2].abs() < 1e-9)).unwrap();
|
||||
let by = |fraction: f64| json!({"type": "edge", "body": body.name, "edge": edge.name, "fraction": fraction});
|
||||
s.execute("geometry.datum.point", &json!({"name": "Mid", "by": {"type": "edge", "body": body.name, "edge": edge.name}})).unwrap();
|
||||
assert!(close(point(&s, "Mid"), [20.0, 0.0, 0.0]), "{:?}", point(&s, "Mid"));
|
||||
s.execute("geometry.datum.point", &json!({"name": "Q", "by": by(0.25)})).unwrap();
|
||||
let q = point(&s, "Q");
|
||||
assert!((q[0] - 10.0).abs() < 1e-6 || (q[0] - 30.0).abs() < 1e-6, "{q:?}");
|
||||
// The centre of the face at x = 0.
|
||||
let side =
|
||||
body.face_surfaces().unwrap().iter().position(
|
||||
|f| matches!(f, Some(fieldcraft_geometry::FaceSurface::Plane { normal, point }) if normal[0] < -0.5 && point[0].abs() < 1e-9),
|
||||
);
|
||||
let face = body.faces()[side.unwrap()].name.clone();
|
||||
s.execute("geometry.datum.point", &json!({"name": "C", "by": {"type": "face_centre", "body": body.name, "face": face}})).unwrap();
|
||||
assert!(close(point(&s, "C"), [0.0, 15.0, 5.0]), "{:?}", point(&s, "C"));
|
||||
|
||||
// Planes and axes through datum points; a point where an axis crosses a plane.
|
||||
let r = s.execute("geometry.datum", &json!({"name": "Base", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Y"}})).unwrap();
|
||||
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
|
||||
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "datum_points", "a": "O", "b": "X"}})).unwrap();
|
||||
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [1.0, 0.0, 0.0] }));
|
||||
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "normal_to_axis", "axis": "Ax", "point": "Mid"}})).unwrap();
|
||||
assert_eq!(s.model.datum("Cut"), Some(&Datum::Plane { point: point(&s, "Mid"), normal: [1.0, 0.0, 0.0] }));
|
||||
s.execute("geometry.datum.point", &json!({"name": "Hit", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Cut"}})).unwrap();
|
||||
assert!(close(point(&s, "Hit"), [20.0, 0.0, 0.0]));
|
||||
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Base"}}),
|
||||
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Base", "plane": "Cut"}}),
|
||||
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": edge.name, "fraction": 2}}),
|
||||
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": "nope"}}),
|
||||
json!({"name": "Z", "by": {"type": "edge", "body": "Nope", "edge": edge.name}}),
|
||||
json!({"name": "Z", "by": {"type": "face_centre", "body": body.name, "face": "nope"}}),
|
||||
// No mesh: no vertex is named.
|
||||
json!({"name": "Z", "by": {"type": "vertex", "body": body.name, "vertex": "v1"}}),
|
||||
json!({"name": "Z", "by": {"type": "coordinates", "point": [f64::MAX, 0, 0]}}),
|
||||
json!({"name": "Z", "by": {"type": "coordinates", "point": [0, 0]}}),
|
||||
] {
|
||||
assert!(s.execute("geometry.datum.point", &p).is_err(), "{p}");
|
||||
}
|
||||
for (command, p) in [
|
||||
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Mid"}})),
|
||||
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Ax"}})),
|
||||
("geometry.datum", json!({"name": "Z", "by": {"type": "normal_to_axis", "axis": "Nope"}})),
|
||||
("geometry.datum.axis", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "O"}})),
|
||||
] {
|
||||
assert!(s.execute(command, &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertex_point_is_at_the_tagged_mesh_node() {
|
||||
let mut s = Session::default();
|
||||
s.execute(
|
||||
"mesh.block",
|
||||
&json!({"body": "Bar", "kind": "hex8", "corners": [[0,0,0],[4,0,0],[4,1,0],[0,1,0],[0,0,1],[4,0,1],[4,1,1],[0,1,1]], "divisions": [2, 1, 1], "vertices": {"7": "tip"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("geometry.datum.point", &json!({"name": "Tip", "by": {"type": "vertex", "body": "Bar", "vertex": "tip"}})).unwrap();
|
||||
assert_eq!(s.model.datum("Tip").and_then(Datum::point), Some([4.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_and_deleting_datums_follow_their_partitions() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
let cut_at = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().bbox_max[0];
|
||||
assert!((cut_at(&s) - 15.0).abs() < 1e-6);
|
||||
|
||||
// Moving the plane cuts again; an edit of the wrong kind or to an axis is refused.
|
||||
s.execute("geometry.datum.edit", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 25}})).unwrap();
|
||||
assert!((cut_at(&s) - 25.0).abs() < 1e-6);
|
||||
assert!(s.execute("geometry.datum.axis.edit", &json!({"name": "Mid", "by": {"type": "principal", "axis": "y"}})).is_err());
|
||||
assert!(s.execute("geometry.datum.edit", &json!({"name": "Ax", "by": {"type": "offset", "plane": "xy"}})).is_err());
|
||||
s.execute("geometry.datum.axis.edit", &json!({"name": "Ax", "by": {"type": "principal", "axis": "y"}})).unwrap();
|
||||
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [0.0, 1.0, 0.0] }));
|
||||
|
||||
// The edit commands take the parameters edit_params gives back.
|
||||
for name in ["Mid", "Ax"] {
|
||||
let (command, params) = edit_params(name, s.model.datum(name).unwrap());
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
}
|
||||
|
||||
// Deleting the plane names its partition, or deletes it too.
|
||||
let e = s.execute("geometry.datum.delete", &json!({"name": "Mid"})).unwrap_err().to_string();
|
||||
assert!(e.contains("partition `Half`"), "{e}");
|
||||
let r = s.execute("geometry.datum.delete", &json!({"name": "Mid", "cascade": true})).unwrap();
|
||||
assert_eq!(r["partitions"], json!(["Half"]));
|
||||
assert!(s.model.partitions().is_empty() && s.model.datum("Mid").is_none());
|
||||
assert_eq!(bodies(&s).len(), 1);
|
||||
// One undo step brings both back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((s.model.partitions().len(), bodies(&s).len()), (1, 2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partition_that_cuts_nothing_changes_nothing() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Out", "by": {"type": "offset", "plane": "yz", "offset": 100}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"name": "P", "datum": "Out"}),
|
||||
json!({"name": "P", "datum": "Ax"}),
|
||||
json!({"name": "P", "datum": "Nope"}),
|
||||
json!({"name": "P", "datum": "Out", "bodies": ["Nope"]}),
|
||||
json!({"name": "P", "datum": "Out", "bodies": "x"}),
|
||||
json!({"name": "", "datum": "Out"}),
|
||||
] {
|
||||
assert!(s.execute("geometry.partition", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.model.partitions().is_empty());
|
||||
assert_eq!(bodies(&s).len(), 1);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A session with the design `commands` (a SolidCraft script) open.
|
||||
pub(crate) fn scripted(commands: Value) -> (crate::tests::TempDir, Session) {
|
||||
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Partition the open design by the planes `x = …` / `y = …` / `z = …` (`(axis, offset)`).
|
||||
pub(crate) fn cut_at(s: &mut Session, planes: &[(usize, f64)]) {
|
||||
for (i, &(k, offset)) in planes.iter().enumerate() {
|
||||
let (name, plane) = (format!("C{i}"), ["yz", "zx", "xy"][k]);
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": plane, "offset": offset}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{i}"), "datum": name})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A box with a plate-shaped void in its middle that touches no side (#388): the planes
|
||||
/// round the void leave a centre column whose material lies above and below the void, two
|
||||
/// pieces that become separate cells only at the last cut.
|
||||
#[test]
|
||||
fn pieces_that_do_not_touch_are_separate_cells() {
|
||||
let (_dir, mut s) = scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 106, "width": 76, "height": 20, "corner": [0, 0, 0], "body_name": "Air"}},
|
||||
{"command": "solid.box", "params": {"length": 85, "width": 56, "height": 2, "corner": [13, 13, 9], "operation": "cut"}},
|
||||
]));
|
||||
let whole = s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume;
|
||||
cut_at(&mut s, &[(0, 22.5), (0, 68.5), (1, 22.5), (1, 59.5)]);
|
||||
let design = s.design.clone().unwrap();
|
||||
let shells: Vec<(&str, usize)> = design.bodies.iter().map(|b| (b.name.as_str(), b.shells().unwrap())).collect();
|
||||
assert!(shells.iter().all(|s| s.1 == 1), "{shells:?}");
|
||||
// 3 × 3 columns, the centre one in two pieces.
|
||||
assert_eq!(shells.len(), 10, "{shells:?}");
|
||||
let volume: f64 = design.bodies.iter().map(|b| b.measure().unwrap().volume).sum();
|
||||
assert!((volume - whole).abs() < 1e-6 * whole, "{volume} vs {whole}");
|
||||
let centre: Vec<&str> = shells.iter().map(|s| s.0).filter(|n| n.ends_with('1') || n.ends_with('2')).collect();
|
||||
assert_eq!(centre.len(), 2, "{centre:?}");
|
||||
// Numbered by centroid: the piece below the void first.
|
||||
let z = |n: &str| design.body(n).unwrap().measure().unwrap().centroid[2];
|
||||
assert!(z(centre[0]) < 9.0 && z(centre[1]) > 11.0, "{centre:?}");
|
||||
}
|
||||
|
||||
/// #385: the cells' meshes go with the cells (deleting, suppressing or replacing their
|
||||
/// partitions), a body's mesh goes when a partition cuts it, and meshes left over from an
|
||||
/// earlier cut are listed by model.check and removed by mesh.clear.
|
||||
#[test]
|
||||
fn meshes_go_with_the_bodies_partitions_remove() {
|
||||
let (mut s, dir) = plate();
|
||||
let whole = s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume;
|
||||
let volume = |s: &mut Session| s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
|
||||
// Partitioning a meshed body takes its mesh.
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
let r = s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
assert_eq!(r["replaced"][0]["body"], "Body1", "{r}");
|
||||
assert!(s.model.elements().is_empty());
|
||||
// Deleting the datum with its partition takes the cells' meshes; the whole body's new
|
||||
// mesh is the only one.
|
||||
s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
let r = s.execute("geometry.datum.delete", &json!({"name": "X30", "cascade": true})).unwrap();
|
||||
let gone: Vec<&str> = r["replaced"].as_array().unwrap().iter().map(|b| b["body"].as_str().unwrap()).collect();
|
||||
assert_eq!(gone, ["Body1/a", "Body1/b"], "{r}");
|
||||
assert!(s.model.elements().is_empty());
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap();
|
||||
let v = volume(&mut s);
|
||||
assert!((v - whole).abs() < 1e-2 * whole, "{v} vs {whole}");
|
||||
// Suppressing or deleting a partition does the same.
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
let r = s.execute("geometry.partition.suppress", &json!({"name": "P"})).unwrap();
|
||||
assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.model.elements().is_empty(), "undo brings the cells' meshes back");
|
||||
let r = s.execute("geometry.partition.delete", &json!({"name": "P"})).unwrap();
|
||||
assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
// A mesh left from an earlier cut (a model saved before the cut changed): listed and
|
||||
// cleared.
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
s.model.remove_partition("P").unwrap();
|
||||
let d = s.design.clone().unwrap();
|
||||
s.design = Some(Arc::new(partitioned(&d, &s.model).unwrap()));
|
||||
let check = s.execute("model.check", &json!({})).unwrap();
|
||||
let listed: Vec<&str> =
|
||||
check["diagnostics"].as_array().unwrap().iter().filter_map(|d| d["subject"].as_str()).filter(|d| d.starts_with("body")).collect();
|
||||
assert_eq!(listed, ["body `Body1/a`", "body `Body1/b`"], "{check}");
|
||||
let r = s.execute("mesh.clear", &json!({})).unwrap();
|
||||
assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(s.model.elements().is_empty());
|
||||
assert!(s.execute("mesh.clear", &json!({"bodies": ["Body1"]})).is_err(), "nothing to clear");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The Raspberry Pi 5 enclosure analysis design (base, lid, heat sink, board, chips and the
|
||||
/// air inside), its SolidCraft recipe.
|
||||
pub(crate) fn pi5() -> (crate::tests::TempDir, Session) {
|
||||
scripted(serde_json::from_str(include_str!("../../tests/designs/pi5-enclosure.json")).unwrap())
|
||||
}
|
||||
|
||||
/// #384: cuts stay on their plane (the kernel's own split put them 1.8 µm off it on the air
|
||||
/// of the enclosure, so the cut faces were not even recognized), and a plane along an
|
||||
/// existing face or through a hole's axis is a warning naming the plane, the body and where.
|
||||
#[test]
|
||||
fn cuts_stay_on_the_plane_and_risky_ones_are_named() {
|
||||
let (_dir, mut s) = pi5();
|
||||
let plane = |s: &mut Session, name: &str, x: f64| {
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": "yz", "offset": x}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{name}"), "datum": name, "bodies": ["Air"]})).unwrap()
|
||||
};
|
||||
// Beside the board's mounting holes: clean cells, cut faces on the plane.
|
||||
let r = plane(&mut s, "X", 16.5);
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
let design = s.design.clone().unwrap();
|
||||
for cell in ["Air/a", "Air/b"] {
|
||||
let shape = design.body(cell).unwrap();
|
||||
let cut: Vec<f64> = shape
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(shape.face_surfaces().unwrap())
|
||||
.filter(|(f, _)| f.name.starts_with("partition:PX:"))
|
||||
.filter_map(|(_, srf)| match srf {
|
||||
Some(fieldcraft_geometry::FaceSurface::Plane { point, .. }) => Some(point[0]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!cut.is_empty() && cut.iter().all(|x| (x - 16.5).abs() < 1e-9), "{cell}: {cut:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Along the side of the heat sink's pedestal: named.
|
||||
let r = plane(&mut s, "Side", 34.0);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PSide`: the plane lies on face `F45:-x` of `Air`"), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Through a standoff's hole: the cell whose boundary breaks at the hole's edge is named.
|
||||
let r = plane(&mut s, "Axis", 74.5);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PAxis`: cell `Air/") && warnings.contains("boundary no mesher takes"), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use fieldcraft_io::roots::Access;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::req;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const WRITE: CommandSpec = CommandSpec {
|
||||
@@ -14,6 +15,7 @@ pub const WRITE: CommandSpec = CommandSpec {
|
||||
params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: fieldcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); LS-DYNA writes several steps as *CASE analyses (run it with CASE); fails naming every error of the model check and everything the format cannot express"#,
|
||||
form: Some(super::forms::DECK),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection],
|
||||
run: write,
|
||||
};
|
||||
|
||||
@@ -60,7 +62,7 @@ mod tests {
|
||||
assert!(s.execute("deck.write", &json!({"path": other})).is_err());
|
||||
assert!(s.execute("deck.write", &json!({"format": "abaqus"})).is_err());
|
||||
let mut empty = Session::default();
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Deck(_))));
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Unavailable(_))));
|
||||
assert!(!dir.join("other.inp").exists());
|
||||
assert!(matches!(s.execute("deck.write", &json!({"format": "abaqus", "path": dir.to_string_lossy()})), Err(EngineError::File(_))));
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@ use fieldcraft_model::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use fieldcraft_io::{Item, ItemKind};
|
||||
use fieldcraft_model::rename::Kind as RenameKind;
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The parameters other than `name`, read as a definition's JSON form.
|
||||
@@ -26,13 +30,14 @@ fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||||
/// Create, edit and delete commands for one kind of definition.
|
||||
macro_rules! definition_commands {
|
||||
($what:literal, $ty:ty, $id:literal, $label:literal, $doc:literal, $form:expr, $delete_form:expr, $add:ident, $replace:ident, $remove:ident,
|
||||
$CREATE:ident, $EDIT:ident, $DELETE:ident) => {
|
||||
$CREATE:ident, $EDIT:ident, $DELETE:ident, requires: $requires:expr, exists: $exists:ident) => {
|
||||
pub const $CREATE: CommandSpec = CommandSpec {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
requires: $requires,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -45,6 +50,7 @@ macro_rules! definition_commands {
|
||||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -57,6 +63,7 @@ macro_rules! definition_commands {
|
||||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||||
form: Some($delete_form),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -78,7 +85,9 @@ definition_commands!(
|
||||
remove_set,
|
||||
SET_CREATE,
|
||||
SET_EDIT,
|
||||
SET_DELETE
|
||||
SET_DELETE,
|
||||
requires: &[],
|
||||
exists: Set
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -86,7 +95,7 @@ definition_commands!(
|
||||
Material,
|
||||
"material",
|
||||
"Material",
|
||||
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} | {"type": "mooney_rivlin", "c01": 0.1} (C10 = μ/2 − C01) | {"type": "yeoh", "c20": -0.01, "c30": 0.001} (C10 = μ/2) (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there)} — in the model's unit system (model.units), never converted"#,
|
||||
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} | {"type": "mooney_rivlin", "c01": 0.1} (C10 = μ/2 − C01) | {"type": "yeoh", "c20": -0.01, "c30": 0.001} (C10 = μ/2) (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there), "thermal": {"conductivity": 0.05 | [kx, ky, kz], "specific_heat": 4.6e8, "expansion": 1.2e-5 | [αx, αy, αz], "reference_temperature": 20 (zero thermal strain; default 0)} (heat transfer and thermal strain; each key optional)} — in the model's unit system (model.units), never converted"#,
|
||||
super::forms::MATERIAL,
|
||||
super::forms::MATERIAL_DELETE,
|
||||
add_material,
|
||||
@@ -94,7 +103,9 @@ definition_commands!(
|
||||
remove_material,
|
||||
MATERIAL_CREATE,
|
||||
MATERIAL_EDIT,
|
||||
MATERIAL_DELETE
|
||||
MATERIAL_DELETE,
|
||||
requires: &[],
|
||||
exists: Material
|
||||
);
|
||||
|
||||
/// A copy of material `name` with `change` applied, put back through the model's validation.
|
||||
@@ -112,6 +123,7 @@ pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#,
|
||||
form: Some(super::forms::MATERIAL_PLASTICITY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
m.plastic = req(p, "plastic")?;
|
||||
@@ -126,6 +138,7 @@ pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#,
|
||||
form: Some(super::forms::MATERIAL_DAMAGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
// Absent leaves a field as it is; null removes it.
|
||||
@@ -157,7 +170,9 @@ definition_commands!(
|
||||
remove_section,
|
||||
SECTION_CREATE,
|
||||
SECTION_EDIT,
|
||||
SECTION_DELETE
|
||||
SECTION_DELETE,
|
||||
requires: &[Requirement::Material, Requirement::Set],
|
||||
exists: Section
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -173,7 +188,9 @@ definition_commands!(
|
||||
remove_reference_point,
|
||||
REFERENCE_POINT_CREATE,
|
||||
REFERENCE_POINT_EDIT,
|
||||
REFERENCE_POINT_DELETE
|
||||
REFERENCE_POINT_DELETE,
|
||||
requires: &[],
|
||||
exists: ReferencePoint
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -189,7 +206,9 @@ definition_commands!(
|
||||
remove_coupling,
|
||||
COUPLING_CREATE,
|
||||
COUPLING_EDIT,
|
||||
COUPLING_DELETE
|
||||
COUPLING_DELETE,
|
||||
requires: &[Requirement::ReferencePoint, Requirement::Set],
|
||||
exists: Coupling
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -205,7 +224,9 @@ definition_commands!(
|
||||
remove_mass,
|
||||
MASS_CREATE,
|
||||
MASS_EDIT,
|
||||
MASS_DELETE
|
||||
MASS_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Mass
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -221,7 +242,9 @@ definition_commands!(
|
||||
remove_spring,
|
||||
SPRING_CREATE,
|
||||
SPRING_EDIT,
|
||||
SPRING_DELETE
|
||||
SPRING_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Spring
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -237,7 +260,9 @@ definition_commands!(
|
||||
remove_boundary_condition,
|
||||
BOUNDARY_CONDITION_CREATE,
|
||||
BOUNDARY_CONDITION_EDIT,
|
||||
BOUNDARY_CONDITION_DELETE
|
||||
BOUNDARY_CONDITION_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: BoundaryCondition
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -245,7 +270,7 @@ definition_commands!(
|
||||
Load,
|
||||
"load",
|
||||
"Load",
|
||||
r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} | {"name", "type": "centrifugal", "angular_velocity" (rad per time unit), "point": [x, y, z] (on the axis), "axis": [x, y, z], "set" (optional)} — 2D models: in-plane vectors, surface loads on element edges; active in the steps that list it (model.step.activate)"#,
|
||||
r#"{"name": "push", "type": "force|moment", "target": {"set": "…"} | {"reference_point": "…"}, "vector": [x, y, z], "distribution": "total" (default: the vector is the total, split by area over the set's faces, or equally over its nodes) | "per_node" (at every node: depends on the mesh)} | {"name", "type": "traction", "set", "vector" (per area, on the set's faces)} | {"name", "type": "pressure", "set", "magnitude" (positive pushes in)} | {"name", "type": "gravity", "acceleration": [x, y, z], "set" (optional, default the whole model)} | {"name", "type": "body_force", "set", "vector" (per volume)} | {"name", "type": "centrifugal", "angular_velocity" (rad per time unit), "point": [x, y, z] (on the axis), "axis": [x, y, z], "set" (optional)} | {"name", "type": "surface_flux", "set", "flux" (heat per area into the set's faces)} | {"name", "type": "convection", "set", "film" (film coefficient h), "sink" (surrounding temperature): heat flux h (sink − T) into the set's faces} | {"name", "type": "heat_source", "set", "power" (heat per volume in the set's elements)} | {"name", "type": "temperature", "target": {"set": "…"} | {"reference_point": "…"}, "value" (a temperature that strains the material in stress steps)} — heat loads act in heat transfer and coupled steps; 2D models: in-plane vectors, surface loads on element edges; active in the steps that list it (model.step.activate)"#,
|
||||
super::forms::LOAD,
|
||||
super::forms::LOAD_DELETE,
|
||||
add_load,
|
||||
@@ -253,7 +278,9 @@ definition_commands!(
|
||||
remove_load,
|
||||
LOAD_CREATE,
|
||||
LOAD_EDIT,
|
||||
LOAD_DELETE
|
||||
LOAD_DELETE,
|
||||
requires: &[],
|
||||
exists: Load
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -269,7 +296,9 @@ definition_commands!(
|
||||
remove_fluid_domain,
|
||||
FLUID_DOMAIN_CREATE,
|
||||
FLUID_DOMAIN_EDIT,
|
||||
FLUID_DOMAIN_DELETE
|
||||
FLUID_DOMAIN_DELETE,
|
||||
requires: &[Requirement::Material],
|
||||
exists: FluidDomain
|
||||
);
|
||||
|
||||
pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||||
@@ -278,6 +307,7 @@ pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#,
|
||||
form: Some(super::forms::FLUID_BOUNDARY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain],
|
||||
run: fluid_boundary,
|
||||
};
|
||||
|
||||
@@ -292,9 +322,10 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "buckle", "modes": 5, "shift"?: 0} | {"type": "heat_transfer", "transient"?: {"end_time": 10, "increment": 0.5, "theta": 1, "initial_temperature": 20}} (steady without `transient`) | {"type": "coupled_temperature_displacement"} (steady heat and the static equilibrium of its thermal strains) | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
form: Some(super::forms::STEP_CREATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: step_create,
|
||||
};
|
||||
|
||||
@@ -304,6 +335,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||||
form: Some(super::forms::STEP_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: step_edit,
|
||||
};
|
||||
|
||||
@@ -313,6 +345,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "Bend"}"#,
|
||||
form: Some(super::forms::STEP_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
|
||||
@@ -322,15 +355,71 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||||
form: Some(super::forms::STEP_ACTIVATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Step],
|
||||
run: step_activate,
|
||||
};
|
||||
|
||||
pub const RENAME: CommandSpec = CommandSpec {
|
||||
id: "model.rename",
|
||||
label: "Rename",
|
||||
params: r#"{"kind": "set" | "material" | "section" | "step" | "boundary_condition" | "load" | "reference_point" | "coupling" | "contact" | "tie" | "point_mass" | "spring" | "fluid_domain" | "datum", "from": "end", "to": "Tip"} — renames the definition and every reference to it (sets in sections, couplings, contacts, ties, discrete elements, boundary conditions, loads and explicit steps; materials in sections and fluid domains; reference points; boundary conditions and loads in the steps they are active in; steps in imperfections; fluid domains in fluid steps; datum planes in partitions). A name already taken is refused; the viewport's hidden items follow the new name"#,
|
||||
form: Some(super::forms::RENAME),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: rename,
|
||||
};
|
||||
|
||||
/// `model.rename`'s kinds, as the command takes them.
|
||||
pub const RENAME_KINDS: &[(&str, RenameKind)] = &[
|
||||
("set", RenameKind::Set),
|
||||
("material", RenameKind::Material),
|
||||
("section", RenameKind::Section),
|
||||
("step", RenameKind::Step),
|
||||
("boundary_condition", RenameKind::BoundaryCondition),
|
||||
("load", RenameKind::Load),
|
||||
("reference_point", RenameKind::ReferencePoint),
|
||||
("coupling", RenameKind::Coupling),
|
||||
("contact", RenameKind::Contact),
|
||||
("tie", RenameKind::Tie),
|
||||
("point_mass", RenameKind::PointMass),
|
||||
("spring", RenameKind::Spring),
|
||||
("fluid_domain", RenameKind::FluidDomain),
|
||||
("datum", RenameKind::Datum),
|
||||
];
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind: &str = req(p, "kind")?;
|
||||
let from: &str = req(p, "from")?;
|
||||
let to: &str = req(p, "to")?;
|
||||
let Some((_, k)) = RENAME_KINDS.iter().find(|(id, _)| *id == kind) else {
|
||||
let kinds: Vec<&str> = RENAME_KINDS.iter().map(|(id, _)| *id).collect();
|
||||
return Err(EngineError::Params(format!("`kind` is one of {}", kinds.join(", "))));
|
||||
};
|
||||
s.model.rename(*k, from, to)?;
|
||||
// What the viewport hides follows the new name.
|
||||
let item = match k {
|
||||
RenameKind::Datum => Some(ItemKind::Datum),
|
||||
RenameKind::BoundaryCondition => Some(ItemKind::BoundaryCondition),
|
||||
RenameKind::Load => Some(ItemKind::Load),
|
||||
RenameKind::ReferencePoint => Some(ItemKind::ReferencePoint),
|
||||
RenameKind::Coupling => Some(ItemKind::Coupling),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = item
|
||||
&& s.view.hidden.remove(&Item { kind, name: from.to_string() })
|
||||
{
|
||||
s.view.hidden.insert(Item { kind, name: to.to_string() });
|
||||
}
|
||||
Ok(json!({"kind": kind, "from": from, "name": to}))
|
||||
}
|
||||
|
||||
pub const ACTIVITY: CommandSpec = CommandSpec {
|
||||
id: "model.activity",
|
||||
label: "Step Table",
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: |s, _| Ok(activity(&s.model)),
|
||||
};
|
||||
|
||||
@@ -441,6 +530,27 @@ pub(crate) mod tests {
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
/// A rename rewrites the references, checks clean, is undone in one step, follows the hidden
|
||||
/// state and refuses a name in use (#290).
|
||||
#[test]
|
||||
fn rename_keeps_the_model_whole_and_undoes() {
|
||||
let mut s = Session::default();
|
||||
defined_bar(&mut s);
|
||||
s.execute("view.hide", &json!({"items": [{"kind": "load", "name": "push"}]})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "set", "from": "end", "to": "Tip"})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "load", "from": "push", "to": "Push"})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "step", "from": "Bend", "to": "Load"})).unwrap();
|
||||
assert!(s.model.sets().get("end").is_none() && !s.model.set_users("Tip").is_empty());
|
||||
assert!(s.model.step("Load").is_some_and(|st| st.loads.contains("Push")));
|
||||
assert!(s.view.is_hidden(fieldcraft_io::ItemKind::Load, "Push") && !s.view.is_hidden(fieldcraft_io::ItemKind::Load, "push"));
|
||||
assert_eq!(s.execute("model.check", &json!({})).unwrap()["ready"], true);
|
||||
let e = s.execute("model.rename", &json!({"kind": "set", "from": "Tip", "to": "root"})).unwrap_err().to_string();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert!(s.execute("model.rename", &json!({"kind": "partition", "from": "a", "to": "b"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.model.step("Bend").is_some() && s.model.step("Load").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_defines_a_model_that_checks_clean() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Crash reports and bug report drafts (docs/crash-reports.md): list and read the reports the
|
||||
//! desktop app wrote, draft a bug report, and (with `FIELDCRAFT_CRASH_TEST=1`) crash on purpose
|
||||
//! to check that reporting works.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use fieldcraft_io::roots::Access;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{file_param, opt, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, bug_report, crash};
|
||||
|
||||
pub const CRASH_REPORTS: CommandSpec = CommandSpec {
|
||||
id: "diagnostics.crash_reports",
|
||||
label: "Crash Reports",
|
||||
params: r#"{"limit"?: 20, "dir"?: "…"} — the crash reports the desktop app wrote on this computer, newest first: id, kind (command_panic, recovered_panic, panic, unclean_exit), time, message, command, count, version, path and whether it can be replayed (default folder: FIELDCRAFT_CRASH_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
|
||||
form: Some(super::forms::CRASH_REPORTS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: crash_reports,
|
||||
};
|
||||
|
||||
pub const CRASH_REPORT: CommandSpec = CommandSpec {
|
||||
id: "diagnostics.crash_report",
|
||||
label: "Crash Report",
|
||||
params: r#"{"id": "20261014-101500-…", "dir"?: "…"} — one crash report: what happened, where, the backtrace, the command and recent commands, and the paths of its model, replay and log files"#,
|
||||
form: Some(super::forms::CRASH_REPORT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: crash_report,
|
||||
};
|
||||
|
||||
pub const CRASH_REPORTS_DELETE: CommandSpec = CommandSpec {
|
||||
id: "diagnostics.crash_reports.delete",
|
||||
label: "Delete Crash Reports",
|
||||
params: r#"{"id": "20261014-101500-…"} or {"all": true}; "dir"?: "…" — deletes one crash report, or all of them, from this computer: how many were deleted"#,
|
||||
form: Some(super::forms::CRASH_REPORTS_DELETE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: crash_reports_delete,
|
||||
};
|
||||
|
||||
pub const BUG_REPORT: CommandSpec = CommandSpec {
|
||||
id: "diagnostics.bug_report",
|
||||
label: "Report a Bug",
|
||||
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a GitHub bug report: the issue link (the bug form filled with the version, the OS, what happened and, for a crash, where), and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the desktop app's Help › Report a Bug shows the draft and opens the link when the user asks"#,
|
||||
form: Some(super::forms::BUG_REPORT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: bug_report_draft,
|
||||
};
|
||||
|
||||
pub const CRASH_TEST: CommandSpec = CommandSpec {
|
||||
id: "diagnostics.crash_test",
|
||||
label: "Crash Test",
|
||||
params: r#"{"mode": "panic|recovered|abort"} — crashes on purpose to check that crash reports work (only with FIELDCRAFT_CRASH_TEST=1): panic, an internal error in this command; recovered, one caught inside it; abort, the app ends at once (reported at its next start)"#,
|
||||
form: Some(super::forms::CRASH_TEST),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: crash_test,
|
||||
};
|
||||
|
||||
fn crash_dir(s: &Session, p: &Value) -> Result<PathBuf> {
|
||||
crash_dir_for(s, p, Access::Read)
|
||||
}
|
||||
|
||||
fn crash_dir_for(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
|
||||
if p.get("dir").is_some() {
|
||||
return Ok(PathBuf::from(file_param(s, p, "dir", access)?));
|
||||
}
|
||||
crash::default_dir().ok_or_else(|| EngineError::Params("no crash folder: set FIELDCRAFT_CRASH_DIR or give `dir`".into()))
|
||||
}
|
||||
|
||||
fn crash_reports(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = crash_dir(s, p)?;
|
||||
let limit = opt::<usize>(p, "limit")?.unwrap_or(crash::MAX_REPORTS);
|
||||
let mut reports = crash::list(&dir);
|
||||
reports.truncate(limit);
|
||||
Ok(json!({"dir": dir.to_string_lossy(), "reports": reports}))
|
||||
}
|
||||
|
||||
fn crash_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = crash_dir(s, p)?;
|
||||
let id = req::<String>(p, "id")?;
|
||||
crash::read(&dir, &id).map_err(EngineError::Params)
|
||||
}
|
||||
|
||||
fn crash_reports_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = opt::<String>(p, "id")?;
|
||||
let all = opt::<bool>(p, "all")?.unwrap_or(false);
|
||||
let dir = crash_dir_for(s, p, Access::Write)?;
|
||||
let deleted = match (id.as_deref(), all) {
|
||||
(Some(id), false) => match crash::clear(&dir, Some(id)) {
|
||||
0 => return Err(EngineError::Params(format!("no crash report `{}`", crash::limited(id, 64)))),
|
||||
n => n,
|
||||
},
|
||||
(None, true) => crash::clear(&dir, None),
|
||||
_ => return Err(EngineError::Params("give one of `id` (a crash report) or `all: true`".into())),
|
||||
};
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
/// The draft input for `p` from the session: its commands and model (when shared), or a crash
|
||||
/// report's replay and model.
|
||||
pub fn bug_report_input(s: &Session, p: &Value) -> Result<bug_report::Input> {
|
||||
let share = opt::<bool>(p, "share_session")?.unwrap_or(false);
|
||||
let include_model = opt::<bool>(p, "include_model")?.unwrap_or(false);
|
||||
let mut input = bug_report::Input {
|
||||
title: opt::<String>(p, "title")?.unwrap_or_default(),
|
||||
observed: opt::<String>(p, "observed")?.unwrap_or_default(),
|
||||
share_session: share,
|
||||
include_model,
|
||||
..Default::default()
|
||||
};
|
||||
match opt::<String>(p, "crash")? {
|
||||
Some(id) => {
|
||||
let report = crash::read(&crash_dir(s, p)?, &id).map_err(EngineError::Params)?;
|
||||
if share {
|
||||
let read = |k: &str| {
|
||||
report
|
||||
.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|f| fieldcraft_io::fs::read_capped(std::path::Path::new(f), crash::MAX_MODEL_BYTES as u64).ok())
|
||||
};
|
||||
let replay: Value = read("replay_path").and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
|
||||
input.commands = replay.get("commands").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
input.model = if include_model { read("model_path") } else { None };
|
||||
}
|
||||
input.crash = Some(report);
|
||||
}
|
||||
None if share => {
|
||||
input.commands = s.journal.iter().map(|(command, params)| json!({"command": command, "params": params})).collect();
|
||||
input.model = if include_model { Some(s.file_bytes(None)?) } else { None };
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
fn bug_report_draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let input = bug_report_input(s, p)?;
|
||||
Ok(bug_report::draft(&input, &crash::Build::current().version_line()).to_json())
|
||||
}
|
||||
|
||||
// The one deliberate panic and abort: they prove that crash reports work, and run only when
|
||||
// asked for with FIELDCRAFT_CRASH_TEST=1.
|
||||
#[allow(clippy::panic)]
|
||||
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = req::<String>(p, "mode")?;
|
||||
if std::env::var_os("FIELDCRAFT_CRASH_TEST").is_none_or(|v| v != "1") {
|
||||
return Err(EngineError::Params("the crash test runs only with FIELDCRAFT_CRASH_TEST=1".into()));
|
||||
}
|
||||
match mode.as_str() {
|
||||
"panic" => std::panic::panic_any(String::from("crash test: an internal error in a command")),
|
||||
"recovered" => {
|
||||
let caught = std::panic::catch_unwind(|| std::panic::panic_any(String::from("crash test: an internal error caught inside a command")));
|
||||
Ok(json!({"caught": caught.is_err()}))
|
||||
}
|
||||
"abort" => std::process::abort(),
|
||||
_ => Err(EngineError::invalid("mode", "must be panic, recovered or abort")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `diagnostics.crash_reports.delete` deletes one report by id, then the rest; an unknown or
|
||||
/// hostile id and a missing choice are errors that delete nothing.
|
||||
#[test]
|
||||
fn crash_reports_are_deleted_one_or_all() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-crash-delete-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let ids = ["20261014-101500-0000000000000001", "20261014-101600-0000000000000002", "20261014-101700-0000000000000003"];
|
||||
for id in ids {
|
||||
std::fs::create_dir_all(dir.join(id)).unwrap();
|
||||
std::fs::write(dir.join(id).join("report.json"), r#"{"kind": "panic", "message": "x"}"#).unwrap();
|
||||
}
|
||||
let mut s = Session::default();
|
||||
let d = dir.to_string_lossy().to_string();
|
||||
let listed = |s: &mut Session| s.execute("diagnostics.crash_reports", &json!({"dir": d})).unwrap()["reports"].as_array().map_or(0, Vec::len);
|
||||
assert_eq!(listed(&mut s), 3);
|
||||
for bad in [
|
||||
json!({"dir": d}),
|
||||
json!({"dir": d, "id": "../x"}),
|
||||
json!({"dir": d, "id": "20261014-101500-00000000000000ff"}),
|
||||
json!({"dir": d, "id": ids[0], "all": true}),
|
||||
] {
|
||||
assert!(s.execute("diagnostics.crash_reports.delete", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(listed(&mut s), 3);
|
||||
assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "id": ids[1]})).unwrap()["deleted"], 1);
|
||||
assert_eq!(listed(&mut s), 2);
|
||||
assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "all": true})).unwrap()["deleted"], 2);
|
||||
assert_eq!(listed(&mut s), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub const UNDO: CommandSpec = CommandSpec {
|
||||
params: r#"{"count"?: 1} — takes back the last command that changed the model (it leaves the journal and can be redone); `count` takes back that many, as far as the history reaches"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::undo),
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ pub const REDO: CommandSpec = CommandSpec {
|
||||
params: r#"{"count"?: 1} — runs the last undone command's change again; `count` redoes that many, as far as there are undone commands"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::redo),
|
||||
};
|
||||
|
||||
|
||||
@@ -19,12 +19,23 @@ use fieldcraft_io::roots::{Access, Roots};
|
||||
use super::{file_param, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
|
||||
|
||||
pub const NEW: CommandSpec = CommandSpec {
|
||||
id: "file.new",
|
||||
label: "New Model",
|
||||
params: r#"{} — starts an empty model in place of the current one: no design, mesh, definitions, results or journal; unsaved changes are discarded (the window asks first) and the undo history starts empty"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: new_model,
|
||||
};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
params: r#"{"path": "bracket.fieldcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
form: Some(super::forms::FILE_OPEN),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -34,6 +45,7 @@ pub const SAVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"path"?: "bracket.fieldcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `<file>.bak`"#,
|
||||
form: Some(super::forms::FILE_SAVE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: save,
|
||||
};
|
||||
|
||||
@@ -43,15 +55,17 @@ pub const SAVE_AS: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "bracket.fieldcraft"} — saves to a new file, which becomes the model's file"#,
|
||||
form: Some(super::forms::FILE_SAVE_AS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: save_as,
|
||||
};
|
||||
|
||||
pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.list",
|
||||
label: "Recoverable Models",
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first, each with its id, path, saved_at, age (seconds) and node and element counts (default folder: FIELDCRAFT_RECOVERY_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
|
||||
form: Some(super::forms::RECOVERY_LIST),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recovery_list,
|
||||
};
|
||||
|
||||
@@ -61,6 +75,7 @@ pub const RECOVER: CommandSpec = CommandSpec {
|
||||
params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#,
|
||||
form: Some(super::forms::RECOVER),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recover,
|
||||
};
|
||||
|
||||
@@ -70,6 +85,7 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#,
|
||||
form: Some(super::forms::RECOVERY_DISCARD),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
@@ -79,9 +95,19 @@ pub const SAMPLE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "cantilever"} — opens a sample model (fieldcraft_engine::sample; the list is in docs/examples.md) in place of the current one, unsaved changes discarded; every sample but `parts` is ready to solve (solve.run)"#,
|
||||
form: Some(super::forms::SAMPLE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open_sample,
|
||||
};
|
||||
|
||||
fn new_model(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut empty = Session::default();
|
||||
// Nothing to save until a command changes it.
|
||||
empty.saved_revision = Some(empty.revision);
|
||||
empty.saved_view = empty.view.clone();
|
||||
s.replace_with(empty);
|
||||
Ok(summary(s, &[]))
|
||||
}
|
||||
|
||||
fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: &str = req(p, "name")?;
|
||||
let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::<Vec<_>>().join(", ");
|
||||
@@ -90,11 +116,16 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(recipe) = sample.design {
|
||||
opened.design = Some(Arc::new(open_design(&fieldcraft_geometry::script_design(recipe)?)?));
|
||||
}
|
||||
if let Some(prepare) = sample.prepare {
|
||||
opened.run_script(&prepare())?;
|
||||
}
|
||||
let bodies = opened.design.as_ref().map_or(&[][..], |d| d.bodies.as_slice());
|
||||
let script = (sample.script)(bodies);
|
||||
opened.run_script(&script)?;
|
||||
// A sample just opened has nothing to save: it opens again the same way.
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
opened.saved_view = opened.view.clone();
|
||||
opened.sample = Some(sample.label);
|
||||
s.replace_with(opened);
|
||||
let mut reply = summary(s, &[]);
|
||||
reply["sample"] = json!(sample.name);
|
||||
@@ -180,7 +211,7 @@ impl Session {
|
||||
design: self.sources.design.as_ref().map(stored).transpose()?,
|
||||
meshes: self.sources.meshes.iter().map(stored).collect::<fieldcraft_io::Result<_>>()?,
|
||||
};
|
||||
Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal)?)
|
||||
Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal, &self.view)?)
|
||||
}
|
||||
|
||||
/// A session holding what a file holds, its geometry opened again from the paths it names
|
||||
@@ -192,7 +223,16 @@ impl Session {
|
||||
let mut design: Option<Arc<Design>> = None;
|
||||
if let Some(stored) = &file.geometry.design {
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
|
||||
design = opened.map(Arc::new);
|
||||
design = opened.and_then(|d| match super::datum::partitioned(&d, &file.model) {
|
||||
Ok(cut) => Some(Arc::new(cut)),
|
||||
Err(e) => {
|
||||
warnings.push(format!("the design's partitions could not be applied ({e}); the model opens without the design"));
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(d) = &design {
|
||||
warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message)));
|
||||
}
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
@@ -201,18 +241,30 @@ impl Session {
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
let session =
|
||||
Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() };
|
||||
let session = Session {
|
||||
model: file.model,
|
||||
design,
|
||||
surfaces,
|
||||
sources,
|
||||
journal: file.journal,
|
||||
revision: next_revision(),
|
||||
saved_view: file.view.clone(),
|
||||
view: file.view,
|
||||
..Session::default()
|
||||
};
|
||||
(session, warnings)
|
||||
}
|
||||
|
||||
/// Replace this session with `opened`, keeping its undo memory limit, its work caps and its
|
||||
/// file access; the history starts empty.
|
||||
/// file access (and where its commands come from); the history starts empty.
|
||||
fn replace_with(&mut self, mut opened: Session) {
|
||||
opened.history = std::mem::take(&mut self.history);
|
||||
opened.limits = self.limits;
|
||||
opened.file_access = std::mem::take(&mut self.file_access);
|
||||
opened.crash_journal = self.crash_journal;
|
||||
opened.origin = self.origin;
|
||||
opened.clear_history();
|
||||
opened.mark_opened();
|
||||
*self = opened;
|
||||
}
|
||||
}
|
||||
@@ -288,8 +340,15 @@ fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entries = fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?);
|
||||
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
|
||||
let mut listed = Vec::new();
|
||||
for e in fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?) {
|
||||
let mut v = serde_json::to_value(&e).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("age".into(), json!(e.age()));
|
||||
}
|
||||
listed.push(v);
|
||||
}
|
||||
Ok(Value::Array(listed))
|
||||
}
|
||||
|
||||
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -335,6 +394,19 @@ mod tests {
|
||||
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
|
||||
}
|
||||
|
||||
/// `file.new` empties the model, its journal and its undo history, and has nothing to save.
|
||||
#[test]
|
||||
fn new_starts_an_empty_model() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
s.execute("mesh.node.add", &json!({"label": 10_000, "xyz": [0, 0, 0]})).unwrap();
|
||||
let r = s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!((r["nodes"].as_u64(), r["elements"].as_u64(), r["journal"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||||
assert!(s.model.materials().is_empty() && s.model.steps().is_empty() && s.design.is_none() && s.sample.is_none());
|
||||
assert!(!s.is_dirty(), "an empty model has nothing to save");
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err(), "the undo history starts empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_keep_the_model_journal_and_design() {
|
||||
let d = dir("roundtrip");
|
||||
@@ -405,6 +477,9 @@ mod tests {
|
||||
refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input})));
|
||||
s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap();
|
||||
defined(&mut s);
|
||||
// A section, so deck.write has all it needs and reaches its path.
|
||||
s.execute("model.set.create", &json!({"name": "all", "elements": [1]})).unwrap();
|
||||
s.execute("model.section.create", &json!({"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
|
||||
// Writes go to the write root only: not the read root, not elsewhere.
|
||||
refused(s.execute("file.save_as", &json!({"path": input.join("m.fieldcraft")})));
|
||||
refused(s.execute("file.save_as", &json!({"path": outside.join("m.fieldcraft")})));
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
use crate::schema::ProblemKind;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::tagged_mesh;
|
||||
use fieldcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use fieldcraft_geometry::{Design, MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use fieldcraft_io::SourceFile;
|
||||
use fieldcraft_io::roots::Access;
|
||||
use fieldcraft_model::Model;
|
||||
use fieldcraft_model::geometry::{Entity, GeometryRef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) `sets` (sets naming a body or face it lacks) and `seeds` (edge seeds on edges it lacks). `errors` lists the features that failed to rebuild with the `bodies` each may change, and `warnings` says so in one line each. One undo step"#,
|
||||
form: Some(super::forms::OPEN_DESIGN),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length) and vertices (persistent name `<face>|<face>|<face>` after the faces that meet there, `~k` when several touch the same faces, and point)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[], // No design is an answer too: no bodies.
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
pub const CENTRE: CommandSpec = CommandSpec {
|
||||
id: "geometry.centre",
|
||||
label: "Centre of Selection",
|
||||
params: r#"{"geometry": [{"body": "Body1", "face": "F3:hole"} | {"body", "edge"} | {"body", "vertex"}], "nodes": [labels]} — the point a reference point or datum point goes at for what is picked: a vertex's point; the mean of nodes; the centre of a hole (cylindrical faces on one axis, or circular edges on one circle: the axis point at their mid-depth, the circle's centre); otherwise the area centroid of the faces and the length centroid of the edges. Replies `point` and `from` (`vertex`, `nodes`, `circle`, `centroid`)"#,
|
||||
form: Some(super::forms::CENTRE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: centre,
|
||||
};
|
||||
|
||||
pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
form: Some(super::forms::TESSELLATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
@@ -41,29 +59,103 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
form: Some(super::forms::IMPORT_MESH),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_model = match p.get("model") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("keep") => false,
|
||||
Some("new") => true,
|
||||
_ => return Err(EngineError::Params(r#"`model` must be "keep" or "new""#.into())),
|
||||
},
|
||||
};
|
||||
let path = super::file_param(s, p, "path", Access::Read)?;
|
||||
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
|
||||
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
|
||||
let design = open_design(&bytes)?;
|
||||
let opened = open_design(&bytes)?;
|
||||
// The model's partitions cut the design again (a new model has none).
|
||||
let design = if new_model { opened } else { super::datum::partitioned(&opened, &s.model)? };
|
||||
let mut fit = fit(s, &design);
|
||||
if new_model {
|
||||
let mut model = Model::default();
|
||||
model.set_units(s.model.units());
|
||||
s.model = model;
|
||||
s.surfaces.clear();
|
||||
s.sources.meshes.clear();
|
||||
s.results_view = None;
|
||||
// A new model is untitled: saving asks for a file instead of overwriting the old model's
|
||||
// (undo brings the old file back with the old model).
|
||||
s.set_document(Default::default());
|
||||
fit.mesh_bodies.clear();
|
||||
fit.sets.clear();
|
||||
fit.seeds.clear();
|
||||
}
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message, "bodies": e.bodies})).collect::<Vec<_>>(),
|
||||
// Shown with the open: a body that differs from the design is never silent.
|
||||
"warnings": design.errors.iter().map(|e| e.warning()).collect::<Vec<_>>(),
|
||||
"model": if new_model { "new" } else { "kept" },
|
||||
"related": fit.related,
|
||||
"stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets, "seeds": fit.seeds},
|
||||
});
|
||||
// Another design file (or a new model) is another document; the same file opened again after
|
||||
// an edit is the same one.
|
||||
if new_model || s.sources.design.as_ref().is_none_or(|d| d.path() != source.path()) {
|
||||
s.mark_opened();
|
||||
}
|
||||
s.design = Some(Arc::new(design));
|
||||
s.sources.design = Some(source);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How the model's geometry references fit a design about to become the geometry.
|
||||
struct Fit {
|
||||
/// Whether the design (or an imported surface) has a body the model refers to, or the model
|
||||
/// refers to none: `false` means the model is of another part.
|
||||
related: bool,
|
||||
/// Bodies the mesh is tagged with that neither the design nor a surface has.
|
||||
mesh_bodies: Vec<String>,
|
||||
/// Sets naming a body or a face that is not there.
|
||||
sets: Vec<String>,
|
||||
/// Edge seeds on an edge the design lacks.
|
||||
seeds: Vec<String>,
|
||||
}
|
||||
|
||||
fn fit(s: &Session, design: &Design) -> Fit {
|
||||
let has = |body: &str| design.body(body).is_some() || s.surfaces.iter().any(|t| t.name == body);
|
||||
let resolves = |r: &GeometryRef| match (&r.entity, design.body(&r.body)) {
|
||||
(Entity::Face(face), Some(b)) => b.faces().iter().any(|f| &f.name == face),
|
||||
_ => has(&r.body),
|
||||
};
|
||||
let tagged: BTreeSet<&str> = s.model.classification().entities().iter().map(|r| r.body.as_str()).collect();
|
||||
let mut referenced = tagged.clone();
|
||||
referenced.extend(s.model.sets().values().flat_map(|set| set.geometry.iter().map(|r| r.body.as_str())));
|
||||
Fit {
|
||||
related: referenced.is_empty() || referenced.iter().any(|b| has(b)),
|
||||
mesh_bodies: tagged.into_iter().filter(|b| !has(b)).map(str::to_string).collect(),
|
||||
sets: s.model.sets().iter().filter(|(_, set)| !set.geometry.iter().all(resolves)).map(|(n, _)| n.clone()).collect(),
|
||||
seeds: s
|
||||
.model
|
||||
.seeds()
|
||||
.iter()
|
||||
.filter(|seed| super::seeds::seeded_edges(design, &seed.edge).map_or(true, |e| e.is_empty()))
|
||||
.map(|seed| seed.edge.to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(design) = &s.design else { return Ok(json!([])) };
|
||||
let mut out = Vec::with_capacity(design.bodies.len());
|
||||
for b in &design.bodies {
|
||||
let m = b.measure()?;
|
||||
let edges: Vec<Value> = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect();
|
||||
let vertices: Vec<Value> = b.vertices()?.iter().map(|v| json!({"name": v.name, "point": v.point})).collect();
|
||||
out.push(json!({
|
||||
"name": b.name,
|
||||
"volume": m.volume,
|
||||
@@ -71,20 +163,203 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"centroid": m.centroid,
|
||||
"bbox": [m.bbox_min, m.bbox_max],
|
||||
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
|
||||
"edges": edges,
|
||||
"vertices": vertices,
|
||||
}));
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
/// Most geometry items and nodes `geometry.centre` takes.
|
||||
const MAX_CENTRE_ITEMS: usize = 10_000;
|
||||
|
||||
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
|
||||
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn scale3(a: [f64; 3], k: f64) -> [f64; 3] {
|
||||
a.map(|v| v * k)
|
||||
}
|
||||
|
||||
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// The centre of the circle through three points, `None` when they lie on a line.
|
||||
fn circumcentre(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let (u, v) = (sub3(b, a), sub3(c, a));
|
||||
let n = cross3(u, v);
|
||||
let nn = dot3(n, n);
|
||||
if !(nn > 1e-24 * dot3(u, u) * dot3(v, v)) {
|
||||
return None;
|
||||
}
|
||||
let k = add3(scale3(cross3(n, u), dot3(v, v)), scale3(cross3(v, n), dot3(u, u)));
|
||||
Some(add3(a, scale3(k, 0.5 / nn)))
|
||||
}
|
||||
|
||||
/// The centre of an edge's polyline when it is an arc or circle (every point at one distance
|
||||
/// from it, within a thousandth), else `None`.
|
||||
fn circle_centre(points: &[[f64; 3]]) -> Option<[f64; 3]> {
|
||||
let n = points.len();
|
||||
if n < 3 {
|
||||
return None;
|
||||
}
|
||||
let closed = points.first().zip(points.last()).is_some_and(|(a, b)| dot3(sub3(*a, *b), sub3(*a, *b)) < 1e-18);
|
||||
let pick = |f: f64| points.get(((n - 1) as f64 * f).round() as usize).copied();
|
||||
let (a, b, c) = if closed { (pick(0.0)?, pick(1.0 / 3.0)?, pick(2.0 / 3.0)?) } else { (pick(0.0)?, pick(0.5)?, pick(1.0)?) };
|
||||
let centre = circumcentre(a, b, c)?;
|
||||
let r = dot3(sub3(a, centre), sub3(a, centre)).sqrt();
|
||||
let on = points.iter().all(|p| (dot3(sub3(*p, centre), sub3(*p, centre)).sqrt() - r).abs() <= 1e-3 * r);
|
||||
on.then_some(centre)
|
||||
}
|
||||
|
||||
/// The length-weighted centre of a polyline and its length.
|
||||
fn polyline_centroid(points: &[[f64; 3]]) -> ([f64; 3], f64) {
|
||||
let (mut sum, mut length) = ([0.0; 3], 0.0);
|
||||
for w in points.windows(2) {
|
||||
let l = dot3(sub3(w[1], w[0]), sub3(w[1], w[0])).sqrt();
|
||||
sum = add3(sum, scale3(add3(w[0], w[1]), 0.5 * l));
|
||||
length += l;
|
||||
}
|
||||
(if length > 0.0 { scale3(sum, 1.0 / length) } else { points.first().copied().unwrap_or_default() }, length)
|
||||
}
|
||||
|
||||
fn centre(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let geometry: Vec<GeometryRef> = super::opt(p, "geometry")?.unwrap_or_default();
|
||||
let nodes: Vec<u32> = super::opt(p, "nodes")?.unwrap_or_default();
|
||||
if geometry.len() + nodes.len() > MAX_CENTRE_ITEMS {
|
||||
return Err(EngineError::Params(format!("geometry.centre takes at most {MAX_CENTRE_ITEMS} items")));
|
||||
}
|
||||
if geometry.is_empty() && nodes.is_empty() {
|
||||
return Err(EngineError::Params("give one of `geometry` or `nodes`: what to find the centre of".into()));
|
||||
}
|
||||
if !geometry.is_empty() && !nodes.is_empty() {
|
||||
return Err(EngineError::Params("give either `geometry` or `nodes`, not both".into()));
|
||||
}
|
||||
if !nodes.is_empty() {
|
||||
let mut sum = [0.0; 3];
|
||||
for n in &nodes {
|
||||
let node = s.model.node(*n).ok_or_else(|| EngineError::Params(format!("no node {n}")))?;
|
||||
sum = add3(sum, node.xyz);
|
||||
}
|
||||
return Ok(json!({"point": scale3(sum, 1.0 / nodes.len() as f64), "from": "nodes"}));
|
||||
}
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let shape = |b: &str| design.body(b).ok_or_else(|| EngineError::Params(format!("the design has no body `{b}`")));
|
||||
if let [g] = geometry.as_slice()
|
||||
&& let Entity::Vertex(v) = &g.entity
|
||||
{
|
||||
let vertex = shape(&g.body)?.vertices()?.into_iter().find(|x| &x.name == v);
|
||||
let vertex = vertex.ok_or_else(|| EngineError::Params(format!("body `{}` has no vertex `{v}`", g.body)))?;
|
||||
return Ok(json!({"point": vertex.point, "from": "vertex"}));
|
||||
}
|
||||
// Hole centres: every face a cylinder about one axis, or every edge an arc of one circle.
|
||||
let mut circles: Vec<[f64; 3]> = Vec::new();
|
||||
let mut axis: Option<([f64; 3], [f64; 3])> = None;
|
||||
let mut round = true;
|
||||
// Centroids, weighted by area (faces) or length (edges).
|
||||
let (mut faces_sum, mut faces_area, mut edges_sum, mut edges_length) = ([0.0; 3], 0.0, [0.0; 3], 0.0);
|
||||
for g in &geometry {
|
||||
let body = shape(&g.body)?;
|
||||
match &g.entity {
|
||||
Entity::Face(f) => {
|
||||
let i = body.face_index(f).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{f}`", g.body)))?;
|
||||
let c = body.face_centre(f)?;
|
||||
let mesh = body.tessellate((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4))?;
|
||||
let area: f64 = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, t)| **t == i)
|
||||
.filter_map(|(t, _)| {
|
||||
let q = |k: usize| mesh.positions.get(t[k] as usize).copied();
|
||||
let (a, b, c) = (q(0)?, q(1)?, q(2)?);
|
||||
let n = cross3(sub3(b, a), sub3(c, a));
|
||||
Some(0.5 * dot3(n, n).sqrt())
|
||||
})
|
||||
.sum();
|
||||
faces_sum = add3(faces_sum, scale3(c, area));
|
||||
faces_area += area;
|
||||
match body.face_surfaces()?.get(i as usize).copied().flatten() {
|
||||
Some(fieldcraft_geometry::FaceSurface::Cylinder { origin, axis: d, .. }) => {
|
||||
let len = dot3(d, d).sqrt();
|
||||
let d = scale3(d, 1.0 / len.max(1e-300));
|
||||
// The face centre's foot on the axis.
|
||||
let foot = add3(origin, scale3(d, dot3(sub3(c, origin), d)));
|
||||
let same = axis.is_none_or(|(o, a)| {
|
||||
let off = sub3(foot, o);
|
||||
let across = sub3(off, scale3(a, dot3(off, a)));
|
||||
dot3(cross3(a, d), cross3(a, d)) < 1e-12 && dot3(across, across).sqrt() <= 1e-6 * body.size().unwrap_or(1.0).max(1e-9)
|
||||
});
|
||||
round &= same;
|
||||
axis.get_or_insert((foot, d));
|
||||
circles.push(foot);
|
||||
}
|
||||
_ => round = false,
|
||||
}
|
||||
}
|
||||
Entity::Edge(e) => {
|
||||
let edges = body.edges()?;
|
||||
let edge = edges.iter().find(|x| &x.name == e).ok_or_else(|| EngineError::Params(format!("body `{}` has no edge `{e}`", g.body)))?;
|
||||
let (c, l) = polyline_centroid(&edge.points);
|
||||
edges_sum = add3(edges_sum, scale3(c, l));
|
||||
edges_length += l;
|
||||
match circle_centre(&edge.points) {
|
||||
Some(c) => circles.push(c),
|
||||
None => round = false,
|
||||
}
|
||||
}
|
||||
Entity::Vertex(_) | Entity::Body => {
|
||||
return Err(EngineError::Params("geometry.centre takes one vertex, or faces and edges".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arcs of one circle share a centre; faces of one axis were checked as they came.
|
||||
let spread = circles.iter().map(|c| circles.first().map_or(0.0, |f| dot3(sub3(*c, *f), sub3(*c, *f)).sqrt())).fold(0.0f64, f64::max);
|
||||
let size = geometry.first().and_then(|g| design.body(&g.body)).and_then(|b| b.size().ok()).unwrap_or(1.0).max(1e-9);
|
||||
let edges_only = geometry.iter().all(|g| matches!(g.entity, Entity::Edge(_)));
|
||||
if round && !circles.is_empty() && (!edges_only || spread <= 1e-6 * size) {
|
||||
let point = match axis {
|
||||
// Cylinder faces: the axis point at the mean of their centres' feet.
|
||||
Some((_, d)) => {
|
||||
let mean = scale3(circles.iter().fold([0.0; 3], |a, c| add3(a, *c)), 1.0 / circles.len() as f64);
|
||||
let o = circles.first().copied().unwrap_or(mean);
|
||||
add3(o, scale3(d, dot3(sub3(mean, o), d)))
|
||||
}
|
||||
None => circles.first().copied().unwrap_or_default(),
|
||||
};
|
||||
return Ok(json!({"point": point, "from": "circle"}));
|
||||
}
|
||||
let point = if faces_area > 0.0 {
|
||||
scale3(faces_sum, 1.0 / faces_area)
|
||||
} else if edges_length > 0.0 {
|
||||
scale3(edges_sum, 1.0 / edges_length)
|
||||
} else {
|
||||
return Err(EngineError::Params("what is picked has no area or length to find a centre of".into()));
|
||||
};
|
||||
if !point.iter().all(|v| v.is_finite()) {
|
||||
return Err(EngineError::Params("the centre is not a finite point".into()));
|
||||
}
|
||||
Ok(json!({"point": point, "from": "centroid"}))
|
||||
}
|
||||
|
||||
fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?;
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
|
||||
}
|
||||
|
||||
/// The body named by `body`, tessellated within `tolerance`.
|
||||
pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, SurfaceMesh)> {
|
||||
let shape = body(s, p)?;
|
||||
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::Params("`tolerance` must be a number (mm)".into()))?;
|
||||
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::param("tolerance", ProblemKind::NotANumber))?;
|
||||
Ok((shape, shape.tessellate(tolerance)?))
|
||||
}
|
||||
|
||||
@@ -132,6 +407,73 @@ mod tests {
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The plate's hole (Ø10 at x = y = 15 through z 0..10): its faces give the axis point at
|
||||
/// mid-depth, a rim arc its circle's centre, a corner vertex its point, a flat face its
|
||||
/// centroid; datum points take the design's vertices by name.
|
||||
#[test]
|
||||
fn centres_of_holes_vertices_and_faces() {
|
||||
use fieldcraft_geometry::FaceSurface;
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
let design = Arc::new(open_design(&plate_design(&Plate::default()).unwrap()).unwrap());
|
||||
let mut s = Session { design: Some(design.clone()), ..Session::default() };
|
||||
let body = &design.bodies[0];
|
||||
let near = |v: &Value, p: [f64; 3]| {
|
||||
let q: [f64; 3] = serde_json::from_value(v["point"].clone()).unwrap();
|
||||
assert!((0..3).all(|k| (q[k] - p[k]).abs() < 1e-6), "{v} vs {p:?}");
|
||||
};
|
||||
let surfaces = body.face_surfaces().unwrap();
|
||||
let hole: Vec<Value> = body
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(&surfaces)
|
||||
.filter(|(_, s)| matches!(s, Some(FaceSurface::Cylinder { .. })))
|
||||
.map(|(f, _)| json!({"body": body.name, "face": f.name}))
|
||||
.collect();
|
||||
assert_eq!(hole.len(), 2);
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": hole})).unwrap();
|
||||
near(&r, [15.0, 15.0, 5.0]);
|
||||
assert_eq!(r["from"], "circle");
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [hole[0]]})).unwrap();
|
||||
near(&r, [15.0, 15.0, 5.0]);
|
||||
// A rim arc on top.
|
||||
let edges = body.edges().unwrap();
|
||||
let arc = edges.iter().find(|e| e.points.iter().all(|p| (p[2] - 10.0).abs() < 1e-9) && e.points.len() > 3).unwrap();
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "edge": arc.name}]})).unwrap();
|
||||
near(&r, [15.0, 15.0, 10.0]);
|
||||
assert_eq!(r["from"], "circle");
|
||||
// The corner at the origin, and a datum point on it by name.
|
||||
let corner = body.vertices().unwrap().into_iter().find(|v| v.point == [0.0; 3]).unwrap();
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "vertex": corner.name}]})).unwrap();
|
||||
near(&r, [0.0; 3]);
|
||||
s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "vertex", "body": body.name, "vertex": corner.name}})).unwrap();
|
||||
assert_eq!(s.model.datum("P").and_then(|d| d.point()), Some([0.0; 3]));
|
||||
let listed = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
assert!(listed[0]["vertices"].as_array().unwrap().iter().any(|v| v["name"] == corner.name.as_str()), "{listed}");
|
||||
// The bottom face (z = 0): its centroid, off the hole.
|
||||
let bottom = body
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(&surfaces)
|
||||
.find(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, point }) if normal[2].abs() > 0.99 && point[2].abs() < 1e-9))
|
||||
.unwrap()
|
||||
.0;
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "face": bottom.name}]})).unwrap();
|
||||
assert_eq!(r["from"], "centroid");
|
||||
// Nodes, and what is refused.
|
||||
s.model.set_node(1, [1.0, 2.0, 3.0]).unwrap();
|
||||
s.model.set_node(2, [3.0, 2.0, 1.0]).unwrap();
|
||||
near(&s.execute("geometry.centre", &json!({"nodes": [1, 2]})).unwrap(), [2.0, 2.0, 2.0]);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"nodes": [99]}),
|
||||
json!({"geometry": [{"body": "nope"}]}),
|
||||
json!({"geometry": [{"body": body.name}]}),
|
||||
json!({"nodes": "x"}),
|
||||
] {
|
||||
assert!(s.execute("geometry.centre", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_tagged_surfaces_and_failures_change_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-import-mesh-{}", std::process::id()));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Structured hexahedral meshing of the open design's bodies or of imported surfaces.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
|
||||
@@ -9,14 +10,16 @@ use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.generate",
|
||||
label: "Generate Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
label: "Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_generate,
|
||||
};
|
||||
|
||||
@@ -33,9 +36,12 @@ const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
|
||||
pub(super) struct Input<'a> {
|
||||
pub(super) name: String,
|
||||
pub(super) mesh: Cow<'a, SurfaceMesh>,
|
||||
surfaces: Vec<Option<Surface>>,
|
||||
pub(super) surfaces: Vec<Option<Surface>>,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) diagonal: f64,
|
||||
/// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its
|
||||
/// edges.
|
||||
pub(super) seeds: Vec<fieldcraft_mesh::seeds::EdgeSeed>,
|
||||
}
|
||||
|
||||
fn analytic(s: FaceSurface) -> Surface {
|
||||
@@ -51,11 +57,7 @@ fn analytic(s: FaceSurface) -> Surface {
|
||||
pub(super) fn number(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && *x > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive number (mm)"))),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x > 0.0).map(Some).ok_or_else(|| EngineError::param(key, ProblemKind::NotPositive)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ fn diagonal(lo: [f64; 3], hi: [f64; 3]) -> f64 {
|
||||
(0..3).map(|i| (hi[i] - lo[i]).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>> {
|
||||
pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>> {
|
||||
let diag = shape.size()?;
|
||||
let tol = tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
|
||||
if tol < MIN_RELATIVE_TOLERANCE * diag {
|
||||
@@ -71,26 +73,33 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
|
||||
}
|
||||
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
|
||||
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
|
||||
}
|
||||
|
||||
/// The imported surface `surface` names, with the body name its mesh is tagged with.
|
||||
pub(super) fn surface<'a>(s: &'a Session, p: &Value) -> Result<(String, &'a fieldcraft_geometry::tagged_mesh::TaggedSurface)> {
|
||||
let n = s.surfaces.len();
|
||||
let index = p.get("surface").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n);
|
||||
let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
|
||||
let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?;
|
||||
let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() };
|
||||
Ok((name, t))
|
||||
}
|
||||
|
||||
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
let tolerance = number(p, "tolerance")?;
|
||||
match (p.get("body"), p.get("surface")) {
|
||||
(Some(b), None) => {
|
||||
let name = b.as_str().ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?;
|
||||
let name = b.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
let shape = s
|
||||
.design
|
||||
.as_deref()
|
||||
.and_then(|d| d.body(name))
|
||||
.ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?;
|
||||
Ok(vec![shape_input(shape, tolerance)?])
|
||||
Ok(vec![Input { seeds: super::seeds::edge_seeds(s)?, ..shape_input(shape, tolerance)? }])
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
let n = s.surfaces.len();
|
||||
let index = i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n);
|
||||
let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
|
||||
let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?;
|
||||
(None, Some(_)) => {
|
||||
let (name, t) = surface(s, p)?;
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for q in &t.mesh.positions {
|
||||
for k in 0..3 {
|
||||
@@ -98,17 +107,17 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() };
|
||||
// Faces named by index, as mesh.tet.generate names them.
|
||||
let face_names = (0..t.faces).map(|f| f.to_string()).collect();
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi) }])
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }])
|
||||
}
|
||||
(None, None) => {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?;
|
||||
if design.bodies.is_empty() || design.bodies.len() > MAX_BODIES {
|
||||
return Err(EngineError::Params(format!("the design has {} bodies; meshing takes 1 to {MAX_BODIES}", design.bodies.len())));
|
||||
}
|
||||
design.bodies.iter().map(|b| shape_input(b, tolerance)).collect()
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
design.bodies.iter().map(|b| Ok(Input { seeds: seeds.clone(), ..shape_input(b, tolerance)? })).collect()
|
||||
}
|
||||
_ => Err(EngineError::Params("give `body` or `surface`, not both".into())),
|
||||
}
|
||||
@@ -120,6 +129,7 @@ pub const HEX_SWEEP: CommandSpec = CommandSpec {
|
||||
params: r#"the parameters of mesh.hex.generate, with "method": "swept" (default: extrude, sweep or revolve, whichever fits first) | "extrude" | "sweep" | "revolve" — mesh.hex.generate limited to the sweeping methods"#,
|
||||
form: Some(super::forms::HEX_SWEEP),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_sweep,
|
||||
};
|
||||
|
||||
@@ -129,33 +139,35 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
|
||||
params: r#"the parameters of mesh.hex.generate without "method" — mesh.hex.generate with the mapped method: a six-sided block (six faces of four edges, three edges at each of eight corners)"#,
|
||||
form: Some(super::forms::HEX_MAPPED),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_mapped,
|
||||
};
|
||||
|
||||
pub const HEX_BLOCKS: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.blocks",
|
||||
label: "Block Hex Mesh",
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
form: Some(super::forms::HEX_BLOCKS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: hex_blocks,
|
||||
};
|
||||
|
||||
fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = number(p, "size")?;
|
||||
let quadratic = order(p)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let shapes: Vec<&Shape> = match p.get("bodies") {
|
||||
None => design.bodies.iter().collect(),
|
||||
Some(v) => {
|
||||
let names = v.as_array().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
|
||||
let names = v.as_array().ok_or_else(|| EngineError::param("bodies", ProblemKind::NotNames))?;
|
||||
// Bounded before the duplicate check, which is quadratic in the list.
|
||||
if names.len() > MAX_CELLS {
|
||||
return Err(EngineError::Params(format!("`bodies` lists {} bodies; at most {MAX_CELLS} are meshed together", names.len())));
|
||||
}
|
||||
let mut out: Vec<&Shape> = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
let name = n.as_str().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
|
||||
let name = n.as_str().ok_or_else(|| EngineError::param("bodies", ProblemKind::NotNames))?;
|
||||
let shape = design.body(name).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
|
||||
if out.iter().any(|b| b.name == shape.name) {
|
||||
return Err(EngineError::Params(format!("body `{name}` is listed twice")));
|
||||
@@ -205,30 +217,21 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
quadratic,
|
||||
chordal_tolerance: tolerance,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: super::seeds::edge_seeds(s)?,
|
||||
..HexOptions::default()
|
||||
};
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| match e {
|
||||
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
|
||||
e => EngineError::Params(e.to_string()),
|
||||
})?;
|
||||
let m = &cm.mesh;
|
||||
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(label(i)?, *xyz)?;
|
||||
}
|
||||
let mut body_tags = Vec::with_capacity(inputs.len());
|
||||
for b in &inputs {
|
||||
body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
|
||||
}
|
||||
for e in 0..m.element_count() {
|
||||
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| i + 1).collect();
|
||||
model.set_element(label(e)?, m.kind, nodes)?;
|
||||
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?;
|
||||
s.model = model;
|
||||
// Replace the cells' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, inputs.iter().map(|b| b.name.as_str()));
|
||||
add_cells(model, &inputs, &cm)?;
|
||||
let blocks: Vec<Value> =
|
||||
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
|
||||
Ok(json!({
|
||||
let reply = json!({
|
||||
"cells": inputs.len(),
|
||||
"interfaces": cm.interfaces,
|
||||
"description": m.report.description,
|
||||
@@ -241,12 +244,8 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"surface_volume": m.report.surface_volume,
|
||||
"size": h,
|
||||
"blocks": blocks,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A 1-based model label for 0-based index `i`.
|
||||
fn label(i: usize) -> Result<u32> {
|
||||
u32::try_from(i).ok().and_then(|x| x.checked_add(1)).ok_or_else(|| EngineError::Params("too many nodes or elements".into()))
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// The `order` parameter: false for hex8, true for hex20.
|
||||
@@ -256,7 +255,7 @@ fn order(p: &Value) -> Result<bool> {
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(1) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
_ => Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
|
||||
_ => Err(EngineError::invalid("order", "must be 1 (hex8) or 2 (hex20)")),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -288,41 +287,16 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
let bodies = inputs(s, p)?;
|
||||
let mut meshes: Vec<(Input, HexMesh)> = Vec::with_capacity(bodies.len());
|
||||
for b in bodies {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
|
||||
if h < MIN_RELATIVE_SIZE * b.diagonal {
|
||||
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
|
||||
}
|
||||
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) };
|
||||
let options =
|
||||
HexOptions { method, quadratic, chordal_tolerance: chord, max_elements: fieldcraft_model::MAX_ELEMENTS / 8, ..HexOptions::default() };
|
||||
let mesh = hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let mesh = hex_body(p, &b, method, quadratic, size)?;
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
// A copy: the inputs borrow the session.
|
||||
let mut model = s.model.clone();
|
||||
let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str()));
|
||||
let mut report = Vec::with_capacity(meshes.len());
|
||||
let (mut node_label, mut element_label) = (1u32, 1u32);
|
||||
for (b, m) in &meshes {
|
||||
let first_node = node_label;
|
||||
for xyz in &m.nodes {
|
||||
model.set_node(node_label, *xyz)?;
|
||||
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
let first_element = element_label;
|
||||
for e in 0..m.element_count() {
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
model.set_element(element_label, m.kind, nodes)?;
|
||||
model.tag_element(element_label, body_tag)?;
|
||||
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
|
||||
}
|
||||
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
add_hex(&mut model, b, m)?;
|
||||
report.push(json!({
|
||||
"body": b.name,
|
||||
"method": m.report.method.name(),
|
||||
@@ -336,8 +310,76 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
"surface_volume": m.report.surface_volume,
|
||||
}));
|
||||
}
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
s.model = model;
|
||||
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent);
|
||||
/// the model is not touched. Errors name the body.
|
||||
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>) -> Result<HexMesh> {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
|
||||
if h < MIN_RELATIVE_SIZE * b.diagonal {
|
||||
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
|
||||
}
|
||||
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) };
|
||||
let options = HexOptions {
|
||||
method,
|
||||
quadratic,
|
||||
chordal_tolerance: chord,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: b.seeds.clone(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::of_body(&b.name, e))
|
||||
}
|
||||
|
||||
/// Add a mesh of several cells (`inputs[c]` is cell `c`) after the model's largest labels, its
|
||||
/// nodes shared between the cells, tagging each element with its cell's body and the boundary
|
||||
/// element faces with the cell's B-rep faces.
|
||||
pub(super) fn add_cells(model: &mut Model, inputs: &[Input], cm: &fieldcraft_mesh::hex::CellsMesh) -> Result<()> {
|
||||
let m = &cm.mesh;
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let mut body_tags = Vec::with_capacity(inputs.len());
|
||||
for b in inputs {
|
||||
body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
|
||||
}
|
||||
for e in 0..m.element_count() {
|
||||
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(model, inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, first_element)
|
||||
}
|
||||
|
||||
/// Add one body's hex mesh after the model's largest labels, tagging the elements with the body
|
||||
/// and the boundary element faces with its B-rep faces; returns the first node and element
|
||||
/// labels.
|
||||
pub(super) fn add_hex(model: &mut Model, b: &Input, m: &HexMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
for e in 0..m.element_count() {
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tag)?;
|
||||
}
|
||||
tag_faces(model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
Ok((first_node, first_element))
|
||||
}
|
||||
|
||||
/// Tag every boundary element face with the B-rep face it lies on, of the body
|
||||
@@ -471,12 +513,28 @@ mod tests {
|
||||
assert_eq!(two["cells"], 2);
|
||||
}
|
||||
|
||||
/// The Partitioned Mesh form's "All hex" option is mesh.hex.blocks.
|
||||
#[test]
|
||||
fn mesh_cells_all_blocks_is_mesh_hex_blocks() {
|
||||
let (_dir, mut s) = split_box(false);
|
||||
let blocks = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap();
|
||||
let mut cells = s.execute("mesh.cells", &json!({"default": "blocks", "size": 2.5})).unwrap();
|
||||
let mut blocks = blocks;
|
||||
// The same mesh, in place of the first one.
|
||||
let replaced = cells.as_object_mut().and_then(|o| o.remove("replaced")).unwrap();
|
||||
assert_eq!(replaced.as_array().map(Vec::len), Some(4), "{replaced}");
|
||||
blocks.as_object_mut().and_then(|o| o.remove("replaced"));
|
||||
assert_eq!(cells, blocks);
|
||||
let e = s.execute("mesh.cells", &json!({"default": "blocks", "methods": {"Box": "tet"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("methods"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_that_do_not_conform_and_hostile_params_are_errors() {
|
||||
let (_dir, mut s) = split_box(true);
|
||||
let before = s.model.clone();
|
||||
let e = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("do not conform") && e.contains("`Box`"), "{e}");
|
||||
assert!(e.contains("do not conform") && e.contains("`Box.1`"), "{e}");
|
||||
assert_eq!(s.model, before);
|
||||
for p in [
|
||||
json!({"size": 0}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::schema::ProblemKind;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -13,6 +14,7 @@ use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, listed, optional_number, points, xyz};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
@@ -21,6 +23,7 @@ pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
|
||||
form: Some(super::forms::NODE_ADD),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: node_add,
|
||||
};
|
||||
|
||||
@@ -30,6 +33,7 @@ pub const NODE_REMOVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1} — only a node no element uses"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Nodes],
|
||||
run: node_remove,
|
||||
};
|
||||
|
||||
@@ -39,6 +43,7 @@ pub const ELEMENT_ADD: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
|
||||
form: Some(super::forms::ELEMENT_ADD),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Nodes],
|
||||
run: element_add,
|
||||
};
|
||||
|
||||
@@ -48,6 +53,7 @@ pub const ELEMENT_REMOVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"label": 1}"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: element_remove,
|
||||
};
|
||||
|
||||
@@ -57,6 +63,7 @@ pub const QUALITY: CommandSpec = CommandSpec {
|
||||
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
|
||||
form: Some(super::forms::QUALITY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
@@ -66,6 +73,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
form: Some(super::forms::DELAUNAY_POINTS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
@@ -92,15 +100,17 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
|
||||
form: Some(super::forms::SURFACE_CHECK),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: surface_check,
|
||||
};
|
||||
|
||||
pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.tet.generate",
|
||||
label: "Generate Tet Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: tet_generate,
|
||||
};
|
||||
|
||||
@@ -114,12 +124,15 @@ const TET_MAX_PASSES: u64 = 200;
|
||||
pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| range.contains(n))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number in {}..={}", range.start(), range.end())))?,
|
||||
Some(v) => v.as_u64().filter(|n| range.contains(n)).ok_or_else(|| EngineError::param(key, whole_in(&range)))?,
|
||||
};
|
||||
usize::try_from(v).map_err(|_| EngineError::Params(format!("`{key}` is too large")))
|
||||
usize::try_from(v).map_err(|_| EngineError::param(key, whole_in(&range)))
|
||||
}
|
||||
|
||||
/// A whole number in `range`, as a problem's kind.
|
||||
fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
|
||||
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
|
||||
ProblemKind::NotAWholeNumber { min: int(*range.start()), max: int(*range.end()) }
|
||||
}
|
||||
|
||||
/// The boundary options of `mesh.surface.check`, `mesh.tet.generate` and the hex meshers.
|
||||
@@ -135,18 +148,18 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
|
||||
/// An optional number in `range`.
|
||||
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
let v = optional_number(p, key, default)?;
|
||||
if range.contains(&v) { Ok(v) } else { Err(EngineError::Params(format!("`{key}` must be a number in {}..={}", range.start(), range.end()))) }
|
||||
if range.contains(&v) { Ok(v) } else { Err(EngineError::param(key, ProblemKind::NotInRange { min: *range.start(), max: *range.end() })) }
|
||||
}
|
||||
|
||||
/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all.
|
||||
fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
|
||||
pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
|
||||
let d = QualityOptions::default();
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD)
|
||||
.ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?,
|
||||
.ok_or_else(|| EngineError::param("size", ProblemKind::NotPositive))?,
|
||||
),
|
||||
};
|
||||
let order = match p.get("order") {
|
||||
@@ -154,7 +167,7 @@ fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
|
||||
Some(v) => match v.as_str() {
|
||||
Some("linear") => Order::Linear,
|
||||
Some("quadratic") => Order::Quadratic,
|
||||
_ => return Err(EngineError::Params("`order` must be \"linear\" or \"quadratic\"".into())),
|
||||
_ => return Err(EngineError::param("order", ProblemKind::NotAChoice)),
|
||||
},
|
||||
};
|
||||
let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?;
|
||||
@@ -200,7 +213,7 @@ pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec<Option<Analy
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn quality_json(q: &TetQuality) -> Value {
|
||||
pub(super) fn quality_json(q: &TetQuality) -> Value {
|
||||
json!({
|
||||
"tets": q.tets,
|
||||
"min_dihedral": q.min_dihedral,
|
||||
@@ -213,10 +226,10 @@ fn quality_json(q: &TetQuality) -> Value {
|
||||
}
|
||||
|
||||
/// One body's tetrahedra, ready to add, with what its report needs of the surface and the CDT.
|
||||
struct BodyTets {
|
||||
name: String,
|
||||
face_names: Vec<String>,
|
||||
mesh: QualityMesh,
|
||||
pub(super) struct BodyTets {
|
||||
pub(super) name: String,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) mesh: QualityMesh,
|
||||
steiner: usize,
|
||||
cavities: usize,
|
||||
surface_volume: f64,
|
||||
@@ -224,7 +237,7 @@ struct BodyTets {
|
||||
|
||||
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
|
||||
/// names the body.
|
||||
fn mesh_body(
|
||||
pub(super) fn mesh_body(
|
||||
s: &Session,
|
||||
p: &Value,
|
||||
input: super::hex::Input,
|
||||
@@ -239,9 +252,16 @@ fn mesh_body(
|
||||
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
|
||||
Some(_) => Vec::new(),
|
||||
};
|
||||
fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, "");
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
fieldcraft_model::progress::stage(&format!("{}: Delaunay", input.name), None, "");
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
|
||||
let mesh = if quality {
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
|
||||
quality_mesh(&surface, &tets, analytic, &options)?
|
||||
} else {
|
||||
raw_mesh(&surface, &tets)?
|
||||
};
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
@@ -251,7 +271,7 @@ fn mesh_body(
|
||||
surface_volume: surface.volume(),
|
||||
})
|
||||
};
|
||||
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
mesh().map_err(|e| if several && !e.is_cancelled() { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -266,9 +286,11 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
|
||||
}
|
||||
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let cleared = super::replace::clear(&mut s.model, meshed.iter().map(|b| b.name.as_str()));
|
||||
let mut bodies = Vec::with_capacity(meshed.len());
|
||||
for b in &meshed {
|
||||
let (first_node, first_element) = add_tets(s, &b.name, &b.face_names, &b.mesh)?;
|
||||
let (first_node, first_element) = add_tets(&mut s.model, &b.name, &b.face_names, &b.mesh)?;
|
||||
let r = &b.mesh.report;
|
||||
bodies.push(json!({
|
||||
"body": b.name,
|
||||
@@ -292,12 +314,13 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"straightened": r.straightened,
|
||||
"stopped": r.stopped,
|
||||
},
|
||||
"seeds": r.seeds.iter().map(|(name, count, edges)| json!({"edge": name, "count": count, "mesh_edges": edges})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
|
||||
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
|
||||
let first = bodies.first();
|
||||
Ok(json!({
|
||||
let reply = json!({
|
||||
"kind": meshed.first().map(|b| b.mesh.kind.name()),
|
||||
"nodes": count(|b| b.mesh.points.len()),
|
||||
"elements": count(|b| b.mesh.elements.len()),
|
||||
@@ -308,21 +331,22 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"boundary_volume": sum(|b| b.mesh.report.boundary_volume),
|
||||
"surface_volume": sum(|b| b.surface_volume),
|
||||
"bodies": bodies,
|
||||
}))
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// Add one body's elements after the model's largest labels, tagging them with the body and each
|
||||
/// boundary element face with its geometry face; returns the first node and element labels.
|
||||
fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
|
||||
pub(super) fn add_tets(model: &mut fieldcraft_model::Model, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(model, mesh.points.len(), mesh.elements.len())?;
|
||||
for (i, xyz) in mesh.points.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, *xyz)?;
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body)?)?;
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(body)?)?;
|
||||
for (i, e) in mesh.elements.iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
|
||||
s.model.tag_element(label, body_tag)?;
|
||||
model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
|
||||
model.tag_element(label, body_tag)?;
|
||||
}
|
||||
let mut face_tags: Vec<Option<u32>> = vec![None; face_names.len()];
|
||||
for b in &mesh.boundary {
|
||||
@@ -331,7 +355,7 @@ fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMe
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
|
||||
let t = s.model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
|
||||
let t = model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
|
||||
if let Some(slot) = face_tags.get_mut(face) {
|
||||
*slot = Some(t);
|
||||
}
|
||||
@@ -339,7 +363,7 @@ fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMe
|
||||
}
|
||||
};
|
||||
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
|
||||
s.model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
}
|
||||
Ok((first_node, first_element))
|
||||
}
|
||||
@@ -347,9 +371,10 @@ fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMe
|
||||
pub const PLANAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.planar",
|
||||
label: "2D Mesh",
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Replaces the body's mesh (as mesh.tet.generate), then adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
form: Some(super::forms::PLANAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: planar,
|
||||
};
|
||||
|
||||
@@ -379,14 +404,14 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| *v > 0.0 && v.is_finite())
|
||||
.ok_or_else(|| EngineError::Params("`size` must be a positive number (the element size)".into()))?;
|
||||
.ok_or_else(|| super::wrong(p, "size", ProblemKind::NotPositive))?;
|
||||
let kind = match p.get("kind") {
|
||||
None => ElementKind::Quad4,
|
||||
Some(k) => k
|
||||
.as_str()
|
||||
.and_then(ElementKind::from_name)
|
||||
.filter(|k| k.dimension() == 2)
|
||||
.ok_or_else(|| EngineError::Params("`kind` must be quad4, quad8, tri3 or tri6".into()))?,
|
||||
.ok_or_else(|| EngineError::param("kind", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
let opts = fieldcraft_mesh::quad::QuadOptions {
|
||||
max_elements: optional_count(p, "max_elements", 1_000_000, 1..=PLANAR_MAX_ELEMENTS)?,
|
||||
@@ -408,7 +433,7 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let name = match p.get("name") {
|
||||
None => "Plane".to_string(),
|
||||
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`name` must be a body name".into()))?,
|
||||
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::param("name", ProblemKind::NotAName))?,
|
||||
};
|
||||
GeometryRef::body(name.clone())?;
|
||||
let mut id = 0u32;
|
||||
@@ -428,7 +453,7 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(loops, name, "area".to_string(), names)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`face` must be a face name".into()))?;
|
||||
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::param("face", ProblemKind::NotAName))?;
|
||||
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
|
||||
let face = shape.face_index(face_name).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{face_name}`", shape.name)))?;
|
||||
let b = face_boundary(&mesh.positions, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance)?;
|
||||
@@ -439,6 +464,8 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let m = mesh_region(&loops, kind, size, &opts)?;
|
||||
|
||||
// Replace the body's mesh (replace.rs); other bodies and the definitions stay.
|
||||
let cleared = super::replace::clear(&mut s.model, [body.as_str()]);
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, m.nodes.len(), m.elements.len())?;
|
||||
for (i, xy) in m.nodes.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?;
|
||||
@@ -469,8 +496,8 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.model.tag_node(first_node + node, t)?;
|
||||
}
|
||||
}
|
||||
let edges: Vec<&String> = tags.keys().filter_map(|id| names.get(id)).collect();
|
||||
Ok(json!({
|
||||
let edges: Vec<String> = tags.keys().filter_map(|id| names.get(id)).cloned().collect();
|
||||
let reply = json!({
|
||||
"body": body,
|
||||
"face": face,
|
||||
"kind": kind.name(),
|
||||
@@ -479,7 +506,18 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"edges": edges,
|
||||
}))
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// The body `mesh.planar` fills: `body`, or the polygon's `name` (default `Plane`).
|
||||
pub(super) fn planar_body(_: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
let name = match (p.get("polygon"), p.get("body")) {
|
||||
(Some(_), None) => p.get("name").map_or(Some("Plane"), Value::as_str),
|
||||
(None, Some(b)) => b.as_str(),
|
||||
_ => None,
|
||||
};
|
||||
Ok(vec![name.ok_or_else(|| EngineError::Params("give either `polygon` or `body`".into()))?.to_string()])
|
||||
}
|
||||
|
||||
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -518,7 +556,7 @@ fn number(v: f64) -> Value {
|
||||
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bins = match p.get("bins") {
|
||||
None => 10,
|
||||
Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::Params("`bins` must be a whole number".into()))?,
|
||||
Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::invalid("bins", "must be a whole number"))?,
|
||||
};
|
||||
let selection = match p.get("elements") {
|
||||
None => None,
|
||||
@@ -745,8 +783,9 @@ mod tests {
|
||||
// A tagged tetrahedron, and the same with a face missing.
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet =
|
||||
SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
}
|
||||
@@ -776,7 +815,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tet_generate_meshes_a_body_with_tags_and_appends() {
|
||||
fn tet_generate_meshes_a_body_with_tags_and_replaces_its_mesh() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::open_design;
|
||||
@@ -802,11 +841,12 @@ mod tests {
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements);
|
||||
assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}");
|
||||
// A second run appends after the existing labels.
|
||||
// A second run replaces the body's mesh (#267).
|
||||
let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap();
|
||||
assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1);
|
||||
assert_eq!(s.model.elements().len(), 2 * elements);
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
assert_eq!(again["replaced"][0]["elements"].as_u64().unwrap() as usize, elements, "{again}");
|
||||
assert_eq!(s.model.elements().len(), elements);
|
||||
// The same mesh again, under the same labels: nothing changed, so no undo step.
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
let journal = s.journal.len();
|
||||
let model = s.model.clone();
|
||||
@@ -837,7 +877,7 @@ mod tests {
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("open a design first"));
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("Needs geometry"));
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
|
||||
let bodies = r["bodies"].as_array().unwrap();
|
||||
@@ -856,11 +896,40 @@ mod tests {
|
||||
assert!(e.starts_with("body `Box"), "{e}");
|
||||
}
|
||||
|
||||
/// Persistent names grow with the design's split history: a tray floor split by joined ribs
|
||||
/// names every split, past the old 256-character cap. Such a design meshes, and a set on that
|
||||
/// face resolves by its full name, again after a remesh (#374).
|
||||
#[test]
|
||||
fn long_persistent_face_names_mesh_and_resolve() {
|
||||
let mut commands = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0], "body_name": "Case"}}),
|
||||
json!({"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "direction": "inside", "body": "Case"}}),
|
||||
];
|
||||
// Ribs across the floor split it into strips, each named by every split before it.
|
||||
for x in (3..=36).step_by(3) {
|
||||
commands.push(json!({"command": "solid.box", "params": {"length": 1, "width": 26, "height": 5, "corner": [x, 2, 2], "operation": "join", "targets": ["Case"]}}));
|
||||
}
|
||||
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let long = bodies[0]["faces"].as_array().unwrap().iter().filter_map(|f| f["name"].as_str()).max_by_key(|n| n.len()).unwrap().to_string();
|
||||
assert!(long.len() > 256, "the fixture needs a face name past the old cap: {long}");
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Case"})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "floor", "geometry": [{"body": "Case", "face": long}]})).unwrap();
|
||||
let first = s.model.resolve_set("floor").unwrap();
|
||||
assert!(!first.faces.is_empty());
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Case", "tolerance": 0.5})).unwrap();
|
||||
let again = s.model.resolve_set("floor").unwrap();
|
||||
assert!(!again.faces.is_empty(), "the set follows the remesh by the full name");
|
||||
}
|
||||
|
||||
/// The quality report's one-line summary counts the inverted elements.
|
||||
#[test]
|
||||
fn quality_summary_counts_invalid_elements() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.execute("mesh.quality", &json!({})).unwrap()["summary"], "no elements to measure");
|
||||
assert!(matches!(s.execute("mesh.quality", &json!({})), Err(crate::EngineError::Unavailable(_))), "no elements to measure");
|
||||
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
|
||||
}
|
||||
@@ -946,8 +1015,9 @@ mod tests {
|
||||
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet =
|
||||
SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let mut s = Session::default();
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! `mesh.mirror`: the mesh of some bodies reflected across a plane, joined to them on it.
|
||||
//!
|
||||
//! The geometry tags of the image name what it is the image of: the body of the open design that
|
||||
//! occupies the reflected place (the other half of a part cut on its symmetry plane, found by its
|
||||
//! volume and centroid, or named in `as`), and on it the face each reflected boundary face lies
|
||||
//! on (found by position). Without such a body the image is `<body>/mirror` with the source's
|
||||
//! face names.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_geometry::Shape;
|
||||
use fieldcraft_mesh::mirror::{mirror, reflected_edges, reflected_faces};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const MIRROR: CommandSpec = CommandSpec {
|
||||
id: "mesh.mirror",
|
||||
label: "Mirror Mesh",
|
||||
params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "<body>/mirror")} — replaces each image body's mesh (as mesh.tet.generate) with the reflection of the bodies' elements (tet4, tet10, wedge6, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#,
|
||||
form: Some(super::forms::MIRROR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: mirror_mesh,
|
||||
};
|
||||
|
||||
/// Relative difference of volume and centroid (to the body's size) under which a body is the
|
||||
/// image of another.
|
||||
const SAME_PLACE: f64 = 1e-4;
|
||||
|
||||
/// Distance (relative to the mesh's bounding-box diagonal) within which nodes are on the plane.
|
||||
const ON_PLANE: f64 = 1e-7;
|
||||
|
||||
fn not_a_plane() -> EngineError {
|
||||
EngineError::invalid("plane", r#"must be a datum plane's name or {"point": [x, y, z], "normal": [x, y, z]}"#)
|
||||
}
|
||||
|
||||
/// The plane: a datum plane by name, or a point and a normal.
|
||||
fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> {
|
||||
match p.get("plane") {
|
||||
Some(Value::String(name)) => super::datum::datum_plane_of(&s.model, name),
|
||||
Some(v @ Value::Object(_)) => match (super::xyz(v, "point"), super::xyz(v, "normal")) {
|
||||
(Ok(point), Ok(normal)) => Ok((point, normal)),
|
||||
_ => Err(not_a_plane()),
|
||||
},
|
||||
None | Some(Value::Null) => Err(EngineError::param("plane", ProblemKind::Required)),
|
||||
_ => Err(not_a_plane()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reflect(x: [f64; 3], point: [f64; 3], n: [f64; 3]) -> [f64; 3] {
|
||||
let d: f64 = (0..3).map(|i| (x[i] - point[i]) * n[i]).sum();
|
||||
[x[0] - 2.0 * d * n[0], x[1] - 2.0 * d * n[1], x[2] - 2.0 * d * n[2]]
|
||||
}
|
||||
|
||||
/// The design body at the reflected place of `source`: the same volume and the reflected
|
||||
/// centroid, within [`SAME_PLACE`] (the kernel measures curved bodies on a tessellation, so the
|
||||
/// two halves of a symmetric part measure alike only to its accuracy).
|
||||
fn image_body<'a>(bodies: &'a [Shape], source: &Shape, point: [f64; 3], n: [f64; 3]) -> Result<Option<&'a Shape>> {
|
||||
let m = source.measure()?;
|
||||
let size = source.size()?.max(1e-9);
|
||||
let want = reflect(m.centroid, point, n);
|
||||
for b in bodies.iter().filter(|b| b.name != source.name) {
|
||||
let o = b.measure()?;
|
||||
let d = (0..3).map(|k| (o.centroid[k] - want[k]).powi(2)).sum::<f64>().sqrt();
|
||||
if (o.volume - m.volume).abs() <= SAME_PLACE * m.volume.abs().max(1e-12) && d <= SAME_PLACE * size {
|
||||
return Ok(Some(b));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// What `mesh.mirror` reflects, across which plane, into which bodies.
|
||||
struct Plan<'d> {
|
||||
point: [f64; 3],
|
||||
/// The plane's unit normal.
|
||||
n: [f64; 3],
|
||||
/// The elements reflected.
|
||||
region: BTreeSet<u32>,
|
||||
/// Each mirrored body's image: its name and, when the design has it, its shape.
|
||||
image: BTreeMap<String, (String, Option<&'d Shape>)>,
|
||||
}
|
||||
|
||||
fn plan<'d>(s: &Session, design: Option<&'d fieldcraft_geometry::Design>, p: &Value) -> Result<Plan<'d>> {
|
||||
let (point, normal) = plane(s, p)?;
|
||||
let len = normal.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
if !(len > 1e-12 && len.is_finite() && point.iter().all(|x| x.is_finite())) {
|
||||
return Err(EngineError::Params("the plane needs a finite point and a non-zero normal".into()));
|
||||
}
|
||||
let n = normal.map(|x| x / len);
|
||||
let c = s.model.classification();
|
||||
// The bodies the mesh is tagged with, by tag.
|
||||
let tagged: BTreeMap<u32, String> = c
|
||||
.entities()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.entity == Entity::Body)
|
||||
.filter_map(|(t, r)| Some((u32::try_from(t).ok()?, r.body.clone())))
|
||||
.collect();
|
||||
let bodies: Vec<String> = match p.get("bodies") {
|
||||
None => {
|
||||
let mut v: Vec<String> = tagged.values().cloned().collect();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
};
|
||||
if bodies.is_empty() {
|
||||
return Err(EngineError::Params("no body to mirror: the mesh is not tagged with a body".into()));
|
||||
}
|
||||
for b in &bodies {
|
||||
if !tagged.values().any(|t| t == b) {
|
||||
return Err(EngineError::Params(format!("the mesh has no elements of body `{}`", b.chars().take(64).collect::<String>())));
|
||||
}
|
||||
}
|
||||
let named: BTreeMap<String, String> = super::opt(p, "as")?.unwrap_or_default();
|
||||
let region: BTreeSet<u32> =
|
||||
c.elements().iter().filter(|(_, t)| t.region.and_then(|r| tagged.get(&r)).is_some_and(|b| bodies.contains(b))).map(|(e, _)| *e).collect();
|
||||
|
||||
// The image of each body: named, found in the design, or its own `/mirror`.
|
||||
let mut image: BTreeMap<String, (String, Option<&Shape>)> = BTreeMap::new();
|
||||
for b in &bodies {
|
||||
let source = design.and_then(|d| d.body(b));
|
||||
let target = match named.get(b) {
|
||||
Some(t) => (t.clone(), design.and_then(|d| d.body(t))),
|
||||
None => match (design, source) {
|
||||
(Some(d), Some(src)) => match image_body(&d.bodies, src, point, n)? {
|
||||
Some(t) => (t.name.clone(), Some(t)),
|
||||
None => (format!("{b}/mirror"), None),
|
||||
},
|
||||
_ => (format!("{b}/mirror"), None),
|
||||
},
|
||||
};
|
||||
if bodies.contains(&target.0) {
|
||||
return Err(EngineError::Params(format!("the image of `{b}` would be `{}`, which is mirrored too", target.0)));
|
||||
}
|
||||
image.insert(b.clone(), target);
|
||||
}
|
||||
Ok(Plan { point, n, region, image })
|
||||
}
|
||||
|
||||
/// The bodies `mesh.mirror` fills: the images.
|
||||
pub(super) fn image_names(s: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
Ok(plan(s, s.design.as_deref(), p)?.image.into_values().map(|(name, _)| name).collect())
|
||||
}
|
||||
|
||||
fn mirror_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let design = s.design.clone();
|
||||
let Plan { point, n, region, image } = plan(s, design.as_deref(), p)?;
|
||||
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for node in s.model.nodes().values() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(node.xyz[k]);
|
||||
hi[k] = hi[k].max(node.xyz[k]);
|
||||
}
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt().max(1e-9);
|
||||
// An image body's earlier mesh is replaced (replace.rs).
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, image.values().map(|(name, _)| name.as_str()));
|
||||
let before = model.clone();
|
||||
let r = mirror(model, ®ion, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
tag_image(model, &before, &r, &image, point, n, diagonal)?;
|
||||
let added_nodes = model.nodes().len() - before.nodes().len();
|
||||
let images: Vec<Value> = image.iter().map(|(b, (t, shape))| json!({"body": b, "image": t, "in_design": shape.is_some()})).collect();
|
||||
let reply = json!({
|
||||
"elements": r.elements.len(),
|
||||
"nodes": added_nodes,
|
||||
"on_plane": r.on_plane,
|
||||
"images": images,
|
||||
"total": {"nodes": s.model.nodes().len(), "elements": s.model.elements().len()},
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// Tag the image: each copy with its image body, its faces with the faces of that body they lie
|
||||
/// on (or the source's names), its edges and the images of tagged nodes with the source's names.
|
||||
fn tag_image(
|
||||
model: &mut Model,
|
||||
before: &Model,
|
||||
r: &fieldcraft_mesh::mirror::Mirrored,
|
||||
image: &BTreeMap<String, (String, Option<&Shape>)>,
|
||||
point: [f64; 3],
|
||||
n: [f64; 3],
|
||||
diagonal: f64,
|
||||
) -> Result<()> {
|
||||
let c = before.classification();
|
||||
let entity = |t: u32| c.entity(t).cloned();
|
||||
// A source face's image: by the image body's face under its reflected centroid, one lookup per
|
||||
// face name (the first element face of it stands for all).
|
||||
let mut face_points: BTreeMap<(String, String), [f64; 3]> = BTreeMap::new();
|
||||
for (&e, tags) in c.elements() {
|
||||
if !r.elements.contains_key(&e) {
|
||||
continue;
|
||||
}
|
||||
for (&f, &t) in &tags.faces {
|
||||
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(t) else { continue };
|
||||
if face_points.contains_key(&(body.clone(), name.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Some(nodes) = before.face_nodes(ElementFace { element: e, face: f }) else { continue };
|
||||
let pts: Vec<[f64; 3]> = nodes.iter().filter_map(|n| before.node(*n).map(|x| x.xyz)).collect();
|
||||
let k = pts.len().max(1) as f64;
|
||||
let centre = [0, 1, 2].map(|i| pts.iter().map(|p| p[i]).sum::<f64>() / k);
|
||||
face_points.insert((body, name), reflect(centre, point, n));
|
||||
}
|
||||
}
|
||||
let mut face_names: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
|
||||
let mut by_body: BTreeMap<String, Vec<(String, [f64; 3])>> = BTreeMap::new();
|
||||
for ((body, name), p) in &face_points {
|
||||
by_body.entry(body.clone()).or_default().push((name.clone(), *p));
|
||||
}
|
||||
for (body, faces) in by_body {
|
||||
let Some((target, shape)) = image.get(&body) else { continue };
|
||||
let found = match shape {
|
||||
Some(shape) => {
|
||||
let pts: Vec<[f64; 3]> = faces.iter().map(|(_, p)| *p).collect();
|
||||
shape.nearest_faces(&pts, (1e-3 * diagonal).max(1e-4))?
|
||||
}
|
||||
None => vec![None; faces.len()],
|
||||
};
|
||||
for ((name, _), f) in faces.iter().zip(found) {
|
||||
let mapped = f.and_then(|f| shape.and_then(|s| s.faces().get(f as usize))).map_or_else(|| name.clone(), |x| x.name.clone());
|
||||
face_names.insert((body.clone(), name.clone()), (target.clone(), mapped));
|
||||
}
|
||||
}
|
||||
let mut tags: BTreeMap<GeometryRef, u32> = BTreeMap::new();
|
||||
let mut tag = |model: &mut Model, r: GeometryRef| -> Result<u32> {
|
||||
if let Some(&t) = tags.get(&r) {
|
||||
return Ok(t);
|
||||
}
|
||||
let t = model.geometry_tag(&r)?;
|
||||
tags.insert(r, t);
|
||||
Ok(t)
|
||||
};
|
||||
for (&e, ©) in &r.elements {
|
||||
let Some(el) = before.element(e) else { continue };
|
||||
let Some(t) = c.elements().get(&e) else { continue };
|
||||
let faces = reflected_faces(el.kind).unwrap_or_default();
|
||||
let edges = reflected_edges(el.kind).unwrap_or_default();
|
||||
if let Some(GeometryRef { body, .. }) = t.region.and_then(entity)
|
||||
&& let Some((target, _)) = image.get(&body)
|
||||
{
|
||||
let tg = tag(model, GeometryRef::body(target.as_str())?)?;
|
||||
model.tag_element(copy, tg)?;
|
||||
}
|
||||
for (&f, &ft) in &t.faces {
|
||||
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(ft) else { continue };
|
||||
let Some((tb, tn)) = face_names.get(&(body, name)) else { continue };
|
||||
let g = faces.get(usize::from(f).saturating_sub(1)).copied().unwrap_or(f);
|
||||
let tg = tag(model, GeometryRef::face(tb.as_str(), tn.as_str())?)?;
|
||||
model.tag_element_face(copy, g, tg)?;
|
||||
}
|
||||
for (&k, &et) in &t.edges {
|
||||
let Some(GeometryRef { body, entity: ent }) = entity(et) else { continue };
|
||||
let Some((target, _)) = image.get(&body) else { continue };
|
||||
let g = edges.get(usize::from(k).saturating_sub(1)).copied().unwrap_or(k);
|
||||
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
|
||||
model.tag_element_edge(copy, g, tg)?;
|
||||
}
|
||||
}
|
||||
for (&node, ©) in &r.nodes {
|
||||
if copy == node {
|
||||
continue;
|
||||
}
|
||||
let Some(GeometryRef { body, entity: ent }) = c.nodes().get(&node).and_then(|&t| entity(t)) else { continue };
|
||||
let Some((target, _)) = image.get(&body) else { continue };
|
||||
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
|
||||
model.tag_node(copy, tg)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A session with a 40 × 10 × 10 bar (along x from the origin) open, partitioned at
|
||||
/// x = 20 when `half`.
|
||||
fn bar(half: bool) -> (crate::tests::TempDir, Session) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let b = s.execute("geometry.bodies", &json!({})).unwrap()[0]["bbox"].clone();
|
||||
let lo: Vec<f64> = b[0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let hi: Vec<f64> = b[1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!(lo, [0.0; 3]);
|
||||
assert_eq!(hi, [40.0, 10.0, 10.0]);
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 20}})).unwrap();
|
||||
if half {
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
}
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// The face of `body` nearest to `point`.
|
||||
fn face(s: &Session, body: &str, point: [f64; 3]) -> String {
|
||||
let shape = s.design.as_ref().unwrap().body(body).unwrap();
|
||||
let f = shape.nearest_faces(&[point], 0.01).unwrap()[0].unwrap();
|
||||
shape.faces()[f as usize].name.clone()
|
||||
}
|
||||
|
||||
/// Clamped at x = 0, 1 kN down on the end at x = 40 (sets by face name on the bodies given),
|
||||
/// solved; the largest downward deflection and the mesh's volume and counts.
|
||||
fn bend(s: &mut Session, root: (&str, String), tip: (&str, String)) -> (f64, f64, usize, usize) {
|
||||
let all: Vec<Value> = s.design.as_ref().unwrap().bodies.iter().map(|b| json!({"body": b.name})).collect();
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "geometry": all}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": root.0, "face": root.1}]}},
|
||||
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": tip.0, "face": tip.1}]}},
|
||||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
|
||||
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let tip_nodes = s.model.resolve_set("tip").unwrap().nodes;
|
||||
assert!(!tip_nodes.is_empty());
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip_nodes})).unwrap();
|
||||
let w = probe["nodes"].as_array().unwrap().iter().map(|n| -n["displacement"][2].as_f64().unwrap()).fold(f64::MIN, f64::max);
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
|
||||
(w, q["volume"].as_f64().unwrap(), s.model.nodes().len(), s.model.elements().len())
|
||||
}
|
||||
|
||||
/// Half the bar meshed and mirrored is the whole bar meshed: the same counts and volume, and
|
||||
/// the same bending deflection to round-off, hex8 and hex20; the image's faces carry the
|
||||
/// whole bar's face names, so the load on the far end (a face of the image only) is found.
|
||||
#[test]
|
||||
fn a_mirrored_half_is_the_whole_mesh() {
|
||||
for order in [1, 2] {
|
||||
let (_d1, mut whole) = bar(false);
|
||||
whole.execute("mesh.hex.mapped", &json!({"body": "Bar", "size": 2.5, "order": order})).unwrap();
|
||||
let (root, tip) = (face(&whole, "Bar", [0.0, 5.0, 5.0]), face(&whole, "Bar", [40.0, 5.0, 5.0]));
|
||||
let full = bend(&mut whole, ("Bar", root), ("Bar", tip));
|
||||
|
||||
let (_d2, mut half) = bar(true);
|
||||
half.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 2.5, "order": order})).unwrap();
|
||||
let r = half.execute("mesh.mirror", &json!({"plane": "Mid"})).unwrap();
|
||||
assert_eq!(r["images"][0], json!({"body": "Bar/a", "image": "Bar/b", "in_design": true}), "{r}");
|
||||
// 5 × 5 corner nodes on the plane (and 40 mid-edge nodes on it for hex20).
|
||||
assert_eq!(r["on_plane"], json!(if order == 1 { 25 } else { 65 }), "{r}");
|
||||
let (root, tip) = (face(&half, "Bar/a", [0.0, 5.0, 5.0]), face(&half, "Bar/b", [40.0, 5.0, 5.0]));
|
||||
let mirrored = bend(&mut half, ("Bar/a", root), ("Bar/b", tip));
|
||||
assert_eq!((mirrored.2, mirrored.3), (full.2, full.3), "order {order}: node and element counts");
|
||||
assert!((mirrored.1 - full.1).abs() < 1e-9 * full.1, "order {order}: volume {} vs {}", mirrored.1, full.1);
|
||||
assert!((mirrored.0 - full.0).abs() < 1e-9 * full.0, "order {order}: deflection {} vs {}", mirrored.0, full.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tets of either order mirror into valid elements sharing the plane's nodes; the volume
|
||||
/// doubles.
|
||||
#[test]
|
||||
fn mirrored_tets_are_valid_and_conforming() {
|
||||
for order in ["linear", "quadratic"] {
|
||||
let (_d, mut s) = bar(true);
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Bar/a", "size": 4, "order": order})).unwrap();
|
||||
let v = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
|
||||
let r = s.execute("mesh.mirror", &json!({"plane": {"point": [20, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Bar/a"]})).unwrap();
|
||||
assert!(r["on_plane"].as_u64().unwrap() > 0, "{r}");
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
|
||||
assert!((q["volume"].as_f64().unwrap() - 2.0 * v).abs() < 1e-9 * v, "{q}");
|
||||
// The plane's faces are interior now: the skin of both bodies is the bar's.
|
||||
s.execute("model.set.create", &json!({"name": "a", "geometry": [{"body": "Bar/a"}]})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "b", "geometry": [{"body": "Bar/b"}]})).unwrap();
|
||||
let (a, b) = (s.model.resolve_set("a").unwrap(), s.model.resolve_set("b").unwrap());
|
||||
assert_eq!(a.elements.len(), b.elements.len());
|
||||
assert_eq!(a.faces.len(), b.faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_change_nothing() {
|
||||
let (_d, mut s) = bar(true);
|
||||
s.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 5})).unwrap();
|
||||
let model = s.model.clone();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"plane": "Nope"}),
|
||||
json!({"plane": {"point": [0, 0, 0], "normal": [0, 0, 0]}}),
|
||||
json!({"plane": {"point": [f64::MAX, 0, 0], "normal": [1, 0, 0]}}),
|
||||
json!({"plane": {"point": [0, 0], "normal": [1, 0, 0]}}),
|
||||
json!({"plane": "Mid", "bodies": ["Nope"]}),
|
||||
json!({"plane": "Mid", "bodies": []}),
|
||||
json!({"plane": "Mid", "as": {"Bar/a": "Bar/a"}}),
|
||||
json!({"plane": "Mid", "as": "x"}),
|
||||
] {
|
||||
assert!(s.execute("mesh.mirror", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
mod block;
|
||||
mod cells;
|
||||
mod contact;
|
||||
pub(crate) mod datum;
|
||||
pub(crate) mod deck;
|
||||
pub(crate) mod define;
|
||||
pub mod diagnostics;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod forms;
|
||||
@@ -11,13 +14,26 @@ mod geometry;
|
||||
mod hex;
|
||||
pub(crate) mod mesh;
|
||||
|
||||
pub use datum::edit_params as datum_edit_params;
|
||||
pub use datum::preview as datum_preview;
|
||||
pub use mesh::analytic_surfaces;
|
||||
pub use nodes::merge_preview as node_merge_preview;
|
||||
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
|
||||
mod mirror;
|
||||
mod model;
|
||||
pub(crate) mod nodes;
|
||||
pub(crate) mod octree;
|
||||
pub(crate) mod relax;
|
||||
pub mod replace;
|
||||
mod results;
|
||||
pub(crate) mod seeds;
|
||||
pub(crate) mod solve;
|
||||
mod symmetry;
|
||||
mod tags;
|
||||
pub mod update;
|
||||
pub(crate) mod view;
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use fieldcraft_model::MAX_LABEL;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -30,12 +46,29 @@ pub static ALL: &[CommandSpec] = &[
|
||||
model::INFO,
|
||||
geometry::OPEN,
|
||||
geometry::BODIES,
|
||||
geometry::CENTRE,
|
||||
geometry::TESSELLATE,
|
||||
geometry::IMPORT_MESH,
|
||||
datum::DATUM_PLANE,
|
||||
datum::DATUM_AXIS,
|
||||
datum::DATUM_POINT,
|
||||
datum::DATUM_EDIT,
|
||||
datum::DATUM_AXIS_EDIT,
|
||||
datum::DATUM_POINT_EDIT,
|
||||
datum::DATUM_DELETE,
|
||||
view::HIDE,
|
||||
view::SHOW,
|
||||
view::ISOLATE,
|
||||
view::SHOW_ALL,
|
||||
datum::PARTITION,
|
||||
datum::DATUM_SUPPRESS,
|
||||
datum::PARTITION_SUPPRESS,
|
||||
datum::PARTITION_DELETE,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
mesh::ELEMENT_REMOVE,
|
||||
replace::CLEAR,
|
||||
mesh::QUALITY,
|
||||
mesh::DELAUNAY_POINTS,
|
||||
mesh::SURFACE_CHECK,
|
||||
@@ -47,6 +80,16 @@ pub static ALL: &[CommandSpec] = &[
|
||||
hex::HEX_SWEEP,
|
||||
hex::HEX_MAPPED,
|
||||
hex::HEX_BLOCKS,
|
||||
cells::CELLS,
|
||||
seeds::SEED_EDGE,
|
||||
seeds::SEED_CLEAR,
|
||||
seeds::SEED_LIST,
|
||||
mirror::MIRROR,
|
||||
nodes::MERGE,
|
||||
nodes::DUPLICATES,
|
||||
nodes::RENUMBER,
|
||||
relax::RELAX,
|
||||
relax::APPLY,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
model::UNITS,
|
||||
@@ -77,6 +120,8 @@ pub static ALL: &[CommandSpec] = &[
|
||||
contact::TIE,
|
||||
contact::TIE_EDIT,
|
||||
contact::TIE_DELETE,
|
||||
contact::RIGID_WALL,
|
||||
contact::RIGID_WALL_DELETE,
|
||||
define::MASS_CREATE,
|
||||
define::MASS_EDIT,
|
||||
define::MASS_DELETE,
|
||||
@@ -86,6 +131,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::BOUNDARY_CONDITION_CREATE,
|
||||
define::BOUNDARY_CONDITION_EDIT,
|
||||
define::BOUNDARY_CONDITION_DELETE,
|
||||
symmetry::SYMMETRY,
|
||||
define::LOAD_CREATE,
|
||||
define::LOAD_EDIT,
|
||||
define::LOAD_DELETE,
|
||||
@@ -97,7 +143,9 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::STEP_EDIT,
|
||||
define::STEP_DELETE,
|
||||
define::STEP_ACTIVATE,
|
||||
define::RENAME,
|
||||
define::ACTIVITY,
|
||||
file::NEW,
|
||||
file::OPEN,
|
||||
file::SAVE,
|
||||
file::SAVE_AS,
|
||||
@@ -124,15 +172,28 @@ pub static ALL: &[CommandSpec] = &[
|
||||
results::ANIMATE,
|
||||
results::FRAME,
|
||||
results::REACTIONS,
|
||||
results::EXPORT_CSV,
|
||||
results::CLEAR,
|
||||
diagnostics::CRASH_REPORTS,
|
||||
diagnostics::CRASH_REPORT,
|
||||
diagnostics::CRASH_REPORTS_DELETE,
|
||||
diagnostics::BUG_REPORT,
|
||||
diagnostics::CRASH_TEST,
|
||||
update::CHECK,
|
||||
];
|
||||
|
||||
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
|
||||
/// else `kind`.
|
||||
pub(crate) fn wrong(p: &Value, key: &str, kind: ProblemKind) -> EngineError {
|
||||
EngineError::param(key, if p.get(key).is_none_or(Value::is_null) { ProblemKind::Required } else { kind })
|
||||
}
|
||||
|
||||
/// 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")))
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAWholeNumber { min: 1, max: i64::from(u32::MAX) }))
|
||||
}
|
||||
|
||||
/// The first node and element labels after the model's largest, for `nodes` new nodes and
|
||||
@@ -150,7 +211,7 @@ pub(crate) fn fresh_labels(model: &fieldcraft_model::Model, nodes: usize, elemen
|
||||
|
||||
/// A required `[x, y, z]` parameter.
|
||||
pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
|
||||
p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::Params(format!("`{key}` must be [x, y, z]")))
|
||||
p.get(key).and_then(xyz_value).ok_or_else(|| wrong(p, key, ProblemKind::NotAVector { len: 3 }))
|
||||
}
|
||||
|
||||
/// A JSON `[x, y, z]`.
|
||||
@@ -163,9 +224,12 @@ fn xyz_value(v: &Value) -> Option<[f64; 3]> {
|
||||
|
||||
/// A required list of `[x, y, z]` points, at most `max_len` long.
|
||||
pub(crate) fn points(p: &Value, key: &str, max_len: usize) -> Result<Vec<[f64; 3]>> {
|
||||
let bad = |what: String| EngineError::Params(format!("`{key}` must be a list of at most {max_len} [x, y, z]{what}"));
|
||||
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len).ok_or_else(|| bad(String::new()))?;
|
||||
raw.iter().enumerate().map(|(i, v)| xyz_value(v).ok_or_else(|| bad(format!(" (entry {i} is not)")))).collect()
|
||||
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len);
|
||||
let raw = raw.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "[x, y, z]" }))?;
|
||||
raw.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| xyz_value(v).ok_or_else(|| EngineError::invalid(key, format!("must be a list of [x, y, z] (entry {i} is not)"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A required list of labels (each in 1..=MAX_LABEL), at most `max_len` long.
|
||||
@@ -174,11 +238,11 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| a.len() <= max_len)
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a list of at most {max_len} labels")))?;
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "labels" }))?;
|
||||
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!("`{key}` must be labels in 1..={MAX_LABEL}")))
|
||||
.ok_or_else(|| EngineError::invalid(key, format!("must be labels in 1..={MAX_LABEL}")))
|
||||
}
|
||||
|
||||
/// A required path parameter, as given.
|
||||
@@ -186,7 +250,7 @@ fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= fieldcraft_io::MAX_PATH_LEN && !s.contains('\0'))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAPath))
|
||||
}
|
||||
|
||||
/// A required path parameter naming a file (or folder) the command reads or writes: the path
|
||||
@@ -201,23 +265,23 @@ pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> R
|
||||
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v.as_f64().ok_or_else(|| EngineError::Params(format!("`{key}` must be a number"))),
|
||||
Some(v) => v.as_f64().ok_or_else(|| EngineError::param(key, ProblemKind::NotANumber)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `name` parameter.
|
||||
pub(crate) fn name(p: &Value) -> Result<&str> {
|
||||
p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`name` must be a name".into()))
|
||||
p.get("name").and_then(Value::as_str).ok_or_else(|| wrong(p, "name", ProblemKind::NotAName))
|
||||
}
|
||||
|
||||
/// An optional parameter read as `T`'s JSON form; `None` when it is absent.
|
||||
pub(crate) fn opt<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::Params(format!("`{key}`: {e}")))).transpose()
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::invalid(key, format!("is not valid: {e}")))).transpose()
|
||||
}
|
||||
|
||||
/// A required parameter read as `T`'s JSON form.
|
||||
pub(crate) fn req<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<T> {
|
||||
opt(p, key)?.ok_or_else(|| EngineError::Params(format!("`{key}` is required")))
|
||||
opt(p, key)?.ok_or_else(|| EngineError::param(key, ProblemKind::Required))
|
||||
}
|
||||
|
||||
/// Entries listed in a report: the first ones, and how many there are.
|
||||
|
||||
@@ -2,10 +2,11 @@ use fieldcraft_model::{Severity, UnitSystem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{listed, name};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec =
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: info };
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, requires: &[], run: info };
|
||||
|
||||
pub const UNITS: CommandSpec = CommandSpec {
|
||||
id: "model.units",
|
||||
@@ -13,11 +14,12 @@ pub const UNITS: CommandSpec = CommandSpec {
|
||||
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
|
||||
form: Some(super::forms::UNITS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: units,
|
||||
};
|
||||
|
||||
pub const CHECK: CommandSpec =
|
||||
CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: check };
|
||||
CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: Some(&[]), kind: CommandKind::Model, requires: &[], run: check };
|
||||
|
||||
pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
id: "model.set.resolve",
|
||||
@@ -25,6 +27,7 @@ pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
|
||||
form: Some(super::forms::SET_RESOLVE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
@@ -52,13 +55,30 @@ fn units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let diagnostics = s.model.check();
|
||||
let mut diagnostics = s.model.check();
|
||||
if let Some(design) = s.design.as_deref() {
|
||||
for (body, n) in super::replace::stale(&s.model, design) {
|
||||
diagnostics.push(fieldcraft_model::Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
subject: format!("body `{body}`"),
|
||||
message: format!(
|
||||
"{n} elements mesh a body the open design no longer has (its partitions changed since it was meshed); mesh.clear removes them"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(why) = s.mesh_outdated() {
|
||||
diagnostics.push(fieldcraft_model::Diagnostic { severity: Severity::Warning, subject: "mesh".into(), message: why });
|
||||
}
|
||||
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
|
||||
let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string);
|
||||
let progress = crate::progress::to_json(&s.progress_given(first_error));
|
||||
Ok(json!({
|
||||
"ready": errors == 0,
|
||||
"errors": errors,
|
||||
"warnings": diagnostics.len() - errors,
|
||||
"diagnostics": diagnostics,
|
||||
"progress": progress,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Node tools (#234): `mesh.node.merge` equivalences coincident nodes, `mesh.check.duplicates`
|
||||
//! finds what is not merged or does not join (coincident nodes, repeated elements, free faces and
|
||||
//! edges, cracks), and `mesh.renumber` gives nodes and elements new labels. Merging and
|
||||
//! renumbering move every reference with the labels (connectivity, sets, geometry tags, a step's
|
||||
//! displacement limit), so boundary conditions and loads, which act on sets, stay on the same
|
||||
//! entities; deck writers write the labels as they are. Both are one undo step; results solved
|
||||
//! before go stale, as after any mesh edit.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::nodes::{Keep, bandwidth, duplicates, merge_plan, reverse_cuthill_mckee};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{MAX_LABEL, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const MERGE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.merge",
|
||||
label: "Merge Nodes",
|
||||
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal), "set"?: "Name" (only the set's nodes and those of its elements and faces), "keep"?: "lowest" (default) | "highest"} — equivalences coincident nodes: every node within the distance of another becomes that one (the lowest or highest label of each group survives, keeping its position) in every element, set and geometry tag, and is removed. A merge that would put two nodes of one element together (collapsing it) is left out and listed under `refused`; the rest are made. Reports `merged`, the `pairs` [node, survivor] (the first 1000) and `refused`"#,
|
||||
form: Some(super::forms::NODE_MERGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: merge,
|
||||
};
|
||||
|
||||
pub const DUPLICATES: CommandSpec = CommandSpec {
|
||||
id: "mesh.check.duplicates",
|
||||
label: "Check Duplicates",
|
||||
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal)} — checks the mesh for what was not merged or does not join, changing nothing: `coincident` nodes within the distance of another ([node, other, distance]), `duplicate_elements` (groups on the same nodes), `free_faces` (faces of solid elements no other element shares: the skin), `cracks` (free faces lying on another free face: parts that touch without sharing nodes) and `free_edges` (edges of the skin and of 2D elements on one facet only: open borders). Lists hold the first 1000; `counts` has the totals"#,
|
||||
form: Some(super::forms::CHECK_DUPLICATES),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: check_duplicates,
|
||||
};
|
||||
|
||||
pub const RENUMBER: CommandSpec = CommandSpec {
|
||||
id: "mesh.renumber",
|
||||
label: "Renumber",
|
||||
params: r#"{"what"?: "both" (default) | "nodes" | "elements", "by"?: "compact" (default: in label order) | "bandwidth" (nodes in reverse Cuthill–McKee order, elements by their lowest new node label) | "body" (body by body, by name; within a body in label order), "start"?: 1, "offset"?: n (instead of `by` and `start`: add n to every label)} — gives nodes and elements new labels from `start` up (or shifted by `offset`), every set, geometry tag and step following, so boundary conditions and loads stay on the same entities and decks write the new labels. Refused, changing nothing, when a label would leave 1..=99999999 or a node would take a reference point's label. Reports the counts renumbered, the label ranges and the bandwidth before and after"#,
|
||||
form: Some(super::forms::RENUMBER),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: renumber,
|
||||
};
|
||||
|
||||
/// Most items a reply lists.
|
||||
const MAX_LISTED: usize = 1000;
|
||||
|
||||
/// The merge distance: `tolerance`, or a millionth of the mesh's bounding-box diagonal.
|
||||
pub(crate) fn tolerance(model: &Model, p: &Value) -> Result<f64> {
|
||||
match p.get("tolerance") {
|
||||
Some(v) => v.as_f64().filter(|t| *t > 0.0 && t.is_finite()).ok_or_else(|| EngineError::param("tolerance", ProblemKind::NotPositive)),
|
||||
None => {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for n in model.nodes().values() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(n.xyz[k]);
|
||||
hi[k] = hi[k].max(n.xyz[k]);
|
||||
}
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt();
|
||||
Ok(if diagonal > 0.0 && diagonal.is_finite() { 1e-6 * diagonal } else { 1e-9 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The merge's candidates: the nodes of `set` (its own, and those of its elements and faces).
|
||||
fn candidates(model: &Model, p: &Value) -> Result<Option<BTreeSet<u32>>> {
|
||||
let Some(name) = super::opt::<String>(p, "set")? else { return Ok(None) };
|
||||
let set = model.resolve_set(&name)?;
|
||||
let mut nodes: BTreeSet<u32> = set.nodes.into_iter().collect();
|
||||
for e in set.elements.iter().chain(set.faces.iter().map(|f| &f.element)) {
|
||||
if let Some(el) = model.element(*e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
Ok(Some(nodes))
|
||||
}
|
||||
|
||||
fn keep(p: &Value) -> Result<Keep> {
|
||||
match super::opt::<String>(p, "keep")?.as_deref() {
|
||||
None | Some("lowest") => Ok(Keep::Lowest),
|
||||
Some("highest") => Ok(Keep::Highest),
|
||||
Some(_) => Err(EngineError::param("keep", ProblemKind::NotAChoice)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The merges `mesh.node.merge` with `params` would make, as [node, survivor] pairs: what its
|
||||
/// form previews. `None` when the parameters are not (yet) valid.
|
||||
pub fn merge_preview(s: &Session, params: &Value) -> Option<Vec<[u32; 2]>> {
|
||||
let tol = tolerance(&s.model, params).ok()?;
|
||||
let only = candidates(&s.model, params).ok()?;
|
||||
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(params).ok()?).ok()?;
|
||||
Some(plan.map.into_iter().map(|(a, b)| [a, b]).collect())
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = tolerance(&s.model, p)?;
|
||||
let only = candidates(&s.model, p)?;
|
||||
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(p)?)?;
|
||||
if !plan.map.is_empty() {
|
||||
s.model.merge_nodes(&plan.map)?;
|
||||
}
|
||||
let pairs: Vec<[u32; 2]> = plan.map.iter().take(MAX_LISTED).map(|(&a, &b)| [a, b]).collect();
|
||||
let refused: Vec<Value> =
|
||||
plan.refused.iter().take(MAX_LISTED).map(|r| json!({"node": r.node, "survivor": r.survivor, "element": r.element})).collect();
|
||||
let mut summary = format!("merged {} node{} within {tol:.3e}", plan.map.len(), if plan.map.len() == 1 { "" } else { "s" });
|
||||
if !plan.refused.is_empty() {
|
||||
summary.push_str(&format!("; {} left apart (they would collapse an element)", plan.refused.len()));
|
||||
}
|
||||
Ok(json!({
|
||||
"merged": plan.map.len(),
|
||||
"pairs": pairs,
|
||||
"refused": refused,
|
||||
"tolerance": tol,
|
||||
"nodes": s.model.nodes().len(),
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn check_duplicates(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = tolerance(&s.model, p)?;
|
||||
let d = duplicates(&s.model, tol)?;
|
||||
let coincident: Vec<Value> = d.coincident.iter().take(MAX_LISTED).map(|&(a, b, dist)| json!([a, b, dist])).collect();
|
||||
let summary = format!(
|
||||
"{} coincident node{}, {} duplicate element group{}, {} crack face{}, {} free edge{} ({} free faces)",
|
||||
d.coincident.len(),
|
||||
plural(d.coincident.len()),
|
||||
d.elements.len(),
|
||||
plural(d.elements.len()),
|
||||
d.cracks.len(),
|
||||
plural(d.cracks.len()),
|
||||
d.free_edges.len(),
|
||||
plural(d.free_edges.len()),
|
||||
d.free_faces,
|
||||
);
|
||||
Ok(json!({
|
||||
"tolerance": tol,
|
||||
"coincident": coincident,
|
||||
"duplicate_elements": d.elements.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"free_faces": d.free_faces,
|
||||
"cracks": d.cracks.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"free_edges": d.free_edges.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"counts": {
|
||||
"coincident": d.coincident.len(),
|
||||
"duplicate_elements": d.elements.len(),
|
||||
"cracks": d.cracks.len(),
|
||||
"free_edges": d.free_edges.len(),
|
||||
},
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn plural(n: usize) -> &'static str {
|
||||
if n == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
/// The body name of each element tagged with one.
|
||||
fn element_bodies(model: &Model) -> BTreeMap<u32, String> {
|
||||
let c = model.classification();
|
||||
c.elements()
|
||||
.iter()
|
||||
.filter_map(|(&e, t)| {
|
||||
let r = c.entity(t.region?)?;
|
||||
(r.entity == Entity::Body).then(|| (e, r.body.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// New labels for `order` from `start` up; `None` when they would leave the label range.
|
||||
fn from_start(order: &[u32], start: u32) -> Option<BTreeMap<u32, u32>> {
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &l)| Some((l, start.checked_add(u32::try_from(i).ok()?).filter(|x| *x <= MAX_LABEL)?)))
|
||||
.filter(|x| x.as_ref().is_none_or(|(a, b)| a != b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn renumber(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes_too, elements_too) = match super::opt::<String>(p, "what")?.as_deref() {
|
||||
None | Some("both") => (true, true),
|
||||
Some("nodes") => (true, false),
|
||||
Some("elements") => (false, true),
|
||||
Some(_) => return Err(EngineError::param("what", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let model = &s.model;
|
||||
let out_of_range = || EngineError::Params(format!("the new labels would leave 1..={MAX_LABEL}"));
|
||||
let (node_map, element_map) = if let Some(v) = p.get("offset") {
|
||||
if p.get("by").is_some() || p.get("start").is_some() {
|
||||
return Err(EngineError::Params("give `offset`, or `by` and `start`, not both".into()));
|
||||
}
|
||||
let offset = v
|
||||
.as_i64()
|
||||
.ok_or_else(|| EngineError::param("offset", ProblemKind::NotAWholeNumber { min: -i64::from(MAX_LABEL), max: i64::from(MAX_LABEL) }))?;
|
||||
let shift = |labels: &mut dyn Iterator<Item = u32>| -> Result<BTreeMap<u32, u32>> {
|
||||
labels
|
||||
.filter(|_| offset != 0)
|
||||
.map(|l| u32::try_from(i64::from(l) + offset).ok().filter(|x| (1..=MAX_LABEL).contains(x)).map(|n| (l, n)).ok_or_else(out_of_range))
|
||||
.collect()
|
||||
};
|
||||
let n = if nodes_too { shift(&mut model.nodes().keys().copied())? } else { BTreeMap::new() };
|
||||
let e = if elements_too { shift(&mut model.elements().keys().copied())? } else { BTreeMap::new() };
|
||||
(n, e)
|
||||
} else {
|
||||
let start = match p.get("start") {
|
||||
None => 1,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|x| u32::try_from(x).ok())
|
||||
.filter(|x| (1..=MAX_LABEL).contains(x))
|
||||
.ok_or_else(|| EngineError::param("start", ProblemKind::NotAWholeNumber { min: 1, max: i64::from(MAX_LABEL) }))?,
|
||||
};
|
||||
let (node_order, element_order): (Vec<u32>, Vec<u32>) = match super::opt::<String>(p, "by")?.as_deref() {
|
||||
None | Some("compact") => (model.nodes().keys().copied().collect(), model.elements().keys().copied().collect()),
|
||||
Some("bandwidth") => {
|
||||
let nodes = reverse_cuthill_mckee(model);
|
||||
let rank: BTreeMap<u32, usize> = nodes.iter().enumerate().map(|(i, &l)| (l, i)).collect();
|
||||
let mut elements: Vec<(usize, u32)> = model
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|(&l, e)| (e.nodes.iter().filter_map(|n| rank.get(n).copied()).min().unwrap_or(usize::MAX), l))
|
||||
.collect();
|
||||
elements.sort_unstable();
|
||||
(nodes, elements.into_iter().map(|(_, l)| l).collect())
|
||||
}
|
||||
Some("body") => {
|
||||
let bodies = element_bodies(model);
|
||||
// Elements by body name (untagged last), then label; nodes as their first
|
||||
// element in that order meets them, then the nodes no element uses.
|
||||
let mut elements: Vec<(Option<&String>, u32)> = model.elements().keys().map(|&l| (bodies.get(&l), l)).collect();
|
||||
elements.sort_by(|a, b| (a.0.is_none(), a.0, a.1).cmp(&(b.0.is_none(), b.0, b.1)));
|
||||
let elements: Vec<u32> = elements.into_iter().map(|(_, l)| l).collect();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut nodes: Vec<u32> = Vec::with_capacity(model.nodes().len());
|
||||
for e in &elements {
|
||||
for &n in model.element(*e).map(|x| x.nodes.as_slice()).unwrap_or(&[]) {
|
||||
if seen.insert(n) {
|
||||
nodes.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free: Vec<u32> = model.nodes().keys().copied().filter(|n| !seen.contains(n)).collect();
|
||||
free.sort_unstable();
|
||||
nodes.extend(free);
|
||||
(nodes, elements)
|
||||
}
|
||||
Some(_) => return Err(EngineError::param("by", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let n = if nodes_too { from_start(&node_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
|
||||
let e = if elements_too { from_start(&element_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
|
||||
(n, e)
|
||||
};
|
||||
let before = bandwidth(model, &|l| l);
|
||||
s.model.renumber(&node_map, &element_map)?;
|
||||
let after = bandwidth(&s.model, &|l| l);
|
||||
let range = |labels: &mut dyn Iterator<Item = u32>| -> Value {
|
||||
let (lo, hi) = labels.fold((u32::MAX, 0), |(lo, hi), l| (lo.min(l), hi.max(l)));
|
||||
if hi == 0 { Value::Null } else { json!([lo, hi]) }
|
||||
};
|
||||
Ok(json!({
|
||||
"nodes": node_map.len(),
|
||||
"elements": element_map.len(),
|
||||
"node_labels": range(&mut s.model.nodes().keys().copied()),
|
||||
"element_labels": range(&mut s.model.elements().keys().copied()),
|
||||
"bandwidth": [before, after],
|
||||
"summary": format!("renumbered {} nodes and {} elements; bandwidth {before} → {after}", node_map.len(), element_map.len()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 2 × 1 × 1 bar of hex8 blocks: whole, or as two halves that do not share their nodes
|
||||
/// on x = 1.
|
||||
fn bar(halves: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
let block = |body: &str, x0: f64, x1: f64, n: u32| {
|
||||
let c = [[x0, 0., 0.], [x1, 0., 0.], [x1, 1., 0.], [x0, 1., 0.], [x0, 0., 1.], [x1, 0., 1.], [x1, 1., 1.], [x0, 1., 1.]];
|
||||
json!({"body": body, "kind": "hex8", "corners": c, "divisions": [n, 2, 2], "merge": 0})
|
||||
};
|
||||
if halves {
|
||||
s.execute("mesh.block", &block("A", 0.0, 1.0, 2)).unwrap();
|
||||
s.execute("mesh.block", &block("B", 1.0, 2.0, 2)).unwrap();
|
||||
} else {
|
||||
s.execute("mesh.block", &block("A", 0.0, 2.0, 4)).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn positions(s: &Session) -> Vec<[u64; 3]> {
|
||||
let mut p: Vec<[u64; 3]> = s.model.nodes().values().map(|n| n.xyz.map(|x| (x + 0.0).to_bits())).collect();
|
||||
p.sort_unstable();
|
||||
p
|
||||
}
|
||||
|
||||
/// Two halves meshed apart are found coincident and cracked, and merging them gives the
|
||||
/// whole bar's nodes: no crack left, and undo brings the halves back.
|
||||
#[test]
|
||||
fn merging_two_halves_gives_the_whole() {
|
||||
let whole = bar(false);
|
||||
let mut s = bar(true);
|
||||
s.execute(
|
||||
"model.set.create",
|
||||
&json!({"name": "seam", "nodes": s.model.nodes().iter().filter(|(_, n)| n.xyz[0] == 1.0).map(|(l, _)| *l).collect::<Vec<_>>()}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!(d["counts"]["coincident"], 9, "{d}");
|
||||
assert_eq!(d["counts"]["cracks"], 8, "{d}");
|
||||
assert_eq!(d["counts"]["free_edges"], 0, "{d}");
|
||||
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
|
||||
|
||||
let r = s.execute("mesh.node.merge", &json!({})).unwrap();
|
||||
assert_eq!(r["merged"], 9, "{r}");
|
||||
assert_eq!(positions(&s), positions(&whole));
|
||||
// The seam set keeps its nine positions, on the survivors.
|
||||
assert_eq!(s.model.set("seam").unwrap().nodes.len(), 9);
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
|
||||
|
||||
// Keeping the highest labels; only a set's nodes; hostile parameters change nothing.
|
||||
let r = s.execute("mesh.node.merge", &json!({"keep": "highest", "set": "seam"})).unwrap();
|
||||
assert!(r["pairs"].as_array().unwrap().iter().all(|p| p[0].as_u64() < p[1].as_u64()), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for bad in [json!({"tolerance": -1}), json!({"tolerance": "x"}), json!({"keep": "middle"}), json!({"set": "nope"})] {
|
||||
assert!(s.execute("mesh.node.merge", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(merge_preview(&s, &json!({})).is_some_and(|p| p.len() == 9));
|
||||
}
|
||||
|
||||
/// Renumbering keeps the analysis: the cantilever solves to the same displacements, point
|
||||
/// for point, after its nodes take the bandwidth order from 1000 and its elements are
|
||||
/// shifted; the deck writes the new labels.
|
||||
#[test]
|
||||
fn renumbering_keeps_the_solution() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::cantilever()).unwrap();
|
||||
let at = json!([[100.0, 10.0, 10.0], [50.0, 0.0, 20.0], [25.0, 20.0, 0.0]]);
|
||||
let probe = |s: &mut Session| {
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
s.execute("solve.probe", &json!({"step": "Load", "at": at})).unwrap()["nodes"].clone()
|
||||
};
|
||||
let before = probe(&mut s);
|
||||
let bw = bandwidth(&s.model, &|l| l);
|
||||
let r = s.execute("mesh.renumber", &json!({"what": "nodes", "by": "bandwidth", "start": 1000})).unwrap();
|
||||
assert_eq!(r["node_labels"][0], 1000, "{r}");
|
||||
assert!(r["bandwidth"][1].as_u64().unwrap() <= u64::from(bw), "{r}");
|
||||
s.execute("mesh.renumber", &json!({"what": "elements", "offset": 500})).unwrap();
|
||||
assert_eq!(s.model.elements().keys().next().copied(), Some(501));
|
||||
let after = probe(&mut s);
|
||||
for (a, b) in before.as_array().unwrap().iter().zip(after.as_array().unwrap()) {
|
||||
for k in 0..3 {
|
||||
let (x, y) = (a["displacement"][k].as_f64().unwrap(), b["displacement"][k].as_f64().unwrap());
|
||||
// Equal to roundoff of the tip deflection (0.24 mm).
|
||||
assert!((x - y).abs() <= 1e-9, "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
let deck = fieldcraft_decks::abaqus::write(&s.model).unwrap();
|
||||
assert!(deck.lines().any(|l| l.starts_with("1000,")) && deck.lines().any(|l| l.starts_with("501,")), "{deck}");
|
||||
// Body by body and compact, then refusals that change nothing.
|
||||
s.execute("mesh.renumber", &json!({"by": "body"})).unwrap();
|
||||
s.execute("mesh.renumber", &json!({})).unwrap();
|
||||
assert_eq!(s.model.nodes().keys().next().copied(), Some(1));
|
||||
let model = s.model.clone();
|
||||
for bad in [
|
||||
json!({"offset": -1}),
|
||||
json!({"start": 99_999_999}),
|
||||
json!({"start": 0}),
|
||||
json!({"by": "random"}),
|
||||
json!({"what": "faces"}),
|
||||
json!({"offset": 1, "start": 3}),
|
||||
] {
|
||||
assert!(s.execute("mesh.renumber", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,16 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, inputs, number, tag_faces};
|
||||
use super::mesh::optional_count;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.octree",
|
||||
label: "Octree Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
form: Some(super::forms::HEX_OCTREE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_octree,
|
||||
};
|
||||
|
||||
@@ -58,26 +60,24 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::of_body(&b.name, e))?;
|
||||
total += mesh.hexes.len();
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let mut model = s.model.clone();
|
||||
let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str()));
|
||||
let mut report = Vec::with_capacity(meshes.len());
|
||||
let (mut node_label, mut element_label) = (1u32, 1u32);
|
||||
for (b, m) in &meshes {
|
||||
let first_node = node_label;
|
||||
for xyz in &m.nodes {
|
||||
model.set_node(node_label, *xyz)?;
|
||||
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
|
||||
let (first_node, first_element) = super::fresh_labels(&model, m.nodes.len(), m.hexes.len())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
let first_element = element_label;
|
||||
for hx in &m.hexes {
|
||||
model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
|
||||
model.tag_element(element_label, body_tag)?;
|
||||
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
|
||||
for (i, hx) in m.hexes.iter().enumerate() {
|
||||
let l = first_element + i as u32;
|
||||
model.set_element(l, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
|
||||
model.tag_element(l, body_tag)?;
|
||||
}
|
||||
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
let r = &m.report;
|
||||
@@ -97,8 +97,9 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"sweeps": r.sweeps,
|
||||
}));
|
||||
}
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
s.model = model;
|
||||
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Node relaxation (#398): `mesh.node.relax` moves the nodes inside a sphere of influence (or
|
||||
//! listed nodes, elements or a set) to fix the worst elements there, nodes on the geometry
|
||||
//! staying on it ([`fieldcraft_mesh::relax`]), and keeps what it did as a relaxation field of the
|
||||
//! model; `mesh.node.relax.apply` does a field again on the current mesh (after a remesh), and a
|
||||
//! field marked `reapply` is applied by itself after every remesh of its bodies.
|
||||
//!
|
||||
//! **Where a node may go** comes from the mesh's geometry tags: a node on one B-rep face slides
|
||||
//! in that face's parameters, a node where two faces meet slides along their edge, a node on a
|
||||
//! geometry vertex or on three or more faces stays. Faces of the same surface (the two halves a
|
||||
//! kernel splits a cylinder into, or one face as cut into cells) count once. Partition cut faces
|
||||
//! (`partition:<name>:a` and `:b`) are not geometry: a node on one moves freely, and the cells on
|
||||
//! both sides share it, so the mesh stays conforming across the cut. A node on the skin whose
|
||||
//! geometry is unknown (an imported mesh) stays.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Freedom, RegionQuality, Relaxed};
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
use fieldcraft_mesh::tet::geometry::{AnalyticSurface, Tessellation};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{Model, Relaxation};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const RELAX: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax",
|
||||
label: "Relax Nodes",
|
||||
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "nodes": [labels] | "elements": [labels] | "set": "Name", "bodies"?: ["Body1"] (only their nodes), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, and the field kept. One undo step"#,
|
||||
form: Some(super::forms::NODE_RELAX),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: relax_nodes,
|
||||
};
|
||||
|
||||
pub const APPLY: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax.apply",
|
||||
label: "Apply Relaxation",
|
||||
params: r#"{"field": "Name", "bodies"?: ["Body1"] (default the field's), "polish"?: 0.3 (strength of one guarded smoothing sweep after; 0 for none)} — applies a relaxation field kept by mesh.node.relax to the current mesh (typically after a remesh): each node inside the relaxed region takes the displacement interpolated from the field at its parametric coordinates in the old cells, on its face or edge as mesh.node.relax moves it; a move that would invert an element is undone; then one guarded smoothing sweep. Reports the nodes moved and the largest displacement"#,
|
||||
form: Some(super::forms::NODE_RELAX_APPLY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: apply,
|
||||
};
|
||||
|
||||
/// The commands after which `reapply` fields are applied again.
|
||||
const REMESH: &[&str] = &[
|
||||
"mesh.tet.generate",
|
||||
"mesh.hex.generate",
|
||||
"mesh.hex.sweep",
|
||||
"mesh.hex.mapped",
|
||||
"mesh.hex.blocks",
|
||||
"mesh.hex.octree",
|
||||
"mesh.cells",
|
||||
"mesh.block",
|
||||
];
|
||||
|
||||
const DEFAULT_ITERATIONS: usize = 5;
|
||||
|
||||
/// The region's quality as JSON.
|
||||
fn quality_json(q: &RegionQuality) -> Value {
|
||||
json!({
|
||||
"elements": q.elements,
|
||||
"min_scaled_jacobian": q.min_quality,
|
||||
"max_edge_ratio": if q.max_edge_ratio.is_finite() { json!(q.max_edge_ratio) } else { Value::Null },
|
||||
"invalid": q.invalid,
|
||||
"worst": q.worst.iter().map(|(e, v)| json!({"element": e, "quality": v})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Faces a node lies on (geometry references), from the element faces, shells and vertex tags.
|
||||
struct NodeTags {
|
||||
faces: BTreeMap<u32, BTreeSet<(String, String)>>,
|
||||
vertex: BTreeSet<u32>,
|
||||
/// Nodes on a partition cut face.
|
||||
cut: BTreeSet<u32>,
|
||||
}
|
||||
|
||||
fn node_tags(model: &Model, nodes: &BTreeSet<u32>) -> NodeTags {
|
||||
let c = model.classification();
|
||||
let mut t = NodeTags { faces: BTreeMap::new(), vertex: BTreeSet::new(), cut: BTreeSet::new() };
|
||||
let note = |n: u32, tag: u32, t: &mut NodeTags| {
|
||||
let Some(g) = c.entity(tag) else { return };
|
||||
if let Entity::Face(name) = &g.entity {
|
||||
if name.starts_with("partition:") {
|
||||
t.cut.insert(n);
|
||||
} else {
|
||||
t.faces.entry(n).or_default().insert((g.body.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (&label, tags) in c.elements() {
|
||||
let Some(e) = model.element(label) else { continue };
|
||||
if !e.nodes.iter().any(|n| nodes.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
if e.kind.dimension() == 2
|
||||
&& let Some(r) = tags.region
|
||||
{
|
||||
for &n in &e.nodes {
|
||||
note(n, r, &mut t);
|
||||
}
|
||||
}
|
||||
for (&face, &tag) in &tags.faces {
|
||||
let Some(local) = e.kind.faces().get(usize::from(face).wrapping_sub(1)) else { continue };
|
||||
for &i in local.iter() {
|
||||
if let Some(&n) = e.nodes.get(i) {
|
||||
note(n, tag, &mut t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (&n, &tag) in c.nodes() {
|
||||
if nodes.contains(&n) && c.entity(tag).is_some_and(|g| matches!(g.entity, Entity::Vertex(_))) {
|
||||
t.vertex.insert(n);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Whether two exact surfaces are the same surface.
|
||||
fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
|
||||
use AnalyticSurface as S;
|
||||
let sub = |x: [f64; 3], y: [f64; 3]| [x[0] - y[0], x[1] - y[1], x[2] - y[2]];
|
||||
let dot = |x: [f64; 3], y: [f64; 3]| x[0] * y[0] + x[1] * y[1] + x[2] * y[2];
|
||||
let len = |x: [f64; 3]| dot(x, x).sqrt();
|
||||
let unit = |x: [f64; 3]| {
|
||||
let l = len(x);
|
||||
if l > 0.0 { [x[0] / l, x[1] / l, x[2] / l] } else { x }
|
||||
};
|
||||
let parallel = |x: [f64; 3], y: [f64; 3]| (dot(unit(x), unit(y)).abs() - 1.0).abs() < 1e-9;
|
||||
let on_axis = |p: [f64; 3], o: [f64; 3], a: [f64; 3]| {
|
||||
let d = sub(p, o);
|
||||
let a = unit(a);
|
||||
let along = dot(d, a);
|
||||
len([d[0] - a[0] * along, d[1] - a[1] * along, d[2] - a[2] * along]) <= tol
|
||||
};
|
||||
match (*a, *b) {
|
||||
(S::Plane { normal: n1, point: p1 }, S::Plane { normal: n2, point: p2 }) => parallel(n1, n2) && dot(sub(p2, p1), unit(n1)).abs() <= tol,
|
||||
(S::Cylinder { origin: o1, axis: a1, radius: r1 }, S::Cylinder { origin: o2, axis: a2, radius: r2 }) => {
|
||||
(r1 - r2).abs() <= tol && parallel(a1, a2) && on_axis(o2, o1, a1)
|
||||
}
|
||||
(S::Sphere { center: c1, radius: r1 }, S::Sphere { center: c2, radius: r2 }) => len(sub(c1, c2)) <= tol && (r1 - r2).abs() <= tol,
|
||||
(S::Cone { apex: p1, axis: a1, half_angle: h1 }, S::Cone { apex: p2, axis: a2, half_angle: h2 }) => {
|
||||
len(sub(p1, p2)) <= tol && dot(unit(a1), unit(a2)) > 1.0 - 1e-9 && (h1 - h2).abs() <= 1e-9
|
||||
}
|
||||
(S::Torus { center: c1, axis: a1, major: m1, minor: n1 }, S::Torus { center: c2, axis: a2, major: m2, minor: n2 }) => {
|
||||
len(sub(c1, c2)) <= tol && parallel(a1, a2) && (m1 - m2).abs() <= tol && (n1 - n2).abs() <= tol
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The design's geometry as relaxation constraints.
|
||||
struct DesignGeometry {
|
||||
/// Per body: its tessellation with exact surfaces.
|
||||
bodies: Vec<Tessellation>,
|
||||
/// Face ids: (body, B-rep face).
|
||||
faces: Vec<(usize, u32)>,
|
||||
/// Curve ids: (body, the two B-rep faces).
|
||||
curves: Vec<(usize, [u32; 2])>,
|
||||
freedom: BTreeMap<u32, Freedom>,
|
||||
}
|
||||
|
||||
impl Constraints for DesignGeometry {
|
||||
fn freedom(&self, node: u32) -> Option<Freedom> {
|
||||
self.freedom.get(&node).copied()
|
||||
}
|
||||
fn slide_face(&self, face: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.faces.get(face as usize)?;
|
||||
self.bodies.get(b)?.slide_face(f, p, d)
|
||||
}
|
||||
fn slide_curve(&self, curve: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.curves.get(curve as usize)?;
|
||||
self.bodies.get(b)?.slide_curve(f, p, d)
|
||||
}
|
||||
}
|
||||
|
||||
/// The constraints of `nodes` from the mesh's tags and the open design.
|
||||
fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
|
||||
let tags = node_tags(&s.model, nodes);
|
||||
let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
|
||||
// Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
|
||||
let mut loaded: BTreeMap<String, Option<(usize, Vec<Option<AnalyticSurface>>)>> = BTreeMap::new();
|
||||
let mut load = |name: &str, g: &mut DesignGeometry| -> Result<Option<(usize, Vec<Option<AnalyticSurface>>)>> {
|
||||
if let Some(l) = loaded.get(name) {
|
||||
return Ok(l.clone());
|
||||
}
|
||||
let shape = s.design.as_deref().and_then(|d| d.body(name));
|
||||
let entry = match shape {
|
||||
None => None,
|
||||
Some(shape) => {
|
||||
let tol = (shape.size()? * 1e-3).max(1e-6);
|
||||
let mesh = shape.tessellate(tol)?;
|
||||
let surface = BoundarySurface::new(
|
||||
&mesh.positions,
|
||||
&mesh.triangles,
|
||||
&mesh.tri_face,
|
||||
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
|
||||
)?;
|
||||
let analytic = super::mesh::analytic_surfaces(shape);
|
||||
g.bodies.push(Tessellation::new(&surface, analytic.clone()));
|
||||
Some((g.bodies.len() - 1, analytic))
|
||||
}
|
||||
};
|
||||
loaded.insert(name.to_string(), entry.clone());
|
||||
Ok(entry)
|
||||
};
|
||||
let tol = 1e-6 * s.model.nodes().values().fold(0.0f64, |m, n| m.max(n.xyz.iter().fold(0.0f64, |a, x| a.max(x.abs())))).max(1.0);
|
||||
let mut face_ids: BTreeMap<(usize, u32), u32> = BTreeMap::new();
|
||||
let mut curve_ids: BTreeMap<(usize, [u32; 2]), u32> = BTreeMap::new();
|
||||
for &n in nodes {
|
||||
if tags.vertex.contains(&n) {
|
||||
g.freedom.insert(n, Freedom::Fixed);
|
||||
continue;
|
||||
}
|
||||
let Some(refs) = tags.faces.get(&n) else {
|
||||
if tags.cut.contains(&n) {
|
||||
g.freedom.insert(n, Freedom::Free);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// Resolve each face; group those on one surface.
|
||||
let mut groups: Vec<Vec<(usize, u32, Option<AnalyticSurface>)>> = Vec::new();
|
||||
let mut unknown = false;
|
||||
for (body, face) in refs {
|
||||
let Some((b, analytic)) = load(body, &mut g)? else {
|
||||
unknown = true;
|
||||
continue;
|
||||
};
|
||||
let Some(f) = s.design.as_deref().and_then(|d| d.body(body)).and_then(|sh| sh.face_index(face)) else {
|
||||
unknown = true;
|
||||
continue;
|
||||
};
|
||||
let surface = analytic.get(f as usize).copied().flatten();
|
||||
let group = groups.iter_mut().find(|grp| {
|
||||
grp.iter().any(|(gb, gf, gs)| (*gb == b && *gf == f) || matches!((gs, surface), (Some(x), Some(y)) if same_surface(x, &y, tol)))
|
||||
});
|
||||
match group {
|
||||
Some(grp) => grp.push((b, f, surface)),
|
||||
None => groups.push(vec![(b, f, surface)]),
|
||||
}
|
||||
}
|
||||
let freedom = match groups.as_slice() {
|
||||
_ if unknown => Freedom::Fixed,
|
||||
[one] => {
|
||||
let Some(&(b, f, _)) = one.first() else { continue };
|
||||
let next = g.faces.len() as u32;
|
||||
let id = *face_ids.entry((b, f)).or_insert(next);
|
||||
if id == next {
|
||||
g.faces.push((b, f));
|
||||
}
|
||||
Freedom::Face(id)
|
||||
}
|
||||
[x, y] => {
|
||||
// A body with a face of each surface carries their edge.
|
||||
let pair = x.iter().find_map(|&(bx, fx, _)| y.iter().find(|(by, _, _)| *by == bx).map(|&(_, fy, _)| (bx, [fx.min(fy), fx.max(fy)])));
|
||||
match pair {
|
||||
Some(key) => {
|
||||
let next = g.curves.len() as u32;
|
||||
let id = *curve_ids.entry(key).or_insert(next);
|
||||
if id == next {
|
||||
g.curves.push(key);
|
||||
}
|
||||
Freedom::Curve(id)
|
||||
}
|
||||
None => Freedom::Fixed,
|
||||
}
|
||||
}
|
||||
_ => Freedom::Fixed,
|
||||
};
|
||||
g.freedom.insert(n, freedom);
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
/// The nodes of `bodies`' elements, when given.
|
||||
fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<u32>)>> {
|
||||
let Some(bodies) = super::opt::<Vec<String>>(p, "bodies")? else { return Ok(None) };
|
||||
let mut nodes = BTreeSet::new();
|
||||
for b in &bodies {
|
||||
let elements = model.body_elements(b);
|
||||
if elements.is_empty() {
|
||||
return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(80).collect::<String>())));
|
||||
}
|
||||
for e in elements {
|
||||
if let Some(el) = model.element(e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some((bodies, nodes)))
|
||||
}
|
||||
|
||||
fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
|
||||
match super::opt::<[f64; 3]>(p, key)? {
|
||||
Some(c) if c.iter().all(|x| x.is_finite()) => Ok(Some(c)),
|
||||
Some(_) => Err(EngineError::param(key, ProblemKind::NotANumber)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sphere around element `label`: its centroid, and twice its longest corner edge.
|
||||
fn around_element(model: &Model, label: u32) -> Result<([f64; 3], f64)> {
|
||||
let e = model.element(label).ok_or_else(|| EngineError::Params(format!("no element {label}")))?;
|
||||
let x: Vec<[f64; 3]> = e.nodes.iter().take(e.kind.corner_count()).filter_map(|n| model.node(*n).map(|p| p.xyz)).collect();
|
||||
if x.is_empty() {
|
||||
return Err(EngineError::Params(format!("element {label} has no nodes")));
|
||||
}
|
||||
let c = x.iter().fold([0.0; 3], |s, p| [s[0] + p[0], s[1] + p[1], s[2] + p[2]]).map(|v| v / x.len() as f64);
|
||||
let mut longest = 0.0f64;
|
||||
for edge in e.kind.edges() {
|
||||
let at = |i: usize| edge.get(i).and_then(|&k| x.get(k)).copied();
|
||||
if let (Some(a), Some(b)) = (at(0), at(1)) {
|
||||
longest = longest.max(((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt());
|
||||
}
|
||||
}
|
||||
Ok((c, 2.0 * longest))
|
||||
}
|
||||
|
||||
/// The nodes to move and their weights, and the sphere when there is one.
|
||||
pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap<u32, f64>, Option<([f64; 3], f64)>)> {
|
||||
let strength = super::optional_number(p, "strength", 1.0)?;
|
||||
if !(strength > 0.0 && strength <= 1.0) {
|
||||
return Err(EngineError::param("strength", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let falloff = match super::opt::<String>(p, "falloff")? {
|
||||
None => Falloff::Smooth,
|
||||
Some(f) => Falloff::from_name(&f).ok_or_else(|| EngineError::param("falloff", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
let only = body_nodes(model, p)?.map(|(_, n)| n);
|
||||
let around = match super::opt::<u32>(p, "around_element")? {
|
||||
Some(e) => Some(around_element(model, e)?),
|
||||
None => None,
|
||||
};
|
||||
let center = point(p, "center")?.or(around.map(|a| a.0));
|
||||
let radius = match p.get("radius") {
|
||||
Some(_) => Some(super::optional_number(p, "radius", 0.0)?)
|
||||
.filter(|r| *r > 0.0 && r.is_finite())
|
||||
.ok_or_else(|| EngineError::param("radius", ProblemKind::NotPositive))
|
||||
.map(Some)?,
|
||||
None => around.map(|a| a.1),
|
||||
};
|
||||
let listed = ["nodes", "elements", "set"].iter().filter(|k| p.get(**k).is_some()).count();
|
||||
if let (Some(c), Some(r)) = (center, radius) {
|
||||
if listed > 0 {
|
||||
return Err(EngineError::Params(
|
||||
"give a sphere (`center` and `radius`, or `around_element`) or `nodes`, `elements` or `set`, not both".into(),
|
||||
));
|
||||
}
|
||||
return Ok((relax::sphere_weights(model, c, r, strength, falloff, only.as_ref())?, Some((c, r))));
|
||||
}
|
||||
if center.is_some() != radius.is_some() {
|
||||
return Err(EngineError::Params("a sphere of influence needs both `center` and `radius`".into()));
|
||||
}
|
||||
let mut nodes: BTreeSet<u32> = BTreeSet::new();
|
||||
if p.get("nodes").is_some() {
|
||||
nodes.extend(super::labels(p, "nodes", fieldcraft_model::MAX_NODES)?);
|
||||
}
|
||||
let mut elements: Vec<u32> = Vec::new();
|
||||
if p.get("elements").is_some() {
|
||||
elements.extend(super::labels(p, "elements", fieldcraft_model::MAX_ELEMENTS)?);
|
||||
}
|
||||
if let Some(name) = super::opt::<String>(p, "set")? {
|
||||
let set = model.resolve_set(&name)?;
|
||||
nodes.extend(set.nodes.iter().copied());
|
||||
elements.extend(set.elements.iter().copied());
|
||||
elements.extend(set.faces.iter().map(|f| f.element));
|
||||
}
|
||||
for e in elements {
|
||||
let el = model.element(e).ok_or_else(|| EngineError::Params(format!("no element {e}")))?;
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
if listed == 0 {
|
||||
return Err(EngineError::Params(
|
||||
"say which nodes to relax: give one of `center` and `radius`, `around_element`, `nodes`, `elements` or `set`".into(),
|
||||
));
|
||||
}
|
||||
if let Some(only) = &only {
|
||||
nodes.retain(|n| only.contains(n));
|
||||
}
|
||||
Ok((relax::uniform_weights(model, &nodes, strength)?, None))
|
||||
}
|
||||
|
||||
fn iterations(p: &Value) -> Result<usize> {
|
||||
match p.get("iterations") {
|
||||
None => Ok(DEFAULT_ITERATIONS),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.filter(|i| (1..=relax::MAX_ITERATIONS).contains(i))
|
||||
.ok_or_else(|| EngineError::param("iterations", ProblemKind::NotInRange { min: 1.0, max: relax::MAX_ITERATIONS as f64 })),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes the relaxation may touch: the weighted ones and their elements' nodes.
|
||||
fn touched(model: &Model, weights: &BTreeMap<u32, f64>) -> BTreeSet<u32> {
|
||||
let mut out: BTreeSet<u32> = weights.keys().copied().collect();
|
||||
for e in model.elements().values() {
|
||||
if e.nodes.iter().any(|n| weights.contains_key(n)) {
|
||||
out.extend(e.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What `mesh.node.relax` with `params` would do, without doing it: the open form's preview.
|
||||
pub struct Preview {
|
||||
pub sphere: Option<([f64; 3], f64)>,
|
||||
pub relaxed: Relaxed,
|
||||
}
|
||||
|
||||
pub fn preview(s: &Session, params: &Value) -> Option<Preview> {
|
||||
let (w, sphere) = weights(&s.model, params).ok()?;
|
||||
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
|
||||
let relaxed = relax::relax(&s.model, &w, iterations(params).ok()?, &g).ok()?;
|
||||
Some(Preview { sphere, relaxed })
|
||||
}
|
||||
|
||||
/// The sphere `params` describe, for the form's gizmo before the rest is valid.
|
||||
pub fn sphere(s: &Session, params: &Value) -> Option<([f64; 3], f64)> {
|
||||
let around = params.get("around_element").and_then(Value::as_u64).and_then(|e| around_element(&s.model, u32::try_from(e).ok()?).ok());
|
||||
let c = point(params, "center").ok().flatten().or(around.map(|a| a.0))?;
|
||||
let r = params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite()).or(around.map(|a| a.1))?;
|
||||
Some((c, r))
|
||||
}
|
||||
|
||||
fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, sphere) = weights(&s.model, p)?;
|
||||
if w.is_empty() {
|
||||
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
|
||||
}
|
||||
let iterations = iterations(p)?;
|
||||
let g = design_geometry(s, &touched(&s.model, &w))?;
|
||||
let r = relax::relax(&s.model, &w, iterations, &g)?;
|
||||
let field_name = match super::opt::<String>(p, "field")? {
|
||||
Some(n) => n,
|
||||
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
|
||||
};
|
||||
let bodies = match body_nodes(&s.model, p)? {
|
||||
Some((b, _)) => b,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let reapply = super::opt::<bool>(p, "reapply")?.unwrap_or(false);
|
||||
if !r.positions.is_empty() {
|
||||
let cells = relax::field_cells(&s.model, &r);
|
||||
s.model.set_relaxation(&field_name, Relaxation { bodies, cells, reapply })?;
|
||||
}
|
||||
for (n, x) in &r.positions {
|
||||
s.model.set_node(*n, *x)?;
|
||||
}
|
||||
let summary = format!(
|
||||
"moved {} node{} (largest {:.3e}); scaled Jacobian min from {:.3} to {:.3}{}",
|
||||
r.moved,
|
||||
if r.moved == 1 { "" } else { "s" },
|
||||
r.max_displacement,
|
||||
r.before.min_quality,
|
||||
r.after.min_quality,
|
||||
if r.positions.is_empty() { String::new() } else { format!("; kept as field {field_name}") }
|
||||
);
|
||||
Ok(json!({
|
||||
"moved": r.moved,
|
||||
"nodes_changed": r.positions.len(),
|
||||
"max_displacement": r.max_displacement,
|
||||
"iterations": r.iterations,
|
||||
"sphere": sphere.map(|(c, r)| json!({"center": c, "radius": r})),
|
||||
"before": quality_json(&r.before),
|
||||
"after": quality_json(&r.after),
|
||||
"field": (!r.positions.is_empty()).then_some(field_name),
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply field `name` to the nodes of `bodies` (or of the whole mesh).
|
||||
fn apply_field(s: &mut Session, name: &str, bodies: &[String], polish: f64) -> Result<Relaxed> {
|
||||
let field = s
|
||||
.model
|
||||
.relaxation(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| EngineError::Params(format!("no relaxation field `{}`", name.chars().take(80).collect::<String>())))?;
|
||||
let mut nodes = BTreeSet::new();
|
||||
let bodies = if bodies.is_empty() { &field.bodies[..] } else { bodies };
|
||||
if bodies.is_empty() {
|
||||
nodes.extend(s.model.nodes().keys().copied());
|
||||
} else {
|
||||
for b in bodies {
|
||||
for e in s.model.body_elements(b) {
|
||||
if let Some(el) = s.model.element(e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only nodes the field reaches need constraints.
|
||||
nodes.retain(|n| s.model.node(*n).is_some_and(|p| relax::field_displacement(&field.cells, p.xyz).is_some()));
|
||||
let g = design_geometry(s, &nodes)?;
|
||||
let r = relax::apply_field(&s.model, &field.cells, Some(&nodes), &g, polish)?;
|
||||
for (n, x) in &r.positions {
|
||||
s.model.set_node(*n, *x)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: String = super::req(p, "field")?;
|
||||
let bodies = super::opt::<Vec<String>>(p, "bodies")?.unwrap_or_default();
|
||||
let polish = super::optional_number(p, "polish", 0.3)?;
|
||||
if !(0.0..=1.0).contains(&polish) {
|
||||
return Err(EngineError::param("polish", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let r = apply_field(s, &name, &bodies, polish)?;
|
||||
Ok(json!({
|
||||
"moved": r.moved,
|
||||
"nodes_changed": r.positions.len(),
|
||||
"max_displacement": r.max_displacement,
|
||||
"summary": format!("applied field {name}: moved {} node{} (largest {:.3e})", r.moved, if r.moved == 1 { "" } else { "s" }, r.max_displacement),
|
||||
}))
|
||||
}
|
||||
|
||||
/// After a remesh (`id`), apply again every field marked `reapply`; its reply lists them.
|
||||
pub(crate) fn after_remesh(s: &mut Session, id: &str, mut reply: Value) -> Result<Value> {
|
||||
if !REMESH.contains(&id) {
|
||||
return Ok(reply);
|
||||
}
|
||||
let fields: Vec<String> = s.model.relaxations().iter().filter(|(_, f)| f.reapply).map(|(n, _)| n.clone()).collect();
|
||||
let mut applied = Vec::new();
|
||||
for name in fields {
|
||||
let r = apply_field(s, &name, &[], 0.3)?;
|
||||
applied.push(json!({"field": name, "moved": r.moved}));
|
||||
}
|
||||
if !applied.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.insert("relaxations_applied".into(), Value::Array(applied));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,194 @@
|
||||
use super::*;
|
||||
|
||||
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15): a point on the hole's wall.
|
||||
const ON_HOLE: [f64; 3] = [20.0, 15.0, 5.0];
|
||||
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
|
||||
|
||||
fn plate() -> Session {
|
||||
super::super::datum::tests::plate().0
|
||||
}
|
||||
|
||||
fn hole_radius(p: [f64; 3]) -> f64 {
|
||||
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Which analytic face a node of the plate lies on, if any.
|
||||
fn on_faces(p: [f64; 3]) -> Vec<&'static str> {
|
||||
let mut f = Vec::new();
|
||||
if (hole_radius(p) - HOLE.1).abs() < 1e-6 {
|
||||
f.push("hole");
|
||||
}
|
||||
if p[2].abs() < 1e-6 || (p[2] - 10.0).abs() < 1e-6 {
|
||||
f.push("cap");
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Spikes near the hole: interior nodes pushed about; nodes on the hole turned round it and
|
||||
/// moved along its axis (still on the cylinder), nodes on the caps moved in their plane.
|
||||
fn perturb(s: &mut Session, centre: [f64; 3], radius: f64, amount: f64) {
|
||||
let nodes: Vec<(u32, [f64; 3])> = s.model.nodes().iter().map(|(n, p)| (*n, p.xyz)).collect();
|
||||
for (k, (n, p)) in nodes.into_iter().enumerate() {
|
||||
let d2: f64 = (0..3).map(|i| (p[i] - centre[i]).powi(2)).sum();
|
||||
if d2 > radius * radius {
|
||||
continue;
|
||||
}
|
||||
let wiggle = |m: usize| amount * ((k * m % 7) as f64 - 3.0) / 3.0;
|
||||
let faces = on_faces(p);
|
||||
let q = match faces.as_slice() {
|
||||
[] => [p[0] + wiggle(3), p[1] + wiggle(5), p[2] + wiggle(11)],
|
||||
["hole"] => {
|
||||
let a = wiggle(3) / HOLE.1;
|
||||
let (x, y) = (p[0] - HOLE.0[0], p[1] - HOLE.0[1]);
|
||||
[HOLE.0[0] + x * a.cos() - y * a.sin(), HOLE.0[1] + x * a.sin() + y * a.cos(), p[2] + wiggle(5)]
|
||||
}
|
||||
["cap"] if hole_radius(p) > HOLE.1 + 1e-6 => [p[0] + wiggle(3), p[1] + wiggle(5), p[2]],
|
||||
_ => continue,
|
||||
};
|
||||
s.model.set_node(n, q).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_on_geometry(before: &Model, after: &Model) {
|
||||
for (n, p) in after.nodes() {
|
||||
let old = before.node(*n).unwrap().xyz;
|
||||
for f in on_faces(old) {
|
||||
match f {
|
||||
"hole" => assert!((hole_radius(p.xyz) - HOLE.1).abs() < 1e-6, "node {n} left the hole: r = {}", hole_radius(p.xyz)),
|
||||
_ => assert!((p.xyz[2] - old[2]).abs() < 1e-6, "node {n} left its cap"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(s: &mut Session) -> u64 {
|
||||
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The issue's hex test on a design: spikes near the hole relax, every surface node stays on its
|
||||
/// face (the hole's cylinder exactly), quality rises, nothing inverts, nothing outside the sphere
|
||||
/// moves; one undo step; the field is kept and saved with the model.
|
||||
#[test]
|
||||
fn hex_spikes_near_a_hole_relax_on_the_geometry() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 3.0, 0.35);
|
||||
let perturbed = s.model.clone();
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 10})).unwrap();
|
||||
let (before, after) = (r["before"]["min_scaled_jacobian"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap());
|
||||
assert!(after > before + 0.05, "{r}");
|
||||
assert_eq!(r["after"]["invalid"], 0, "{r}");
|
||||
assert!(r["moved"].as_u64().unwrap() > 0 && r["field"] == "Relax1", "{r}");
|
||||
check_on_geometry(&perturbed, &s.model);
|
||||
for (n, p) in s.model.nodes() {
|
||||
let old = perturbed.node(*n).unwrap().xyz;
|
||||
if old != p.xyz {
|
||||
let d2: f64 = (0..3).map(|i| (old[i] - ON_HOLE[i]).powi(2)).sum();
|
||||
assert!(d2 < 16.0, "node {n} outside the sphere moved");
|
||||
}
|
||||
}
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
// The field is saved with the model.
|
||||
let text = serde_json::to_string(&s.model).unwrap();
|
||||
let back: Model = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.relaxation("Relax1"), s.model.relaxation("Relax1"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, perturbed);
|
||||
}
|
||||
|
||||
/// The same on tet10 with curved mid-side nodes on the hole: they stay on the cylinder.
|
||||
#[test]
|
||||
fn tet10_relaxes_with_curved_mid_side_nodes_on_the_hole() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.tet.generate", &json!({"size": 2.5, "order": "quadratic"})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 3.0, 0.2);
|
||||
// Put the mid-side nodes back on their edges' middles, on the hole where the edge is.
|
||||
let mids: Vec<(u32, u32, u32)> = s
|
||||
.model
|
||||
.elements()
|
||||
.values()
|
||||
.flat_map(|e| e.kind.edges().iter().map(|ed| (e.nodes[ed[0]], e.nodes[ed[1]], e.nodes[ed[2]])).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let original = s.model.clone();
|
||||
for (a, b, m) in mids {
|
||||
let (pa, pb, pm) = (original.node(a).unwrap().xyz, original.node(b).unwrap().xyz, original.node(m).unwrap().xyz);
|
||||
let mut q = [(pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0, (pa[2] + pb[2]) / 2.0];
|
||||
if on_faces(pm).contains(&"hole") {
|
||||
let r = hole_radius(q);
|
||||
q = [HOLE.0[0] + (q[0] - HOLE.0[0]) * HOLE.1 / r, HOLE.0[1] + (q[1] - HOLE.0[1]) * HOLE.1 / r, q[2]];
|
||||
}
|
||||
s.model.set_node(m, q).unwrap();
|
||||
}
|
||||
let perturbed = s.model.clone();
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 8})).unwrap();
|
||||
assert!(r["after"]["min_scaled_jacobian"].as_f64().unwrap() >= r["before"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
|
||||
assert_eq!(r["after"]["invalid"], 0, "{r}");
|
||||
check_on_geometry(&perturbed, &s.model);
|
||||
}
|
||||
|
||||
/// A partition cut is not geometry: nodes on it move freely, shared by the cells on both sides.
|
||||
#[test]
|
||||
fn nodes_on_a_partition_cut_move_freely() {
|
||||
let mut s = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 27}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "Cut"})).unwrap();
|
||||
s.execute("mesh.cells", &json!({"size": 2.0, "default": "hex"})).unwrap();
|
||||
let centre = [27.0, 15.0, 5.0];
|
||||
perturb(&mut s, centre, 2.5, 0.3);
|
||||
let before = s.model.clone();
|
||||
s.execute("mesh.node.relax", &json!({"center": centre, "radius": 3.5, "iterations": 10})).unwrap();
|
||||
let moved_off = s
|
||||
.model
|
||||
.nodes()
|
||||
.iter()
|
||||
.filter(|(n, p)| {
|
||||
let old = before.node(**n).unwrap().xyz;
|
||||
(old[0] - 27.0).abs() < 1e-6 && on_faces(old).is_empty() && (p.xyz[0] - 27.0).abs() > 1e-6
|
||||
})
|
||||
.count();
|
||||
assert!(moved_off > 0, "nodes on the cut are free");
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
}
|
||||
|
||||
/// A field marked `reapply` is applied again after a remesh; `mesh.node.relax.apply` applies it
|
||||
/// on demand; around the worst element works.
|
||||
#[test]
|
||||
fn a_kept_field_is_applied_again_after_a_remesh() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 3.0})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 4.0, 0.3);
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 5.0, "field": "Spike", "reapply": true})).unwrap();
|
||||
assert_eq!(r["field"], "Spike");
|
||||
let r = s.execute("mesh.hex.generate", &json!({"size": 1.5})).unwrap();
|
||||
assert_eq!(r["relaxations_applied"][0]["field"], "Spike", "{r}");
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
let r = s.execute("mesh.node.relax.apply", &json!({"field": "Spike"})).unwrap();
|
||||
assert!(r["summary"].as_str().unwrap().contains("Spike"));
|
||||
let worst = s.execute("mesh.quality", &json!({})).unwrap()["metrics"]["scaled_jacobian"]["worst"].as_u64().unwrap();
|
||||
let r = s.execute("mesh.node.relax", &json!({"around_element": worst})).unwrap();
|
||||
assert!(r["after"]["min_scaled_jacobian"].as_f64() >= r["before"]["min_scaled_jacobian"].as_f64(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_never_panic() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"center": [0, 0, 0]}),
|
||||
json!({"center": [1e300, 0, 0], "radius": 1}),
|
||||
json!({"center": [0, 0, 0], "radius": -1}),
|
||||
json!({"center": [0, 0, 0], "radius": 1, "nodes": [1]}),
|
||||
json!({"nodes": [1], "strength": 2}),
|
||||
json!({"nodes": [1], "iterations": 0}),
|
||||
json!({"nodes": [1], "iterations": 1000}),
|
||||
json!({"nodes": [1], "falloff": "cubic"}),
|
||||
json!({"around_element": 99999999}),
|
||||
json!({"set": "nope"}),
|
||||
json!({"nodes": [1], "bodies": ["nope"]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("mesh.node.relax.apply", &json!({"field": "nope"})).is_err());
|
||||
assert!(s.execute("mesh.node.relax.apply", &json!({})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Meshing a body replaces its mesh (#267). Every mesher that fills bodies goes through
|
||||
//! [`clear`]: before it adds a body's new elements it removes that body's old elements and the
|
||||
//! nodes only they use, so other bodies keep their meshes and two meshes never stack on one
|
||||
//! body. The reply's `replaced` says what went; sets that listed the removed labels are named as
|
||||
//! `stale_sets` (sets by geometry re-resolve on the new mesh), and results solved on the old
|
||||
//! mesh as `results_cleared`. The command is one undo step, so undo brings the old mesh back.
|
||||
//!
|
||||
//! [`MESHERS`] lists those commands with the bodies each one fills, which the UI reads
|
||||
//! ([`replacement`]) to ask before replacing a mesh. Programmatic calls never ask. `mesh.block`
|
||||
//! is not one of them: a body is built from several blocks, each call adding one.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_model::{BodyMesh, ElementKind, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// What [`clear`] removed.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cleared {
|
||||
/// Per body that had a mesh: its name and the element and node counts removed.
|
||||
bodies: Vec<(String, usize, usize)>,
|
||||
removed: BodyMesh,
|
||||
/// Sets that listed a removed node or element by label.
|
||||
stale_sets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Remove the meshes of `bodies` from `model` (each body once).
|
||||
pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator<Item = &'a str>) -> Cleared {
|
||||
let mut out = Cleared::default();
|
||||
let mut seen = BTreeSet::new();
|
||||
for body in bodies {
|
||||
if !seen.insert(body) {
|
||||
continue;
|
||||
}
|
||||
let r = model.remove_body_mesh(body);
|
||||
if !r.elements.is_empty() {
|
||||
out.bodies.push((body.to_string(), r.elements.len(), r.nodes.len()));
|
||||
out.removed.elements.extend(r.elements);
|
||||
out.removed.nodes.extend(r.nodes);
|
||||
}
|
||||
}
|
||||
out.stale_sets = model.sets_using(&out.removed);
|
||||
out
|
||||
}
|
||||
|
||||
impl Cleared {
|
||||
/// [`Cleared::report`] when anything was removed; `reply` as it is otherwise.
|
||||
pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value {
|
||||
if self.bodies.is_empty() { reply } else { self.report(s, reply) }
|
||||
}
|
||||
|
||||
/// Add `replaced`, `stale_sets` and `results_cleared` to a command's reply (an object).
|
||||
pub(super) fn report(self, s: &Session, mut reply: Value) -> Value {
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
let replaced: Vec<Value> = self.bodies.iter().map(|(b, e, n)| json!({"body": b, "elements": e, "nodes": n})).collect();
|
||||
o.insert("replaced".into(), json!(replaced));
|
||||
if !self.stale_sets.is_empty() {
|
||||
o.insert("stale_sets".into(), json!(self.stale_sets));
|
||||
}
|
||||
let results: Vec<&str> = s.solved_steps().collect();
|
||||
if !results.is_empty() {
|
||||
o.insert("results_cleared".into(), json!(results));
|
||||
}
|
||||
}
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
||||
pub const CLEAR: crate::CommandSpec = crate::CommandSpec {
|
||||
id: "mesh.clear",
|
||||
label: "Clear Mesh",
|
||||
params: r#"{"bodies"?: ["Body1/a"]} — removes the meshes of the bodies (their elements and the nodes only they use; sets that listed them are named as `stale_sets`, results solved on them are cleared); without `bodies`, of every body the open design no longer has because its partitions changed since it was meshed (model.check lists them; none: nothing changes). Reports what went as `replaced`"#,
|
||||
form: Some(super::forms::CLEAR_MESH),
|
||||
kind: crate::CommandKind::Model,
|
||||
requires: &[crate::requirement::Requirement::Mesh],
|
||||
run: clear_meshes,
|
||||
};
|
||||
|
||||
fn clear_meshes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bodies: Vec<String> = match p.get("bodies") {
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
None => s.design.as_deref().map(|d| stale(&s.model, d).into_iter().map(|(b, _)| b).collect()).unwrap_or_default(),
|
||||
};
|
||||
for b in &bodies {
|
||||
if s.model.body_elements(b).is_empty() {
|
||||
return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(64).collect::<String>())));
|
||||
}
|
||||
}
|
||||
let cleared = clear(&mut s.model, bodies.iter().map(String::as_str));
|
||||
let (nodes, elements) = (s.model.nodes().len(), s.model.elements().len());
|
||||
Ok(cleared.report(s, json!({"nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// The meshed bodies the open design no longer has because its partitions changed (#385): cells
|
||||
/// of a partition that is gone (`Base/a/b`), or a body partitions now cut (`Base`), each with
|
||||
/// its element count. Other bodies the design lacks (mirror images, imported surfaces, blocks)
|
||||
/// are not stale.
|
||||
pub(crate) fn stale(model: &Model, design: &fieldcraft_geometry::Design) -> Vec<(String, usize)> {
|
||||
let cell = |name: &str| -> bool {
|
||||
let Some(source) = design.source_of(name) else { return false };
|
||||
let rest = name.get(source.len()..).unwrap_or_default();
|
||||
rest.split('/').skip(1).all(|seg| seg.strip_prefix(['a', 'b']).is_some_and(|k| k.bytes().all(|c| c.is_ascii_digit())))
|
||||
};
|
||||
model
|
||||
.classification()
|
||||
.entities()
|
||||
.iter()
|
||||
.filter(|g| matches!(g.entity, fieldcraft_model::geometry::Entity::Body))
|
||||
.map(|g| g.body.as_str())
|
||||
.filter(|b| design.body(b).is_none() && cell(b))
|
||||
.filter_map(|b| {
|
||||
let n = model.body_elements(b).len();
|
||||
(n > 0).then(|| (b.to_string(), n))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The bodies a meshing command fills, from its parameters, without meshing anything.
|
||||
pub type Targets = fn(&Session, &Value) -> Result<Vec<String>>;
|
||||
|
||||
/// Every command that fills bodies with a mesh and replaces theirs, with the bodies it fills.
|
||||
pub static MESHERS: &[(&str, Targets)] = &[
|
||||
("mesh.tet.generate", body_or_all),
|
||||
("mesh.hex.generate", body_or_all),
|
||||
("mesh.hex.sweep", body_or_all),
|
||||
("mesh.hex.mapped", body_or_all),
|
||||
("mesh.hex.octree", body_or_all),
|
||||
("mesh.hex.blocks", listed_or_all),
|
||||
("mesh.cells", listed_or_all),
|
||||
("mesh.planar", super::mesh::planar_body),
|
||||
("mesh.mirror", super::mirror::image_names),
|
||||
];
|
||||
|
||||
/// `body`, the imported `surface`, or every body of the open design.
|
||||
fn body_or_all(s: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
match (p.get("body"), p.get("surface")) {
|
||||
(Some(b), None) => Ok(vec![b.as_str().ok_or_else(|| EngineError::param("body", crate::schema::ProblemKind::NotAName))?.to_string()]),
|
||||
(None, Some(_)) => Ok(vec![super::hex::surface(s, p)?.0]),
|
||||
(None, None) => design_bodies(s),
|
||||
_ => Err(EngineError::Params("give `body` or `surface`, not both".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bodies`, or every body of the open design.
|
||||
fn listed_or_all(s: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
match p.get("bodies") {
|
||||
Some(_) => super::req(p, "bodies"),
|
||||
None => design_bodies(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn design_bodies(s: &Session) -> Result<Vec<String>> {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
Ok(design.bodies.iter().map(|b| b.name.clone()).collect())
|
||||
}
|
||||
|
||||
/// Tetrahedra and hexahedra of either order: the kinds an order change swaps.
|
||||
fn family(k: ElementKind) -> Option<u8> {
|
||||
match k {
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => Some(0),
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => Some(1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The solid element type `command` makes with `params`, when it makes one type.
|
||||
fn made_kind(command: &str, params: &Value) -> Option<ElementKind> {
|
||||
match command {
|
||||
"mesh.tet.generate" => {
|
||||
Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 })
|
||||
}
|
||||
"mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" => {
|
||||
Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What running a meshing command would replace, for a confirmation before it runs.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Replacement {
|
||||
/// "CrankArm already has a mesh (7,274 hex20). Replace it?"
|
||||
pub question: String,
|
||||
/// What else goes with the old mesh: sets that list its labels, solved results.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `command` with `params` would replace: `None` when it is not a mesher, its parameters
|
||||
/// do not name bodies, or none of its bodies has a mesh.
|
||||
pub fn replacement(s: &Session, command: &str, params: &Value) -> Option<Replacement> {
|
||||
let targets = MESHERS.iter().find(|(id, _)| *id == command)?.1;
|
||||
let bodies = targets(s, params).ok()?;
|
||||
let mut meshed = Vec::new();
|
||||
let mut removed = BodyMesh::default();
|
||||
for body in bodies.iter().collect::<BTreeSet<_>>() {
|
||||
let mesh = s.model.body_mesh(body);
|
||||
if mesh.elements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut counts: BTreeMap<ElementKind, usize> = BTreeMap::new();
|
||||
for e in mesh.elements.iter().filter_map(|l| s.model.element(*l)) {
|
||||
*counts.entry(e.kind).or_default() += 1;
|
||||
}
|
||||
let kinds: Vec<String> = ElementKind::ALL.iter().filter_map(|k| counts.get(k).map(|n| format!("{} {}", thousands(*n), k.name()))).collect();
|
||||
meshed.push((body.as_str(), kinds.join(", ")));
|
||||
removed.elements.extend(mesh.elements);
|
||||
removed.nodes.extend(mesh.nodes);
|
||||
}
|
||||
let question = match meshed.as_slice() {
|
||||
[] => return None,
|
||||
[(body, counts)] => format!("{body} already has a mesh ({counts}). Replace it?"),
|
||||
many => {
|
||||
let list: Vec<String> = many.iter().map(|(body, counts)| format!("{body} ({counts})")).collect();
|
||||
format!("{} already have meshes. Replace them?", list.join(", "))
|
||||
}
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
// The element type changing (tet10 → tet4 when the order is left out) is easy to miss.
|
||||
if let Some(new) = made_kind(command, params) {
|
||||
let old: BTreeSet<ElementKind> = removed.elements.iter().filter_map(|l| s.model.element(*l)).map(|e| e.kind).collect();
|
||||
let changed: Vec<&str> = old.iter().filter(|k| **k != new && family(**k) == family(new)).map(|k| k.name()).collect();
|
||||
if !changed.is_empty() {
|
||||
warnings.push(format!("The element type changes: {} → {}.", changed.join(", "), new.name()));
|
||||
}
|
||||
}
|
||||
let stale = s.model.sets_using(&removed);
|
||||
if !stale.is_empty() {
|
||||
warnings.push(format!("Sets that list its nodes or elements by label ({}) will name labels of the old mesh.", stale.join(", ")));
|
||||
}
|
||||
let results: Vec<&str> = s.solved_steps().collect();
|
||||
if !results.is_empty() {
|
||||
warnings.push(format!("The results of {} will be cleared.", results.join(", ")));
|
||||
}
|
||||
Some(Replacement { question, warnings })
|
||||
}
|
||||
|
||||
/// `n` with thousands separators: 7274 → "7,274".
|
||||
pub fn thousands(n: usize) -> String {
|
||||
let digits = n.to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_model::Element;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_mesher_is_a_command() {
|
||||
for (id, _) in MESHERS {
|
||||
assert!(crate::find_command(id).is_some(), "{id}");
|
||||
}
|
||||
assert_eq!(thousands(7274), "7,274");
|
||||
assert_eq!(thousands(999), "999");
|
||||
assert_eq!(thousands(1_000_000), "1,000,000");
|
||||
}
|
||||
|
||||
/// A 20 × 10 × 10 box split at x = 10, opened as `Box.1` (x < 10) and `Box.2`.
|
||||
fn two_boxes() -> (crate::tests::TempDir, Session) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}},
|
||||
{"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Remeshing without the order would turn tet10 into tet4: the question says so (#343).
|
||||
#[test]
|
||||
fn an_element_type_change_is_named() {
|
||||
let (_dir, mut s) = two_boxes();
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 5, "order": "quadratic"})).unwrap();
|
||||
let r = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w == "The element type changes: tet10 → tet4."), "{r:?}");
|
||||
let same = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "order": "quadratic"})).unwrap();
|
||||
assert!(same.warnings.iter().all(|w| !w.contains("element type")), "{same:?}");
|
||||
}
|
||||
|
||||
/// A body's elements, by label.
|
||||
fn elements_of(s: &Session, body: &str) -> BTreeMap<u32, Element> {
|
||||
s.model.body_elements(body).into_iter().filter_map(|l| Some((l, s.model.element(l)?.clone()))).collect()
|
||||
}
|
||||
|
||||
/// Every mesher in [`MESHERS`], run twice on a body: the second run replaces the first (as
|
||||
/// many elements as a fresh mesh, reported as `replaced`), leaves the other body's mesh as it
|
||||
/// was, names the set that listed the old labels, and one undo brings the first mesh back.
|
||||
#[test]
|
||||
fn every_mesher_replaces_the_mesh_of_its_body_only() {
|
||||
let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box.2", "size": 5}});
|
||||
let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box.1", "size": 2.5}});
|
||||
let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]);
|
||||
let cases: Vec<(&str, Vec<Value>, Value, &str, &str)> = vec![
|
||||
("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 4}), "Box.1", "Box.2"),
|
||||
("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5, "default": "hex"}), "Box.1", "Box.2"),
|
||||
("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"),
|
||||
// The image's tets (meshed before) are replaced by the mirrored hexahedra.
|
||||
(
|
||||
"mesh.mirror",
|
||||
vec![hex_box, tet_other],
|
||||
json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box.1"]}),
|
||||
"Box.2",
|
||||
"Box.1",
|
||||
),
|
||||
];
|
||||
let covered: BTreeSet<&str> = cases.iter().map(|c| c.0).collect();
|
||||
assert_eq!(covered, MESHERS.iter().map(|m| m.0).collect::<BTreeSet<_>>(), "every mesher has a case");
|
||||
|
||||
let (_dir, fresh) = two_boxes();
|
||||
for (id, prep, params, body, other) in cases {
|
||||
let mut s = fresh.clone();
|
||||
for c in &prep {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
// What the session says it would replace matches what the command replaces.
|
||||
let first = s.execute(id, ¶ms).unwrap();
|
||||
let fresh_count = elements_of(&s, body).len();
|
||||
assert!(fresh_count > 0, "{id}: {first}");
|
||||
let others = elements_of(&s, other);
|
||||
assert!(!others.is_empty(), "{id}");
|
||||
let some: Vec<u32> = elements_of(&s, body).keys().take(3).copied().collect();
|
||||
s.execute("model.set.create", &json!({"name": "by_label", "elements": some})).unwrap();
|
||||
let asked = replacement(&s, id, ¶ms).unwrap_or_else(|| panic!("{id}: no question"));
|
||||
assert!(asked.question.starts_with(&format!("{body} already has a mesh (")), "{id}: {asked:?}");
|
||||
assert!(asked.warnings.iter().any(|w| w.contains("by_label")), "{id}: {asked:?}");
|
||||
|
||||
let before = s.model.clone();
|
||||
let second = s.execute(id, ¶ms).unwrap();
|
||||
assert_eq!(elements_of(&s, body).len(), fresh_count, "{id}: {second}");
|
||||
assert_eq!(elements_of(&s, other), others, "{id}: the other body's mesh is untouched");
|
||||
assert_eq!(
|
||||
second["replaced"],
|
||||
json!([{"body": body, "elements": fresh_count, "nodes": second["replaced"][0]["nodes"]}]),
|
||||
"{id}: {second}"
|
||||
);
|
||||
assert_eq!(second["stale_sets"], json!(["by_label"]), "{id}: {second}");
|
||||
// No node is left that no element uses.
|
||||
let used: BTreeSet<u32> = s.model.elements().values().flat_map(|e| e.nodes.iter().copied()).collect();
|
||||
assert!(s.model.nodes().keys().all(|n| used.contains(n)), "{id}: orphan nodes");
|
||||
|
||||
// A mesher is deterministic: the same mesh again is the same model, and no undo step.
|
||||
if s.model != before {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, before, "{id}: undo restores the first mesh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tets_over_hexahedra_leave_only_tets() {
|
||||
let (_dir, mut s) = two_boxes();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap();
|
||||
let hexes = elements_of(&s, "Box.1").len();
|
||||
let other = elements_of(&s, "Box.2");
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap();
|
||||
assert_eq!(r["replaced"][0]["elements"], hexes, "{r}");
|
||||
assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Tet4));
|
||||
assert_eq!(elements_of(&s, "Box.2"), other);
|
||||
let tets = s.model.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(elements_of(&s, "Box.1").len(), hexes, "one undo brings the hexahedra back");
|
||||
assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Hex8));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, tets);
|
||||
// Meshing every body replaces both.
|
||||
let r = s.execute("mesh.tet.generate", &json!({"size": 4})).unwrap();
|
||||
assert_eq!(r["replaced"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(s.model.elements().values().all(|e| e.kind == ElementKind::Tet4));
|
||||
// A body without a mesh asks nothing.
|
||||
let (_dir2, s2) = two_boxes();
|
||||
assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box.1"})), None);
|
||||
assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box.1"})), None, "not a mesher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_solved_on_the_old_mesh_are_cleared_and_undo_brings_them_back() {
|
||||
let mut s = Session::default();
|
||||
let corners = json!([[0, 0, 0], [10, 0, 0], [10, 1, 0], [0, 1, 0], [0, 0, 1], [10, 0, 1], [10, 1, 1], [0, 1, 1]]);
|
||||
s.run_script(&json!([
|
||||
{"command": "mesh.block", "params": {"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [10, 1, 1], "faces": {"6": "root", "4": "tip"}}},
|
||||
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Bar"}]}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "Bar", "face": "root"}]}},
|
||||
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": "Bar", "face": "tip"}]}},
|
||||
{"command": "model.material.create", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000.0, "poisson": 0.0}}},
|
||||
{"command": "model.section.create", "params": {"name": "all", "set": "all", "material": "Mat", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Pull", "type": "force", "target": {"set": "tip"}, "vector": [1, 0, 0]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Pull"]}}
|
||||
]))
|
||||
.unwrap();
|
||||
s.execute("solve.static", &json!({"step": "Load"})).unwrap();
|
||||
assert!(s.result("Load").is_some());
|
||||
let asked = replacement(&s, "mesh.planar", &json!({"polygon": [[0, 0], [10, 0], [10, 1], [0, 1]], "size": 1, "name": "Bar"})).unwrap();
|
||||
assert_eq!(asked.question, "Bar already has a mesh (10 hex8). Replace it?");
|
||||
assert!(asked.warnings.iter().any(|w| w.contains("results of Load")), "{asked:?}");
|
||||
let r = s.execute("mesh.planar", &json!({"polygon": [[0, 0], [10, 0], [10, 1], [0, 1]], "size": 1, "name": "Bar"})).unwrap();
|
||||
assert_eq!(r["results_cleared"], json!(["Load"]), "{r}");
|
||||
assert!(s.result("Load").is_none());
|
||||
assert!(s.model.elements().values().all(|e| e.kind.dimension() == 2));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.result("Load").is_some(), "undo brings the solved state back");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
//! animation) and reaction sums. The display is session state ([`ResultsView`]): these
|
||||
//! commands are not edits, so they are not journalled or undone.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, xyz};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
@@ -14,15 +16,17 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "show": true, "scale": "auto"} — shows a solved step's deformed shape (`show: false` the undeformed mesh); `scale` is a factor (0 draws the undeformed shape, so a sequence from 0 swings it out) or "auto" (the largest displacement drawn a tenth of the model's size); `step` defaults to the step shown or the first solved one"#,
|
||||
form: Some(super::forms::DEFORMED),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: deformed,
|
||||
};
|
||||
|
||||
pub const CONTOUR: CommandSpec = CommandSpec {
|
||||
id: "results.contour",
|
||||
label: "Contours",
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: contour,
|
||||
};
|
||||
|
||||
@@ -32,6 +36,7 @@ pub const MODES: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Modes", "mode": 1} — shows a mode shape of a solved frequency step (from 1) and lists every mode's frequency"#,
|
||||
form: Some(super::forms::MODES),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: modes,
|
||||
};
|
||||
|
||||
@@ -41,6 +46,7 @@ pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
|
||||
form: Some(super::forms::ANIMATE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: animate,
|
||||
};
|
||||
|
||||
@@ -50,24 +56,37 @@ pub const FRAME: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
|
||||
form: Some(super::forms::FRAME),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: frame,
|
||||
};
|
||||
|
||||
pub const REACTIONS: CommandSpec = CommandSpec {
|
||||
id: "results.reactions",
|
||||
label: "Reaction Sums",
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` defaults to the sets the step's boundary conditions hold"#,
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` (none or empty) defaults to the sets the step's boundary conditions hold"#,
|
||||
form: Some(super::forms::REACTIONS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: reactions,
|
||||
};
|
||||
|
||||
pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
||||
id: "results.export.csv",
|
||||
label: "Export Results (CSV)",
|
||||
params: r#"{"what": "probe" | "reactions", "path": "tip.csv", "step": "Load", …} — writes probe or reaction results as a CSV file (RFC 4180, a header line, numbers in shortest round-trip form); the other parameters are solve.probe's (`nodes`, `at` or `set`, `mode`, `frame`) or results.reactions' (`sets`, `about`). Probe: a row per node with node, x, y, z and the quantities the step has; reactions: a row per set, a total row and an applied row. Replies with the path, rows, columns, step and units"#,
|
||||
form: Some(super::forms::EXPORT_CSV),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: export_csv,
|
||||
};
|
||||
|
||||
pub const CLEAR: CommandSpec = CommandSpec {
|
||||
id: "results.clear",
|
||||
label: "Hide Results",
|
||||
params: "{} — back to the model: hides the deformed shape, contours and modes",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::ResultsShown],
|
||||
run: clear,
|
||||
};
|
||||
|
||||
@@ -165,7 +184,7 @@ fn deformed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = step_for(s, p, Want::Any)?;
|
||||
let name = opt::<String>(p, "field")?.ok_or_else(|| EngineError::Params("`field` is required (a field id, or \"none\")".into()))?;
|
||||
let name = opt::<String>(p, "field")?.ok_or_else(|| EngineError::param("field", ProblemKind::Required))?;
|
||||
let field = match name.as_str() {
|
||||
"none" => None,
|
||||
id => Some(Field::parse(id).ok_or_else(|| {
|
||||
@@ -187,14 +206,52 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
|
||||
}
|
||||
let range = match p.get("range") {
|
||||
None => None,
|
||||
Some(r) => Some(legend_range(r)?),
|
||||
};
|
||||
let v = view_of(s, &step)?;
|
||||
if v.contour != field {
|
||||
(v.range_min, v.range_max) = (None, None);
|
||||
}
|
||||
v.contour = field;
|
||||
if let Some(b) = bands {
|
||||
v.bands = b;
|
||||
}
|
||||
if let Some((lo, hi)) = range {
|
||||
(v.range_min, v.range_max) = (lo, hi);
|
||||
}
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
/// `results.contour`'s `range`: `"auto"` or null (follow the field), or `{min?, max?}`, each a
|
||||
/// finite number, null or `"auto"`; `min` below `max`.
|
||||
fn legend_range(r: &Value) -> Result<(Option<f64>, Option<f64>)> {
|
||||
let bound = |k: &str| -> Result<Option<f64>> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(a)) if a == "auto" => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| EngineError::Params(format!("`range.{k}` must be a number or \"auto\"")))
|
||||
}
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Value::Null => Ok((None, None)),
|
||||
Value::String(a) if a == "auto" => Ok((None, None)),
|
||||
Value::Object(_) => {
|
||||
let (lo, hi) = (bound("min")?, bound("max")?);
|
||||
if let (Some(a), Some(b)) = (lo, hi)
|
||||
&& a >= b
|
||||
{
|
||||
return Err(EngineError::Params(format!("`range.min` ({a}) must be below `range.max` ({b})")));
|
||||
}
|
||||
Ok((lo, hi))
|
||||
}
|
||||
_ => Err(EngineError::Params("`range` must be {\"min\"?, \"max\"?} or \"auto\"".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn modes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = step_for(s, p, Want::Frequency)?;
|
||||
let count = match s.latest_result(&step).map(|(r, _)| r.kind()) {
|
||||
@@ -247,7 +304,7 @@ fn frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(t)) if t.is_finite() => {
|
||||
times.iter().enumerate().min_by(|a, b| (a.1 - t).abs().total_cmp(&(b.1 - t).abs())).map_or(last, |(i, _)| i)
|
||||
}
|
||||
(None, Some(_)) => return Err(EngineError::Params("`time` must be a number".into())),
|
||||
(None, Some(_)) => return Err(EngineError::param("time", ProblemKind::NotANumber)),
|
||||
(None, None) => last,
|
||||
};
|
||||
let v = view_of(s, &step)?;
|
||||
@@ -264,7 +321,7 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = step_for(s, p, Want::Any)?;
|
||||
let about = if p.get("about").is_some() { xyz(p, "about")? } else { [0.0; 3] };
|
||||
if !about.iter().all(|c| c.is_finite()) {
|
||||
return Err(EngineError::Params("`about` must be a finite [x, y, z]".into()));
|
||||
return Err(EngineError::param("about", ProblemKind::NotAVector { len: 3 }));
|
||||
}
|
||||
let Some((result, current)) = s.latest_result(&step) else { return Err(EngineError::Params(solved_list(s))) };
|
||||
let Kind::Static(r) = result.kind() else {
|
||||
@@ -272,14 +329,15 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"step `{step}` is not a static step: reactions are kept for static steps (explicit steps keep the constraint force history, solve.history)"
|
||||
)));
|
||||
};
|
||||
let sets: Vec<String> = match p.get("sets") {
|
||||
// No sets (or an empty list, a form with none ticked): the step's boundary-condition sets.
|
||||
let sets: Vec<String> = match p.get("sets").filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) {
|
||||
Some(v) => {
|
||||
let list = v
|
||||
.as_array()
|
||||
.filter(|a| a.len() <= MAX_SETS)
|
||||
.ok_or_else(|| EngineError::Params(format!("`sets` must be a list of at most {MAX_SETS} set names")))?;
|
||||
list.iter()
|
||||
.map(|n| n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`sets` must be set names".into())))
|
||||
.map(|n| n.as_str().map(str::to_string).ok_or_else(|| EngineError::param("sets", ProblemKind::NotNames)))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
None => {
|
||||
@@ -321,6 +379,171 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
|
||||
}
|
||||
|
||||
fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what: String = super::req(p, "what")?;
|
||||
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
||||
let (step, table) = match what.as_str() {
|
||||
"probe" => {
|
||||
let r = super::solve::probe(s, p)?;
|
||||
(r.get("step").cloned().unwrap_or(Value::Null), probe_table(&r, &|label| s.model.node(label).map(|n| n.xyz))?)
|
||||
}
|
||||
"reactions" => {
|
||||
let r = reactions(s, p)?;
|
||||
if r.get("stale").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let step = r.get("step").and_then(Value::as_str).unwrap_or_default();
|
||||
return Err(EngineError::Params(format!(
|
||||
"the result of step `{step}` is stale (the model changed since it was solved); solve it again"
|
||||
)));
|
||||
}
|
||||
(r.get("step").cloned().unwrap_or(Value::Null), reactions_table(&r))
|
||||
}
|
||||
_ => return Err(EngineError::Params("`what` must be \"probe\" or \"reactions\"".into())),
|
||||
};
|
||||
let text = table.csv();
|
||||
fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), text.as_bytes(), false)?;
|
||||
Ok(json!({"path": path, "rows": table.rows.len(), "columns": table.header, "step": step, "units": s.model.units()}))
|
||||
}
|
||||
|
||||
/// A table of cells (a number, text, or empty) under a header, written as CSV.
|
||||
struct Table {
|
||||
header: Vec<String>,
|
||||
rows: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Cell {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// RFC 4180: comma-separated, CRLF line ends, a field with a comma, quote or line break in
|
||||
/// quotes (its quotes doubled); numbers in Rust's shortest round-trip form.
|
||||
fn csv(&self) -> String {
|
||||
let field = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
let mut out = String::new();
|
||||
let line = |cells: Vec<String>, out: &mut String| {
|
||||
out.push_str(&cells.join(","));
|
||||
out.push_str("\r\n");
|
||||
};
|
||||
line(self.header.iter().map(|h| field(h)).collect(), &mut out);
|
||||
for row in &self.rows {
|
||||
let cells = row
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Cell::Number(v) => format!("{v}"),
|
||||
Cell::Text(t) => field(t),
|
||||
Cell::Empty => String::new(),
|
||||
})
|
||||
.collect();
|
||||
line(cells, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rows of named cells into a table whose columns are every name that has a value, in the
|
||||
/// order first met; a column no row has a value in is left out.
|
||||
fn from_named(rows: Vec<Vec<(String, Cell)>>) -> Table {
|
||||
let mut header: Vec<String> = Vec::new();
|
||||
for row in &rows {
|
||||
for (name, c) in row {
|
||||
if *c != Cell::Empty && !header.contains(name) {
|
||||
header.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let rows = rows
|
||||
.into_iter()
|
||||
.map(|row| header.iter().map(|h| row.iter().find(|(n, _)| n == h).map_or(Cell::Empty, |(_, c)| c.clone())).collect())
|
||||
.collect();
|
||||
Table { header, rows }
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSV columns of a quantity of a probe's node entry: a vector's components (and a
|
||||
/// displacement's magnitude), a stress tensor's six components, a scalar under its own name.
|
||||
fn quantity_cells(key: &str, v: &Value, out: &mut Vec<(String, Cell)>) {
|
||||
const VECTORS: [(&str, &[&str]); 7] = [
|
||||
("displacement", &["ux", "uy", "uz"]),
|
||||
("rotation", &["rx", "ry", "rz"]),
|
||||
("reaction", &["rfx", "rfy", "rfz"]),
|
||||
("reaction_moment", &["rmx", "rmy", "rmz"]),
|
||||
("velocity", &["vx", "vy", "vz"]),
|
||||
("shape", &["ux", "uy", "uz", "rx", "ry", "rz"]),
|
||||
("stress", &["s_xx", "s_yy", "s_zz", "s_xy", "s_xz", "s_yz"]),
|
||||
];
|
||||
match v {
|
||||
Value::Number(n) => out.push((key.to_string(), n.as_f64().map_or(Cell::Empty, Cell::Number))),
|
||||
Value::Array(items) if items.iter().all(Value::is_number) => {
|
||||
let nums: Vec<f64> = items.iter().filter_map(Value::as_f64).collect();
|
||||
let names: Vec<String> = match VECTORS.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, n)) if n.len() == nums.len() => n.iter().map(|s| (*s).to_string()).collect(),
|
||||
_ => (1..=nums.len()).map(|i| format!("{key}_{i}")).collect(),
|
||||
};
|
||||
out.extend(names.into_iter().zip(nums.iter().map(|&x| Cell::Number(x))));
|
||||
if key == "displacement" && nums.len() == 3 {
|
||||
out.push(("u_mag".into(), Cell::Number(nums.iter().map(|x| x * x).sum::<f64>().sqrt())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `solve.probe`'s reply as a table: a row per node with its label, undeformed position and
|
||||
/// the quantities the step has.
|
||||
fn probe_table(r: &Value, position: &dyn Fn(u32) -> Option<[f64; 3]>) -> Result<Table> {
|
||||
let nodes = r.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("the probe has no node results to export".into()))?;
|
||||
let rows = nodes
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let label = e.get("node").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok());
|
||||
let mut row = vec![("node".to_string(), label.map_or(Cell::Empty, |l| Cell::Number(f64::from(l))))];
|
||||
if let Some(name) = e.get("reference_point").and_then(Value::as_str) {
|
||||
row.push(("reference_point".into(), Cell::Text(name.to_string())));
|
||||
}
|
||||
let xyz = label.and_then(position);
|
||||
for (k, axis) in ["x", "y", "z"].iter().enumerate() {
|
||||
row.push(((*axis).to_string(), xyz.and_then(|p| p.get(k).copied()).map_or(Cell::Empty, Cell::Number)));
|
||||
}
|
||||
if let Some(o) = e.as_object() {
|
||||
for (k, v) in o.iter().filter(|(k, _)| !matches!(k.as_str(), "node" | "reference_point" | "xyz" | "distance")) {
|
||||
quantity_cells(k, v, &mut row);
|
||||
}
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok(Table::from_named(rows))
|
||||
}
|
||||
|
||||
/// `results.reactions`' reply as a table: a row per set, the total and the applied load.
|
||||
fn reactions_table(r: &Value) -> Table {
|
||||
let row = |name: &str, e: &Value| {
|
||||
let mut row = vec![("set".to_string(), Cell::Text(name.to_string()))];
|
||||
row.push(("nodes".into(), e.get("nodes").and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number)));
|
||||
for (key, names) in [("force", ["fx", "fy", "fz"]), ("moment", ["mx", "my", "mz"])] {
|
||||
let v = e.get(key).and_then(Value::as_array);
|
||||
for (k, n) in names.iter().enumerate() {
|
||||
row.push(((*n).to_string(), v.and_then(|a| a.get(k)).and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number)));
|
||||
}
|
||||
}
|
||||
row
|
||||
};
|
||||
let mut rows: Vec<Vec<(String, Cell)>> = r
|
||||
.get("sets")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| row(e.get("set").and_then(Value::as_str).unwrap_or_default(), e))
|
||||
.collect();
|
||||
rows.push(row("total", r.get("total").unwrap_or(&Value::Null)));
|
||||
if let Some(applied) = r.get("applied").filter(|a| a.is_array()) {
|
||||
rows.push(row("applied", &json!({"force": applied})));
|
||||
}
|
||||
Table::from_named(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -334,12 +557,109 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// `range` clamps the legend (#191): kept with the field, cleared by "auto" or another
|
||||
/// field, reported in the reply; the true range is still there.
|
||||
/// A CSV reader (RFC 4180) for the tests: rows of fields, quotes undone.
|
||||
fn read_csv(text: &str) -> Vec<Vec<String>> {
|
||||
let (mut rows, mut row, mut field, mut quoted, mut chars) = (Vec::new(), Vec::new(), String::new(), false, text.chars().peekable());
|
||||
while let Some(c) = chars.next() {
|
||||
match (quoted, c) {
|
||||
(true, '"') if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
field.push('"');
|
||||
}
|
||||
(true, '"') => quoted = false,
|
||||
(true, c) => field.push(c),
|
||||
(false, '"') => quoted = true,
|
||||
(false, ',') => row.push(std::mem::take(&mut field)),
|
||||
(false, '\r') => {}
|
||||
(false, '\n') => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
(false, c) => field.push(c),
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn temp_csv(tag: &str) -> String {
|
||||
std::env::temp_dir().join(format!("fieldcraft-csv-{tag}-{}.csv", std::process::id())).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `results.export.csv` writes what results.reactions and solve.probe reply, bit for bit
|
||||
/// after parsing; a set name with a comma and a quote round-trips (#92).
|
||||
#[test]
|
||||
fn results_export_as_csv() {
|
||||
let mut s = solved();
|
||||
let path = temp_csv("reactions");
|
||||
let sets = json!(["root", "tip"]);
|
||||
let reply = s.execute("results.export.csv", &json!({"what": "reactions", "path": path, "step": "Bend", "sets": sets})).unwrap();
|
||||
let sums = s.execute("results.reactions", &json!({"step": "Bend", "sets": sets})).unwrap();
|
||||
let rows = read_csv(&std::fs::read_to_string(&path).unwrap());
|
||||
assert_eq!(rows[0], ["set", "nodes", "fx", "fy", "fz", "mx", "my", "mz"]);
|
||||
assert_eq!((reply["rows"].as_u64(), reply["step"].as_str()), (Some(4), Some("Bend")), "{reply}");
|
||||
// A name with a comma and a quote round-trips through a CSV reader.
|
||||
let named =
|
||||
Table { header: vec!["set".into(), "fx".into()], rows: vec![vec![Cell::Text("the \"root\", clamped".into()), Cell::Number(0.1)]] };
|
||||
assert_eq!(read_csv(&named.csv()), [vec!["set", "fx"], vec!["the \"root\", clamped", "0.1"]]);
|
||||
let total = rows.iter().find(|r| r[0] == "total").unwrap();
|
||||
assert_eq!(total[4].parse::<f64>().unwrap(), sums["total"]["force"][2].as_f64().unwrap(), "bit for bit");
|
||||
let applied = rows.iter().find(|r| r[0] == "applied").unwrap();
|
||||
assert_eq!((applied[1].as_str(), applied[5].as_str()), ("", ""), "the applied load has no nodes or moments");
|
||||
let path = temp_csv("probe");
|
||||
s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "nodes": [1, 2, 3]})).unwrap();
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1, 2, 3]})).unwrap();
|
||||
let rows = read_csv(&std::fs::read_to_string(&path).unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
let col = |name: &str| rows[0].iter().position(|h| h == name).unwrap_or_else(|| panic!("no {name}: {:?}", rows[0]));
|
||||
for (i, node) in probe["nodes"].as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(rows[i + 1][col("node")], node["node"].to_string());
|
||||
assert_eq!(rows[i + 1][col("uz")].parse::<f64>().unwrap(), node["displacement"][2].as_f64().unwrap());
|
||||
assert_eq!(rows[i + 1][col("s_yz")].parse::<f64>().unwrap(), node["stress"][5].as_f64().unwrap());
|
||||
assert_eq!(rows[i + 1][col("von_mises")].parse::<f64>().unwrap(), node["von_mises"].as_f64().unwrap());
|
||||
}
|
||||
assert!(!rows[0].iter().any(|h| h == "temperature"), "columns the step does not have are left out: {:?}", rows[0]);
|
||||
// A stale result is refused.
|
||||
// Every parameter the description names is taken: a set's nodes, points, a moment point.
|
||||
let set = s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "set": "tip"})).unwrap();
|
||||
assert!(set["rows"].as_u64().unwrap_or(0) > 0, "{set}");
|
||||
s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "at": [[0, 0, 0]]})).unwrap();
|
||||
s.execute("results.export.csv", &json!({"what": "reactions", "path": path, "step": "Bend", "about": [1, 0, 0]})).unwrap();
|
||||
s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 0]})).unwrap();
|
||||
assert!(s.execute("results.export.csv", &json!({"what": "reactions", "path": temp_csv("stale"), "step": "Bend"})).is_err());
|
||||
for p in ["reactions", "probe"] {
|
||||
let _ = std::fs::remove_file(temp_csv(p));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legend_range_can_be_clamped() {
|
||||
let mut s = solved();
|
||||
let r = s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": 10}})).unwrap();
|
||||
let top = r["contour"]["range"]["max"]["value"].as_f64().unwrap();
|
||||
assert!(top > 10.0, "{r}");
|
||||
assert_eq!(r["contour"]["legend"], json!({"min": null, "max": 10.0}), "{r}");
|
||||
let v = s.results_view.clone().unwrap();
|
||||
let b = v.bands(0.0, top);
|
||||
assert!(b.over && b.max == 10.0 && b.band(top) == Some(b.count));
|
||||
s.execute("results.contour", &json!({"field": "von_mises", "bands": 6})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().unwrap().range_max, Some(10.0), "kept with the field");
|
||||
s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": "auto", "min": 2}})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((Some(2.0), None)));
|
||||
s.execute("results.contour", &json!({"field": "displacement"})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((None, None)), "another field follows its own range");
|
||||
s.execute("results.contour", &json!({"field": "displacement", "range": {"max": 1e-3}})).unwrap();
|
||||
let r = s.execute("results.contour", &json!({"field": "displacement", "range": "auto"})).unwrap();
|
||||
assert!(r["contour"].get("legend").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_display_follows_the_commands_and_is_not_an_edit() {
|
||||
let mut s = solved();
|
||||
let (journal, revision) = (s.journal.len(), s.revision());
|
||||
// Solving showed the first solved step.
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.step.as_str(), v.contour)), Some(("Bend", Some(Field::VonMises))));
|
||||
// Solving showed the first solved step, a static one: contours on the undeformed shape.
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.step.as_str(), v.contour, v.deformed)), Some(("Bend", Some(Field::VonMises), false)));
|
||||
s.execute("results.clear", &json!({})).unwrap();
|
||||
let r = s.execute("results.contour", &json!({"field": "von_mises"})).unwrap();
|
||||
assert_eq!(r["view"]["step"], "Bend", "{r}");
|
||||
@@ -356,10 +676,17 @@ mod tests {
|
||||
let r = s.execute("results.modes", &json!({"mode": 3})).unwrap();
|
||||
assert_eq!((r["view"]["step"].as_str(), r["view"]["mode"].as_u64()), (Some("Modes"), Some(3)));
|
||||
assert_eq!(r["modes"].as_array().map(Vec::len), Some(4));
|
||||
assert_eq!(s.results_view.as_ref().and_then(|v| v.contour), Some(Field::Displacement));
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.contour, v.deformed)), Some((Some(Field::Displacement), true)), "a mode shape is deformed");
|
||||
assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err());
|
||||
s.execute("results.contour", &json!({"field": "displacement", "bands": 6})).unwrap();
|
||||
s.execute("results.animate", &json!({})).unwrap();
|
||||
// Changing the field keeps the deformation, its scale and the animation (#332).
|
||||
s.execute("results.deformed", &json!({"scale": 3})).unwrap();
|
||||
let r = s.execute("results.contour", &json!({"step": "Modes", "field": "displacement_z"})).unwrap();
|
||||
assert_eq!(
|
||||
(r["view"]["deformed"].as_bool(), r["view"]["scale"].as_f64(), r["view"]["animate"].as_bool()),
|
||||
(Some(true), Some(3.0), Some(true))
|
||||
);
|
||||
assert!(s.results_view.as_ref().is_some_and(|v| v.animate && v.bands == 6));
|
||||
s.execute("results.animate", &json!({"on": false})).unwrap();
|
||||
assert!(s.results_view.as_ref().is_some_and(|v| !v.animate));
|
||||
@@ -389,6 +716,8 @@ mod tests {
|
||||
let r = s.execute("results.reactions", &json!({"sets": ["root", "tip"], "about": [10, 0, 0]})).unwrap();
|
||||
assert!(r["sets"][0]["moment"][1].as_f64().unwrap().abs() < 1e-6, "{r}");
|
||||
assert_eq!(r["sets"][1]["force"][2].as_f64(), Some(0.0));
|
||||
let empty = s.execute("results.reactions", &json!({"sets": []})).unwrap();
|
||||
assert_eq!(empty["sets"][0]["set"], "root", "an empty list sums the step's boundary-condition sets: {empty}");
|
||||
assert!(s.execute("results.reactions", &json!({"step": "Modes"})).is_err());
|
||||
assert!(s.execute("results.reactions", &json!({"sets": ["nope"]})).is_err());
|
||||
}
|
||||
@@ -431,6 +760,17 @@ mod tests {
|
||||
json!({"field": 3, "bands": 1e300}),
|
||||
json!({"field": "von_mises", "bands": 0}),
|
||||
json!({"field": "von_mises", "bands": 25}),
|
||||
json!({"field": "von_mises", "range": {"max": "high"}}),
|
||||
json!({"field": "von_mises", "range": {"min": 5, "max": 5}}),
|
||||
json!({"field": "von_mises", "range": {"min": 1e308, "max": -1e308}}),
|
||||
json!({"field": "von_mises", "range": 7}),
|
||||
json!({"what": 7, "path": "x.csv"}),
|
||||
json!({"what": "probe"}),
|
||||
json!({"what": "probe", "path": "", "nodes": [1]}),
|
||||
json!({"what": "probe", "path": "/no/such/dir/x.csv", "nodes": [1]}),
|
||||
json!({"what": "probe", "path": "x.csv", "nodes": vec![1; 1_000_000]}),
|
||||
json!({"what": "reactions", "path": "x.csv", "step": "Modes"}),
|
||||
json!({"what": "reactions", "path": "x.csv", "step": "Nope"}),
|
||||
json!({"scale": -1}),
|
||||
json!({"scale": -0.0001}),
|
||||
json!({"scale": 1e300}),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Edge seeds: `mesh.seed.edge` sets a count or a size (with bias and constraint) on design
|
||||
//! edges, `mesh.seed.clear` removes seeds and `mesh.seed.list` lists them with the count each
|
||||
//! gives on its edge. Seeds live in the model (`fieldcraft_model::seeds`), are saved with it, and
|
||||
//! the meshers honour them.
|
||||
|
||||
use fieldcraft_geometry::{Design, Edge};
|
||||
use fieldcraft_model::{Bias, Divisions, Entity, GeometryRef, Seed, SeedConstraint};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::opt;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// Most edges one command names.
|
||||
const MAX_EDGES: usize = 10_000;
|
||||
|
||||
pub const SEED_EDGE: CommandSpec = CommandSpec {
|
||||
id: "mesh.seed.edge",
|
||||
label: "Seed Edges",
|
||||
params: r#"{"edges": [{"body": "Body1", "edge": "F2:side:hi|F2:top"}, …], "count": 8 | "size": 2.5, "bias"?: {"ratio": 4, "toward": "start" | "end" | "both"}, "constraint"?: "exact" (default) | "min" | "free"} — seeds design edges (persistent edge names, geometry.bodies): `count` divisions, or `size` (the edge's length over it, rounded up). `bias` grades the elements geometrically, the largest `ratio` times the smallest, small at the edge's start, end or both ends. `constraint`: "exact" keeps the count (a mesher whose other edges cannot match refuses, naming the conflicting edges), "min" keeps at least it, "free" makes it a goal. Replaces a seed the edge had; an edge name without its `~k` number seeds every edge of that name. The hex meshers (sweep, mapped, blocks, cells) use the counts and spacing; the tet mesher uses the spacing on the edge and grades its size field from it"#,
|
||||
form: Some(super::forms::SEED_EDGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: seed_edge,
|
||||
};
|
||||
|
||||
pub const SEED_CLEAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.seed.clear",
|
||||
label: "Clear Seeds",
|
||||
params: r#"{"edges"?: [{"body", "edge"}, …]} — removes the seeds on these edges (by the names they were seeded with), or every seed"#,
|
||||
form: Some(super::forms::SEED_CLEAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Seed],
|
||||
run: seed_clear,
|
||||
};
|
||||
|
||||
pub const SEED_LIST: CommandSpec = CommandSpec {
|
||||
id: "mesh.seed.list",
|
||||
label: "Seeds",
|
||||
params: "{} — the edge seeds, each with the edges of the open design it seeds, their lengths and the count it gives each (`stale`: the design has no such edge)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: seed_list,
|
||||
};
|
||||
|
||||
/// The `edges` parameter: a list of edge references.
|
||||
fn edge_refs(p: &Value, required: bool) -> Result<Option<Vec<GeometryRef>>> {
|
||||
let refs: Option<Vec<GeometryRef>> = opt(p, "edges")?;
|
||||
let Some(refs) = refs else {
|
||||
return if required { Err(EngineError::Params("`edges` is required: a list of {body, edge}".into())) } else { Ok(None) };
|
||||
};
|
||||
if refs.is_empty() || refs.len() > MAX_EDGES {
|
||||
return Err(EngineError::Params(format!("`edges` must list 1 to {MAX_EDGES} edges")));
|
||||
}
|
||||
if let Some(r) = refs.iter().find(|r| !matches!(r.entity, Entity::Edge(_))) {
|
||||
return Err(EngineError::Params(format!("`edges` lists the {r}; name edges as {{body, edge}}")));
|
||||
}
|
||||
Ok(Some(refs))
|
||||
}
|
||||
|
||||
/// The edges of the design a reference selects (its `~k` pieces too).
|
||||
pub(crate) fn seeded_edges(design: &Design, r: &GeometryRef) -> Result<Vec<Edge>> {
|
||||
let Some(body) = design.body(&r.body) else { return Ok(Vec::new()) };
|
||||
Ok(body.edges()?.into_iter().filter(|e| GeometryRef::new(r.body.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| r.selects(&g))).collect())
|
||||
}
|
||||
|
||||
/// Every seed on the open design's edges, as the meshers take them. A mesher matches them to its
|
||||
/// own curves by position, so a seed on an edge two partition cells share holds in both.
|
||||
pub(crate) fn edge_seeds(s: &Session) -> Result<Vec<fieldcraft_mesh::seeds::EdgeSeed>> {
|
||||
let Some(design) = s.design.as_deref() else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
// Each body's edges, found once.
|
||||
let mut edges: std::collections::BTreeMap<&str, (f64, Vec<Edge>)> = std::collections::BTreeMap::new();
|
||||
for seed in s.model.seeds() {
|
||||
let Some(body) = design.body(&seed.edge.body) else { continue };
|
||||
let (tolerance, all) = match edges.entry(body.name.as_str()) {
|
||||
std::collections::btree_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::btree_map::Entry::Vacant(v) => {
|
||||
v.insert(((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4), body.edges()?))
|
||||
}
|
||||
};
|
||||
let selected = all.iter().filter(|e| GeometryRef::new(body.name.clone(), Entity::Edge(e.name.clone())).is_ok_and(|g| seed.edge.selects(&g)));
|
||||
for e in selected.cloned().collect::<Vec<_>>() {
|
||||
let tolerance = *tolerance;
|
||||
out.push(fieldcraft_mesh::seeds::EdgeSeed {
|
||||
name: format!("{}:{}", seed.edge.body, e.name),
|
||||
count: seed.count(e.length()) as usize,
|
||||
points: e.points,
|
||||
tolerance,
|
||||
bias: seed.bias,
|
||||
constraint: seed.constraint,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn design(s: &Session) -> Result<&Design> {
|
||||
s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first (geometry.open)".into()))
|
||||
}
|
||||
|
||||
/// A seed's report: its definition and, per edge it seeds, the length and the count it gives.
|
||||
fn report(seed: &Seed, edges: &[Edge]) -> Value {
|
||||
let mut v = json!(seed);
|
||||
v["on"] = edges.iter().map(|e| json!({"edge": e.name, "length": e.length(), "count": seed.count(e.length())})).collect();
|
||||
v["stale"] = json!(edges.is_empty());
|
||||
v
|
||||
}
|
||||
|
||||
fn seed_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let refs = edge_refs(p, true)?.unwrap_or_default();
|
||||
let divisions = match (opt::<u32>(p, "count")?, opt::<f64>(p, "size")?) {
|
||||
(Some(n), None) => Divisions::Count(n),
|
||||
(None, Some(h)) => Divisions::Size(h),
|
||||
_ => return Err(EngineError::Params("give one of `count` (divisions) or `size` (element length)".into())),
|
||||
};
|
||||
let bias: Option<Bias> = opt(p, "bias")?;
|
||||
let constraint: SeedConstraint = opt(p, "constraint")?.unwrap_or_default();
|
||||
let design = design(s)?.clone();
|
||||
let mut out = Vec::with_capacity(refs.len());
|
||||
for r in refs {
|
||||
let edges = seeded_edges(&design, &r)?;
|
||||
if edges.is_empty() {
|
||||
return Err(EngineError::Params(format!("the design has no {r} (geometry.bodies lists the edges)")));
|
||||
}
|
||||
let seed = Seed::new(r, divisions, bias, constraint)?;
|
||||
out.push(report(&seed, &edges));
|
||||
s.model.set_seed(seed)?;
|
||||
}
|
||||
Ok(json!({"seeded": out.len(), "seeds": out}))
|
||||
}
|
||||
|
||||
fn seed_clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cleared = match edge_refs(p, false)? {
|
||||
None => s.model.clear_seeds(),
|
||||
Some(refs) => {
|
||||
if let Some(r) = refs.iter().find(|r| s.model.seed(r).is_none()) {
|
||||
return Err(EngineError::Params(format!("the {r} has no seed")));
|
||||
}
|
||||
refs.iter().filter(|r| s.model.remove_seed(r)).count()
|
||||
}
|
||||
};
|
||||
Ok(json!({"cleared": cleared, "left": s.model.seeds().len()}))
|
||||
}
|
||||
|
||||
fn seed_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut out = Vec::with_capacity(s.model.seeds().len());
|
||||
for seed in s.model.seeds() {
|
||||
let edges = match s.design.as_deref() {
|
||||
Some(d) => seeded_edges(d, &seed.edge)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
out.push(report(seed, &edges));
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plate() -> Session {
|
||||
super::super::datum::tests::plate().0
|
||||
}
|
||||
|
||||
fn thickness_edges(s: &mut Session) -> Vec<Value> {
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
bodies[0]["edges"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| (e["length"].as_f64().unwrap() - 10.0).abs() < 1e-6)
|
||||
.map(|e| json!({"body": "Body1", "edge": e["name"]}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_are_set_replaced_listed_cleared_and_undone() {
|
||||
let mut s = plate();
|
||||
let edges = thickness_edges(&mut s);
|
||||
let r = s.execute("mesh.seed.edge", &json!({"edges": edges[..2], "count": 4, "bias": {"ratio": 3, "toward": "end"}})).unwrap();
|
||||
assert_eq!(r["seeded"], 2);
|
||||
assert_eq!(r["seeds"][0]["on"][0]["count"], 4);
|
||||
assert_eq!(r["seeds"][0]["constraint"], "exact");
|
||||
// A size gives the length over it, rounded up; it replaces the count.
|
||||
let r = s.execute("mesh.seed.edge", &json!({"edges": [edges[0]], "size": 3.0, "constraint": "min"})).unwrap();
|
||||
assert_eq!(r["seeds"][0]["on"][0]["count"], 4);
|
||||
assert_eq!(s.model.seeds().len(), 2);
|
||||
let list = s.execute("mesh.seed.list", &json!({})).unwrap();
|
||||
assert_eq!(list.as_array().unwrap().len(), 2);
|
||||
assert!(list.as_array().unwrap().iter().all(|l| l["stale"] == false));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.seed(&serde_json::from_value(edges[0].clone()).unwrap()).unwrap().divisions, Divisions::Count(4));
|
||||
s.execute("mesh.seed.clear", &json!({"edges": [edges[1]]})).unwrap();
|
||||
assert_eq!(s.model.seeds().len(), 1);
|
||||
assert!(s.execute("mesh.seed.clear", &json!({"edges": [edges[1]]})).is_err());
|
||||
assert_eq!(s.execute("mesh.seed.clear", &json!({})).unwrap()["cleared"], 1);
|
||||
assert!(s.execute("mesh.seed.clear", &json!({})).is_err(), "nothing to clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_survive_save_open_and_a_design_edit() {
|
||||
let (mut s, dir) = super::super::datum::tests::plate();
|
||||
let edges = thickness_edges(&mut s);
|
||||
s.execute("mesh.seed.edge", &json!({"edges": edges, "count": 5, "bias": {"ratio": 2, "toward": "both"}})).unwrap();
|
||||
let file = dir.join("seeded.fieldcraft");
|
||||
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut back = Session::default();
|
||||
back.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(back.model.seeds(), s.model.seeds());
|
||||
// The plate made thicker: the same edges, now 15 mm, keep their seeds.
|
||||
let design = dir.join("plate.solidcraft");
|
||||
let thick = fieldcraft_geometry::sample::Plate { thickness: 15.0, ..Default::default() };
|
||||
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&thick).unwrap()).unwrap();
|
||||
let r = back.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["stale"]["seeds"], json!([]));
|
||||
let list = back.execute("mesh.seed.list", &json!({})).unwrap();
|
||||
for l in list.as_array().unwrap() {
|
||||
assert_eq!(l["on"].as_array().unwrap().len(), 1, "{l}");
|
||||
assert!((l["on"][0]["length"].as_f64().unwrap() - 15.0).abs() < 1e-6, "{l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The polyline of edge `name` of body `body`.
|
||||
fn edge_points(s: &Session, body: &str, name: &str) -> Vec<[f64; 3]> {
|
||||
s.design.as_ref().unwrap().body(body).unwrap().edges().unwrap().into_iter().find(|e| e.name == name).unwrap().points
|
||||
}
|
||||
|
||||
/// The model's nodes on a straight edge, by their distance from its start, sorted.
|
||||
fn nodes_on(s: &Session, line: &[[f64; 3]]) -> Vec<f64> {
|
||||
let (a, b) = (line[0], *line.last().unwrap());
|
||||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let mut out: Vec<f64> = s
|
||||
.model
|
||||
.nodes()
|
||||
.values()
|
||||
.filter_map(|n| {
|
||||
let v = [n.xyz[0] - a[0], n.xyz[1] - a[1], n.xyz[2] - a[2]];
|
||||
let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / len;
|
||||
let off = [v[0] - t * d[0] / len, v[1] - t * d[1] / len, v[2] - t * d[2] / len];
|
||||
let off = (off[0] * off[0] + off[1] * off[1] + off[2] * off[2]).sqrt();
|
||||
(off < 1e-6 && (-1e-6..=len + 1e-6).contains(&t)).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(f64::total_cmp);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_meshers_honour_seeds() {
|
||||
let mut s = plate();
|
||||
let edges = thickness_edges(&mut s);
|
||||
let name = edges[0]["edge"].as_str().unwrap().to_string();
|
||||
let line = edge_points(&s, "Body1", &name);
|
||||
let seed = json!({"edges": [edges[0]], "count": 5, "bias": {"ratio": 3, "toward": "end"}});
|
||||
s.execute("mesh.seed.edge", &seed).unwrap();
|
||||
// Hex sweep: five layers through the thickness, graded geometrically toward the edge's end.
|
||||
s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap();
|
||||
let t = nodes_on(&s, &line);
|
||||
let dt: Vec<f64> = t.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
assert_eq!(dt.len(), 5, "{t:?}");
|
||||
assert!((dt[0] / dt[4] - 3.0).abs() < 1e-6, "{dt:?}");
|
||||
// Tet: the edge is divided at the seed's nodes.
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 5})).unwrap();
|
||||
assert_eq!(r["bodies"][0]["seeds"][0]["mesh_edges"], 5, "{}", r["bodies"][0]["seeds"]);
|
||||
// Two exact seeds that cannot both hold are refused, naming both.
|
||||
s.execute("mesh.seed.edge", &json!({"edges": [edges[1]], "count": 3})).unwrap();
|
||||
let e = s.execute("mesh.hex.sweep", &json!({"body": "Body1", "size": 4})).unwrap_err().to_string();
|
||||
assert!(e.contains(&name) && e.contains(edges[1]["edge"].as_str().unwrap()) && e.contains("conflict"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seed_on_a_cut_edge_holds_in_both_cells() {
|
||||
let (mut s, _dir) = super::super::datum::tests::plate();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
// An edge of the cut face, 30 mm along y, on the cell with the hole.
|
||||
let edge = s
|
||||
.design
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.body("Body1/a")
|
||||
.unwrap()
|
||||
.edges()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|e| e.name.contains("partition:P:a") && (e.length() - 30.0).abs() < 1e-6)
|
||||
.unwrap();
|
||||
s.execute("mesh.seed.edge", &json!({"edges": [{"body": "Body1/a", "edge": edge.name}], "count": 9})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"][0]["joined"], "merged", "{r}");
|
||||
assert_eq!(nodes_on(&s, &edge.points).len(), 10, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_seeds_are_refused_and_change_nothing() {
|
||||
let mut s = plate();
|
||||
let edges = thickness_edges(&mut s);
|
||||
let e = edges[0].clone();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"edges": [], "count": 2}),
|
||||
json!({"edges": [e], "count": 2, "size": 1.0}),
|
||||
json!({"edges": [e]}),
|
||||
json!({"edges": [e], "count": 0}),
|
||||
json!({"edges": [e], "count": -3}),
|
||||
json!({"edges": [e], "count": 1e12}),
|
||||
json!({"edges": [e], "size": 0.0}),
|
||||
json!({"edges": [e], "count": 2, "bias": {"ratio": 1e9, "toward": "end"}}),
|
||||
json!({"edges": [e], "count": 2, "bias": {"ratio": 2, "toward": "middle"}}),
|
||||
json!({"edges": [e], "count": 2, "constraint": "loose"}),
|
||||
json!({"edges": [{"body": "Body1", "face": "x"}], "count": 2}),
|
||||
json!({"edges": [{"body": "Body1", "edge": "no|such"}], "count": 2}),
|
||||
json!({"edges": [{"body": "Nope", "edge": "a|b"}], "count": 2}),
|
||||
json!({"edges": "all", "count": 2}),
|
||||
] {
|
||||
assert!(s.execute("mesh.seed.edge", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.model.seeds().is_empty());
|
||||
assert!(s.journal.is_empty() || s.journal.iter().all(|(c, _)| c != "mesh.seed.edge"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `solve.*`: run the model's steps with the implicit, the explicit or the lattice Boltzmann
|
||||
//! solver, keep the results in the session, and read them back.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use fieldcraft_explicit::{ExplicitResult, solve_explicit};
|
||||
use fieldcraft_lbm::{FluidResult, Obstacle};
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
@@ -13,6 +14,7 @@ use fieldcraft_solver::{
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{labels, opt, points, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, StepResult};
|
||||
|
||||
pub const RUN: CommandSpec = CommandSpec {
|
||||
@@ -21,51 +23,57 @@ pub const RUN: CommandSpec = CommandSpec {
|
||||
params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_all,
|
||||
};
|
||||
|
||||
pub const STATIC: CommandSpec = CommandSpec {
|
||||
id: "solve.static",
|
||||
label: "Solve Static Step",
|
||||
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
|
||||
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; `max_dofs` may only be lowered; `max_factor_entries` defaults to 400000000 and may be deliberately raised up to 2000000000 (8 bytes per factor value; indices and workspace are additional); `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_static,
|
||||
};
|
||||
|
||||
pub const NONLINEAR: CommandSpec = CommandSpec {
|
||||
id: "solve.nonlinear",
|
||||
label: "Solve Nonlinear Static Step",
|
||||
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); `max_dofs` and `max_newton_iterations` may only be lowered (the latter over the whole step); `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_NONLINEAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_nonlinear,
|
||||
};
|
||||
|
||||
pub const HEAT: CommandSpec = CommandSpec {
|
||||
id: "solve.heat",
|
||||
label: "Solve Heat Transfer Step",
|
||||
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; `max_dofs` may only be lowered; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_heat,
|
||||
};
|
||||
|
||||
pub const FREQUENCY: CommandSpec = CommandSpec {
|
||||
id: "solve.frequency",
|
||||
label: "Solve Frequency Step",
|
||||
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FREQUENCY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_frequency,
|
||||
};
|
||||
|
||||
pub const BUCKLE: CommandSpec = CommandSpec {
|
||||
id: "solve.buckle",
|
||||
label: "Solve Buckling Step",
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_BUCKLE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_buckle,
|
||||
};
|
||||
|
||||
@@ -75,6 +83,7 @@ pub const EXPLICIT: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "max_contact_faces": 10000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences, with the model's contacts (interaction.contact.*) and the step's rigid walls; keeps its histories and field frames in the session and reports each contact's peak force, first and last contact, deepest penetration, friction dissipated and the nodes adjusted at the start; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_EXPLICIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
run: run_explicit,
|
||||
};
|
||||
|
||||
@@ -84,6 +93,7 @@ pub const HISTORY: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
form: Some(super::forms::SOLVE_HISTORY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
run: history,
|
||||
};
|
||||
|
||||
@@ -93,6 +103,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FLUID),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain, Requirement::Step],
|
||||
run: run_fluid,
|
||||
};
|
||||
|
||||
@@ -102,9 +113,13 @@ pub const PROBE: CommandSpec = CommandSpec {
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
form: Some(super::forms::SOLVE_PROBE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
run: probe,
|
||||
};
|
||||
|
||||
/// Largest explicit factor cap accepted by solve commands; the default remains 400M entries.
|
||||
pub(super) const FACTOR_ENTRY_CEILING: usize = 2_000_000_000;
|
||||
|
||||
/// Most nodes one probe lists.
|
||||
const MAX_PROBE: usize = 10_000;
|
||||
|
||||
@@ -123,7 +138,7 @@ fn cap<T: Copy + TryInto<u64> + TryFrom<u64> + std::fmt::Display>(p: &Value, key
|
||||
.as_u64()
|
||||
.filter(|n| (1..=most).contains(n))
|
||||
.and_then(|n| T::try_from(n).ok())
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number from 1 to {default}"))),
|
||||
.ok_or_else(|| EngineError::param(key, ProblemKind::NotAWholeNumber { min: 1, max: i64::try_from(most).unwrap_or(i64::MAX) })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +157,11 @@ fn threads(s: &Session, p: &Value) -> Result<usize> {
|
||||
fn limits(p: &Value) -> Result<Limits> {
|
||||
Ok(Limits {
|
||||
max_dofs: cap(p, "max_dofs", Limits::MAX_DOFS)?,
|
||||
max_factor_entries: cap(p, "max_factor_entries", Limits::MAX_FACTOR_ENTRIES)?,
|
||||
max_factor_entries: if p.get("max_factor_entries").is_none() {
|
||||
Limits::MAX_FACTOR_ENTRIES
|
||||
} else {
|
||||
cap(p, "max_factor_entries", FACTOR_ENTRY_CEILING)?
|
||||
},
|
||||
max_lanczos_vectors: cap(p, "max_iterations", Limits::MAX_LANCZOS_VECTORS)?,
|
||||
max_newton_iterations: cap(p, "max_newton_iterations", Limits::MAX_NEWTON_ITERATIONS)?,
|
||||
})
|
||||
@@ -156,6 +175,12 @@ fn extreme(values: impl Iterator<Item = (u32, f64)>, better: fn(f64, f64) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
/// [`parallel::with_threads`], the caller's progress reporter carried onto the pool's thread.
|
||||
fn with_threads<R: Send>(threads: usize, f: impl FnOnce() -> R + Send) -> R {
|
||||
let progress = fieldcraft_model::progress::current();
|
||||
parallel::with_threads(threads, move || fieldcraft_model::progress::scoped(progress, f))
|
||||
}
|
||||
|
||||
fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
use fieldcraft_model::Procedure;
|
||||
type Solve = fn(&mut Session, &Value) -> Result<Value>;
|
||||
@@ -180,13 +205,30 @@ fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Params("the model has no steps to solve: add one (model.step.create)".into()));
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
for (name, procedure, solve) in steps {
|
||||
let summary = solve(s, &json!({"step": name})).map_err(|e| EngineError::Params(format!("step `{name}`: {e}")))?;
|
||||
let count = steps.len();
|
||||
for (index, (name, procedure, solve)) in steps.into_iter().enumerate() {
|
||||
fieldcraft_model::progress::step(&name, index + 1, count);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let summary =
|
||||
solve(s, &json!({"step": name})).map_err(|e| if e.is_cancelled() { e } else { EngineError::Params(format!("step `{name}`: {e}")) })?;
|
||||
done.push(json!({"step": name, "procedure": procedure, "summary": summary}));
|
||||
}
|
||||
Ok(json!({"steps": done}))
|
||||
}
|
||||
|
||||
/// A sum of nodal values with its round-off set to 0: a component below a billionth of the sum
|
||||
/// of the terms' magnitudes is what cancelling left (a balanced load sums to ~1e-11, not 0).
|
||||
fn settled(total: [f64; 3], terms: &[[f64; 3]]) -> [f64; 3] {
|
||||
let mut out = total;
|
||||
for (k, c) in out.iter_mut().enumerate() {
|
||||
let scale: f64 = terms.iter().filter_map(|t| t.get(k)).map(|x| x.abs()).filter(|x| x.is_finite()).sum();
|
||||
if c.abs() <= 1e-9 * scale {
|
||||
*c = 0.0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn largest(values: impl Iterator<Item = (u32, f64)>) -> Value {
|
||||
extreme(values, |v, b| v > b)
|
||||
}
|
||||
@@ -199,7 +241,7 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
|
||||
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -208,8 +250,8 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"factor_entries": r.stats.factor_entries,
|
||||
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
|
||||
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
|
||||
"applied_total": r.applied_total,
|
||||
"reaction_total": r.reaction_total,
|
||||
"applied_total": settled(r.applied_total, &r.reaction),
|
||||
"reaction_total": settled(r.reaction_total, &r.reaction),
|
||||
"reference_points": r.reference_points.iter().map(|p| json!({
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
@@ -227,7 +269,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?;
|
||||
let history = r.nonlinear.as_ref();
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -252,8 +294,8 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
|
||||
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
|
||||
"max_plastic_strain": largest(r.elements.iter().flat_map(|e| e.plastic_strain.iter().map(|v| (e.label, *v)))),
|
||||
"applied_total": r.applied_total,
|
||||
"reaction_total": r.reaction_total,
|
||||
"applied_total": settled(r.applied_total, &r.reaction),
|
||||
"reaction_total": settled(r.reaction_total, &r.reaction),
|
||||
"reference_points": r.reference_points.iter().map(|p| json!({
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
@@ -270,7 +312,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
|
||||
let t = r.temperature();
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -283,6 +325,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"max_temperature": largest(r.nodes.iter().copied().zip(t.iter().copied())),
|
||||
"heat_supplied": r.applied_total,
|
||||
"heat_removed": r.reaction_total,
|
||||
"heat_flows": crate::results::heat_flows(&r),
|
||||
"warnings": r.warnings,
|
||||
});
|
||||
s.store_result(StepResult::Implicit(ImplicitResult::Heat(r)));
|
||||
@@ -295,7 +338,7 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mass = opt::<MassKind>(p, "mass")?.unwrap_or_default();
|
||||
let shift = opt::<f64>(p, "shift")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
"dofs": r.stats.dofs,
|
||||
@@ -318,7 +361,7 @@ fn run_buckle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?;
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
"dofs": r.stats.dofs,
|
||||
@@ -342,7 +385,7 @@ fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
|
||||
let summary = explicit_summary(&r);
|
||||
s.store_result(StepResult::Explicit(r));
|
||||
Ok(summary)
|
||||
@@ -379,7 +422,7 @@ fn history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = &r.history;
|
||||
let samples = h.time.len();
|
||||
let every = match opt::<u64>(p, "every")? {
|
||||
Some(0) => return Err(EngineError::Params("`every` must be at least 1".into())),
|
||||
Some(0) => return Err(EngineError::param("every", ProblemKind::Below(1.0))),
|
||||
Some(k) => usize::try_from(k).unwrap_or(usize::MAX),
|
||||
None => samples.div_ceil(MAX_HISTORY_SAMPLES).max(1),
|
||||
};
|
||||
@@ -447,7 +490,7 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?;
|
||||
let obstacles = obstacles(s, domain)?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
|
||||
let summary = fluid_summary(&r);
|
||||
s.store_result(StepResult::Fluid(r));
|
||||
Ok(summary)
|
||||
@@ -529,8 +572,8 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"step": r.step, "frame": frame, "time": time, "points": out}))
|
||||
}
|
||||
|
||||
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
|
||||
pub(crate) fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::param("step", ProblemKind::Required))?;
|
||||
let result = s.result(&step).ok_or_else(|| {
|
||||
EngineError::Params(if s.latest_result(&step).is_some() {
|
||||
format!("the result of step `{step}` is stale (the model changed since it was solved); solve it again")
|
||||
@@ -792,7 +835,7 @@ pub(crate) mod tests {
|
||||
s.execute("solve.run", &json!({})).unwrap();
|
||||
assert!(s.result("Impact").is_some());
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("no steps"));
|
||||
assert!(empty.execute("solve.run", &json!({})).unwrap_err().to_string().contains("Needs a mesh"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
@@ -885,6 +928,76 @@ pub(crate) mod tests {
|
||||
assert!(e.contains("stale"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factor_cap_can_be_raised_deliberately_to_a_finite_ceiling() {
|
||||
assert_eq!(limits(&json!({})).unwrap(), Limits::default());
|
||||
for entries in [1, 400_000_000, 400_000_001, 467_000_000, 2_000_000_000] {
|
||||
assert_eq!(limits(&json!({"max_factor_entries": entries})).unwrap().max_factor_entries, entries);
|
||||
}
|
||||
for entries in
|
||||
[json!(null), json!(true), json!("467000000"), json!(-1), json!(0), json!(2.5), json!(2_000_000_001u64), json!(u64::MAX), json!(1e300)]
|
||||
{
|
||||
assert!(limits(&json!({"max_factor_entries": entries})).is_err(), "{entries}");
|
||||
}
|
||||
for params in [
|
||||
json!({"max_dofs": Limits::MAX_DOFS + 1}),
|
||||
json!({"max_iterations": Limits::MAX_LANCZOS_VECTORS + 1}),
|
||||
json!({"max_newton_iterations": Limits::MAX_NEWTON_ITERATIONS + 1}),
|
||||
] {
|
||||
assert!(limits(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for id in ["solve.static", "solve.nonlinear", "solve.heat", "solve.frequency", "solve.buckle"] {
|
||||
let schema = crate::schema::json_schema(crate::find_command(id).unwrap().form.unwrap());
|
||||
let factor = &schema["properties"]["max_factor_entries"];
|
||||
assert_eq!(factor["minimum"], 1, "{id}: {factor}");
|
||||
assert_eq!(factor["maximum"], 2_000_000_000u64, "{id}: {factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raised_factor_caps_solve_small_models_and_failures_keep_results() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
s.run_script(&json!([
|
||||
{"command": "model.section.edit", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {}}},
|
||||
{"command": "model.step.create", "params": {"name": "Nonlinear", "procedure": {"type": "nonlinear_static", "nonlinear_geometry": false}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
|
||||
{"command": "model.load.create", "params": {"name": "compress", "type": "force", "target": {"set": "tip"}, "vector": [-1, 0, 0]}},
|
||||
{"command": "model.step.create", "params": {"name": "Buckle", "procedure": {"type": "buckle", "modes": 1}, "boundary_conditions": ["clamp"], "loads": ["compress"]}},
|
||||
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "cold", "type": "temperature", "target": {"set": "root"}, "value": 0}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "hot", "type": "temperature", "target": {"set": "tip"}, "value": 100}},
|
||||
{"command": "model.step.create", "params": {"name": "Heat", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["cold", "hot"]}},
|
||||
])).unwrap();
|
||||
for (command, step) in [
|
||||
("solve.static", "Bend"),
|
||||
("solve.nonlinear", "Nonlinear"),
|
||||
("solve.heat", "Heat"),
|
||||
("solve.frequency", "Modes"),
|
||||
("solve.buckle", "Buckle"),
|
||||
] {
|
||||
let expected = s.execute(command, &json!({"step": step})).unwrap();
|
||||
let probe_params = json!({"step": step, "nodes": [6]});
|
||||
let probe = s.execute("solve.probe", &probe_params).unwrap();
|
||||
for entries in [400_000_001, 467_000_000, 2_000_000_000] {
|
||||
assert_eq!(s.execute(command, &json!({"step": step, "max_factor_entries": entries})).unwrap(), expected);
|
||||
assert_eq!(s.execute("solve.probe", &probe_params).unwrap(), probe);
|
||||
}
|
||||
let before = s.model.clone();
|
||||
let results = s.solved_steps().count();
|
||||
let journal = s.journal.len();
|
||||
for entries in [json!(1), json!(0), json!(-1), json!(2.5), json!(null), json!(2_000_000_001u64), json!(u64::MAX)] {
|
||||
let e = s.execute(command, &json!({"step": step, "max_factor_entries": entries})).unwrap_err().to_string();
|
||||
if entries == 1 {
|
||||
assert!(e.contains("bytes of factor values alone") && e.contains("limit is 1 entries"), "{command}: {e}");
|
||||
}
|
||||
assert_eq!(s.model, before, "{command}: {entries}");
|
||||
assert_eq!(s.solved_steps().count(), results);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert_eq!(s.execute("solve.probe", &probe_params).unwrap(), probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_hostile_params_never_panic() {
|
||||
let mut s = Session::default();
|
||||
@@ -1376,8 +1489,8 @@ pub(crate) mod tests {
|
||||
s.execute("model.step.create", &json!({"name": "Long", "procedure": long})).unwrap();
|
||||
assert!(s.execute("solve.explicit", &json!({"step": "Long"})).unwrap_err().to_string().contains("increments"));
|
||||
assert!(s.execute("model.set.delete", &json!({"name": "all_nodes"})).unwrap_err().to_string().contains("step `Impact`"));
|
||||
// Defining steps edited the model, so the impact result is stale.
|
||||
assert_eq!(s.solved_steps().count(), 0);
|
||||
// Defining other steps left what the impact step reads alone: its result is current.
|
||||
assert_eq!(s.solved_steps().collect::<Vec<_>>(), ["Impact"]);
|
||||
}
|
||||
|
||||
/// A mass on a spring and a dashpot, defined, solved and written through commands; editing
|
||||
@@ -1457,6 +1570,30 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A heat balance per load: the convection `Air` carries away what the heater's prescribed
|
||||
/// temperature supplies (#362).
|
||||
#[test]
|
||||
fn heat_flows_balance_per_load() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
let script = json!([
|
||||
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "root"}, "value": 100}},
|
||||
{"command": "model.set.create", "params": {"name": "skin", "faces": (1..=6).map(|f| json!({"element": 10, "face": f})).collect::<Vec<_>>()}},
|
||||
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.5, "sink": 20}},
|
||||
{"command": "model.step.create", "params": {"name": "Therm", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["Heater"], "loads": ["Air"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
let r = s.execute("solve.heat", &json!({"step": "Therm"})).unwrap();
|
||||
let flows = &r["heat_flows"];
|
||||
let air = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Air").unwrap();
|
||||
assert_eq!(air["type"], "convection");
|
||||
let (out, supplied) = (air["heat"].as_f64().unwrap(), flows["reactions"].as_f64().unwrap());
|
||||
assert!(out < 0.0 && supplied > 0.0, "{flows}");
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-6 * supplied, "{flows}");
|
||||
assert_eq!(s.results_report()["heat_flows"], *flows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_heat_and_probe_temperatures() {
|
||||
// The cantilever bar as a conductor: 0 at the root, 100 at the tip, linear between.
|
||||
@@ -1621,4 +1758,91 @@ pub(crate) mod tests {
|
||||
let v = s.execute("results.contour", &json!({"step": "Squeeze", "field": "contact_shear"})).unwrap();
|
||||
assert!(v["contour"]["range"]["max"]["value"].as_f64().unwrap() > 1.0, "{v}");
|
||||
}
|
||||
|
||||
/// A chip with a pad on it, both in a pocket of air (tet4), the chip and pad hex8 at other
|
||||
/// sizes: tie `air` holds the chip's and the pad's sides on the air, tie `chip` the pad's
|
||||
/// underside on the chip. The pad's bottom edge is on both ties' secondary surfaces, and the
|
||||
/// chip's top edge, the main nodes there, follows the air: two constraints per edge node that
|
||||
/// nearly repeat each other.
|
||||
fn chip_pad_air(s: &mut Session) {
|
||||
let (_dir, opened) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 6, "corner": [0, 0, 0], "body_name": "Air"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 3, "corner": [6, 6, 0], "operation": "cut"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 2, "corner": [6, 6, 0], "operation": "new_body", "body_name": "Chip"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 1, "corner": [6, 6, 2], "operation": "new_body", "body_name": "Pad"}},
|
||||
]));
|
||||
*s = opened;
|
||||
std::mem::forget(_dir);
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Air", "size": 2.0})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Chip", "size": 0.8})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Pad", "size": 0.45})).unwrap();
|
||||
let m = &s.model;
|
||||
// Element faces of a body all of whose nodes satisfy `on`.
|
||||
let faces = |body: &str, on: &dyn Fn([f64; 3]) -> bool| -> Vec<Value> {
|
||||
m.body_elements(body)
|
||||
.into_iter()
|
||||
.flat_map(|e| (1..=m.element(e).unwrap().kind.faces().len() as u8).map(move |f| (e, f)))
|
||||
.filter(|&(e, f)| {
|
||||
let ns = m.face_nodes(fieldcraft_model::ElementFace { element: e, face: f }).unwrap();
|
||||
ns.iter().all(|n| on(m.node(*n).unwrap().xyz))
|
||||
})
|
||||
.map(|(e, f)| json!({"element": e, "face": f}))
|
||||
.collect()
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
let side = |p: [f64; 3]| near(p[0], 6.0) || near(p[0], 14.0) || near(p[1], 6.0) || near(p[1], 14.0);
|
||||
let pocket = |p: [f64; 3]| {
|
||||
(side(p) && p[2] < 3.0 + 1e-6 && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
|| (near(p[2], 3.0) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
};
|
||||
let outer = |p: [f64; 3]| near(p[0], 0.0) || near(p[0], 20.0) || near(p[1], 0.0) || near(p[1], 20.0) || near(p[2], 6.0);
|
||||
let mut sides = faces("Chip", &|p| side(p));
|
||||
sides.extend(faces("Pad", &|p| side(p)));
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "chip", "elements": m.body_elements("Chip")}},
|
||||
{"command": "model.set.create", "params": {"name": "pocket", "faces": faces("Air", &pocket)}},
|
||||
{"command": "model.set.create", "params": {"name": "sides", "faces": sides}},
|
||||
{"command": "model.set.create", "params": {"name": "chip_top", "faces": faces("Chip", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "pad_bottom", "faces": faces("Pad", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "outside", "faces": faces("Air", &outer)}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "interaction.tie", "params": {"name": "air", "main": "pocket", "secondary": "sides", "position_tolerance": 0.1}},
|
||||
{"command": "interaction.tie", "params": {"name": "chip", "main": "chip_top", "secondary": "pad_bottom", "position_tolerance": 0.1}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "chip", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Room", "type": "convection", "set": "outside", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Room"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
/// #389: a node on the secondary surfaces of two ties follows their main surfaces together,
|
||||
/// so the heat balances to round-off and no temperature drops below the surroundings'. (With
|
||||
/// a constraint per tie, this model put 335 W more in than the 128 W supplied, and some
|
||||
/// temperatures below 0.)
|
||||
#[test]
|
||||
fn nodes_in_two_ties_keep_the_heat_balance() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
let r = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
let flows = &r["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!((supplied - 128.0).abs() < 1e-9, "{flows}");
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(r["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{r}");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("ties `air`, `chip`"), "{warnings}");
|
||||
}
|
||||
|
||||
/// Two ties on one interface in opposite directions hold every node there twice: an error
|
||||
/// naming both, not a field.
|
||||
#[test]
|
||||
fn ties_both_ways_on_one_interface_are_an_error_naming_them() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
s.execute("interaction.tie", &json!({"name": "back", "main": "pad_bottom", "secondary": "chip_top", "position_tolerance": 0.1})).unwrap();
|
||||
let e = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap_err().to_string();
|
||||
assert!(e.contains("tie `back`") && e.contains("tie `chip`"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! `model.boundary_condition.symmetry`: a symmetry condition on a datum plane, so half of a
|
||||
//! symmetric part can be solved as a half.
|
||||
//!
|
||||
//! The condition holds the displacement normal to the plane, and the rotations about the two
|
||||
//! axes in it, at zero (`type: "symmetry"`). Its set is given, or made of the faces of the open
|
||||
//! design's bodies that lie on the plane (the cut faces of a partition by that datum, `partition:
|
||||
//! <name>:a|b`), so it follows the geometry through a remesh.
|
||||
|
||||
use fieldcraft_geometry::FaceSurface;
|
||||
use fieldcraft_model::{BoundaryCondition, GeometryRef, Set, Symmetry, Target};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const SYMMETRY: CommandSpec = CommandSpec {
|
||||
id: "model.boundary_condition.symmetry",
|
||||
label: "Symmetry Plane",
|
||||
params: r#"{"name": "Sym", "datum": "Mid" (a datum plane normal to X, Y or Z), "set"?: "cut" (default: a new set of the same name holding every face of the open design's bodies on the plane, e.g. a partition's cut faces), "bodies"?: ["Part/a"] (only their faces)} — a symmetry boundary condition on the plane: zero displacement normal to it and zero rotation about the axes in it, so half a symmetric part (loaded symmetrically, with half the load) is solved as a half. Add it to the steps (model.step.activate). Planes not normal to X, Y or Z are not supported yet"#,
|
||||
form: Some(super::forms::SYMMETRY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::DatumPlane],
|
||||
run: symmetry,
|
||||
};
|
||||
|
||||
/// A face lies on the plane within this fraction of its body's size.
|
||||
const ON_PLANE: f64 = 1e-6;
|
||||
|
||||
fn symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let datum: String = super::req(p, "datum")?;
|
||||
let (point, normal) = super::datum::datum_plane_of(&s.model, &datum)?;
|
||||
let axis = (0..3).find(|&k| (normal[k].abs() - 1.0).abs() < 1e-9).ok_or_else(|| {
|
||||
EngineError::Params(format!("not supported yet: symmetry about a plane not normal to X, Y or Z (datum `{datum}` has normal {normal:?})"))
|
||||
})?;
|
||||
let plane = [Symmetry::X, Symmetry::Y, Symmetry::Z][axis];
|
||||
let set = match p.get("set") {
|
||||
Some(_) => super::req::<String>(p, "set")?,
|
||||
None => {
|
||||
let only: Option<Vec<String>> = super::opt(p, "bodies")?;
|
||||
let design =
|
||||
s.design.as_deref().ok_or_else(|| EngineError::Params("open a design to find the faces on the plane, or give `set`".into()))?;
|
||||
let mut faces = Vec::new();
|
||||
for b in &design.bodies {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&b.name)) {
|
||||
continue;
|
||||
}
|
||||
let tol = ON_PLANE * b.size()?.max(1.0);
|
||||
for (f, surface) in b.faces().iter().zip(b.face_surfaces()?) {
|
||||
if let Some(FaceSurface::Plane { normal: m, point: q }) = surface {
|
||||
let parallel = (m[axis].abs() - 1.0).abs() < 1e-9;
|
||||
if parallel && (q[axis] - point[axis]).abs() < tol {
|
||||
faces.push(GeometryRef::face(b.name.as_str(), f.name.as_str())?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(EngineError::Params(format!("no face of the design lies on datum `{datum}`: partition by it first, or give `set`")));
|
||||
}
|
||||
s.model.add_set(&name, Set::of_geometry(faces))?;
|
||||
name.clone()
|
||||
}
|
||||
};
|
||||
s.model.add_boundary_condition(&name, BoundaryCondition::Symmetry { target: Target::Set(set.clone()), plane })?;
|
||||
let faces = s.model.set(&set).map_or(0, |x| x.geometry.len());
|
||||
let axis_name = ["x", "y", "z"].get(axis).copied().unwrap_or("x");
|
||||
Ok(json!({"name": name, "set": set, "plane": axis_name, "faces": faces}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 40 × 10 × 10 bar along x (0..40, 0..10, 0..10), the datum `Mid` at y = 5.
|
||||
fn bar(half: bool) -> (crate::tests::TempDir, Session) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "zx", "offset": 5}})).unwrap();
|
||||
if half {
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
}
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
fn face(s: &Session, body: &str, point: [f64; 3]) -> String {
|
||||
let shape = s.design.as_ref().unwrap().body(body).unwrap();
|
||||
let f = shape.nearest_faces(&[point], 0.01).unwrap()[0].unwrap();
|
||||
shape.faces()[f as usize].name.clone()
|
||||
}
|
||||
|
||||
/// Clamp the x = 0 end and push the x = 40 end down (−z) with `force`, solve, and give the
|
||||
/// largest downward tip deflection and the clamp's reaction.
|
||||
fn bend(s: &mut Session, body: &str, force: f64, extra: &[&str]) -> (f64, f64) {
|
||||
let (root, tip) = (face(s, body, [0.0, 2.5, 5.0]), face(s, body, [40.0, 2.5, 5.0]));
|
||||
let mut bcs = vec!["Clamp".to_string()];
|
||||
bcs.extend(extra.iter().map(|x| x.to_string()));
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": body}]}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": body, "face": root}]}},
|
||||
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": body, "face": tip}]}},
|
||||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
|
||||
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -force]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": bcs, "loads": ["Push"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let tip = s.model.resolve_set("tip").unwrap().nodes;
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip})).unwrap();
|
||||
let w = probe["nodes"].as_array().unwrap().iter().map(|n| -n["displacement"][2].as_f64().unwrap()).fold(f64::MIN, f64::max);
|
||||
let r = s.execute("results.reactions", &json!({"step": "Load", "sets": ["root"]})).unwrap();
|
||||
(w, r["sets"][0]["force"][2].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// Half the bar, cut on its symmetry plane y = 5, with the symmetry condition and half the
|
||||
/// load, bends as the whole bar does: the same deflection, half the reaction.
|
||||
#[test]
|
||||
fn a_half_model_with_symmetry_matches_the_whole() {
|
||||
let (_d1, mut whole) = bar(false);
|
||||
whole.execute("mesh.hex.mapped", &json!({"body": "Bar", "size": 2.5, "order": 2})).unwrap();
|
||||
let full = bend(&mut whole, "Bar", 1000.0, &[]);
|
||||
|
||||
let (_d2, mut half) = bar(true);
|
||||
half.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 2.5, "order": 2})).unwrap();
|
||||
let r = half.execute("model.boundary_condition.symmetry", &json!({"name": "Sym", "datum": "Mid", "bodies": ["Bar/a"]})).unwrap();
|
||||
assert_eq!(r, json!({"name": "Sym", "set": "Sym", "plane": "y", "faces": 1}));
|
||||
assert_eq!(half.model.set("Sym").unwrap().geometry[0].entity, fieldcraft_model::geometry::Entity::Face("partition:Half:a".into()));
|
||||
let halved = bend(&mut half, "Bar/a", 500.0, &["Sym"]);
|
||||
assert!((halved.0 - full.0).abs() < 1e-9 * full.0, "deflection {} vs {}", halved.0, full.0);
|
||||
assert!((2.0 * halved.1 - full.1).abs() < 1e-9 * full.1.abs(), "reaction {} vs {}", halved.1, full.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclined_planes_and_hostile_params_change_nothing() {
|
||||
let (_d, mut s) = bar(true);
|
||||
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [1, 1, 0]}})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "Out", "by": {"type": "offset", "plane": "xy", "offset": 100}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
|
||||
let model = s.model.clone();
|
||||
let e = s.execute("model.boundary_condition.symmetry", &json!({"name": "S", "datum": "Tilt"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
for p in [
|
||||
json!({"name": "S", "datum": "Out"}),
|
||||
json!({"name": "S", "datum": "Ax"}),
|
||||
json!({"name": "S", "datum": "Nope"}),
|
||||
json!({"name": "S", "datum": "Mid", "set": "nope"}),
|
||||
json!({"name": "S", "datum": "Mid", "bodies": ["Nope"]}),
|
||||
json!({"name": "1S", "datum": "Mid"}),
|
||||
json!({"datum": "Mid"}),
|
||||
] {
|
||||
assert!(s.execute("model.boundary_condition.symmetry", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Geometry tags on the mesh: which body, face, edge or vertex each mesh entity lies on. Meshers
|
||||
//! write them; these commands set them by hand (for a mesh from elsewhere) and clear them.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const TAG: CommandSpec = CommandSpec {
|
||||
@@ -13,11 +15,19 @@ pub const TAG: CommandSpec = CommandSpec {
|
||||
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
|
||||
form: Some(super::forms::TAG),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: tag,
|
||||
};
|
||||
|
||||
pub const TAG_CLEAR: CommandSpec =
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: tag_clear };
|
||||
pub const TAG_CLEAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.tag.clear",
|
||||
label: "Clear Geometry Tags",
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: tag_clear,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -29,7 +39,7 @@ struct ElementEdge {
|
||||
/// An optional list parameter of at most [`MAX_ELEMENTS`] entries.
|
||||
fn list<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Vec<T>> {
|
||||
if p.get(key).and_then(Value::as_array).is_some_and(|a| a.len() > MAX_ELEMENTS) {
|
||||
return Err(EngineError::Params(format!("`{key}` must be a list of at most {MAX_ELEMENTS} entries")));
|
||||
return Err(EngineError::param(key, ProblemKind::NotAList { max: MAX_ELEMENTS, what: "entries" }));
|
||||
}
|
||||
Ok(opt(p, key)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `app.update.check`: is a newer FieldCraft published? The same check as the desktop app's
|
||||
//! Help › Check for Updates and `fieldcraft-cli update --check` (docs/updates.md). It only
|
||||
//! reports: nothing is downloaded or installed, and no dialog opens.
|
||||
//!
|
||||
//! Checking reaches the network, so it is switched on only by the programs that may
|
||||
//! ([`enable_checks`]: the desktop app and the CLI); everywhere else (tests, scratch sessions,
|
||||
//! the browser) the command refuses without asking anything.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const CHECK: CommandSpec = CommandSpec {
|
||||
id: "app.update.check",
|
||||
label: "Check for Updates",
|
||||
params: r#"{} — asks GitHub Releases for the latest published FieldCraft release and compares it with this version: current, latest, newer, url (its page and release notes), published, signed (whether it can be verified and installed from the app) and asset (the file this install would download; null when it cannot update itself). Reports only: nothing is downloaded or installed. Desktop app and fieldcraft-cli only"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: check,
|
||||
};
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Let `app.update.check` reach the release server (the desktop app and the CLI do).
|
||||
pub fn enable_checks(on: bool) {
|
||||
ENABLED.store(on, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn check(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(EngineError::Params("not in the browser: the web app offers Reload to update when a new version is deployed".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn check(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
if !ENABLED.load(Ordering::SeqCst) {
|
||||
return Err(EngineError::Params("update checks are off in this session (the desktop app and fieldcraft-cli check for updates)".into()));
|
||||
}
|
||||
let check = fieldcraft_update::net::Client::from_env().check().map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
let asset = fieldcraft_update::install::here().asset_name(&check.latest.version, std::env::consts::ARCH);
|
||||
Ok(check.to_json(asset.as_deref()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Off unless a program switches it on: tests and scratch sessions never reach the network.
|
||||
#[test]
|
||||
fn checks_are_off_unless_enabled() {
|
||||
let e = Session::default().execute("app.update.check", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("update checks are off"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! What the viewport hides: `view.hide`, `view.show`, `view.isolate` and `view.show_all`.
|
||||
//!
|
||||
//! The hidden items (bodies, imported surfaces, datums, boundary conditions, loads, reference
|
||||
//! points, couplings) are view state ([`fieldcraft_io::View`]): saved with the model and marking
|
||||
//! it unsaved, but not a definition, so they are neither journalled nor undone and they leave
|
||||
//! results current. A hidden item is not drawn, so it cannot be picked either. An item that goes
|
||||
//! away (deleted, or a body cut into cells) is forgotten.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HIDE: CommandSpec = CommandSpec {
|
||||
id: "view.hide",
|
||||
label: "Hide",
|
||||
params: concat!(
|
||||
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
|
||||
" — hides the items in the viewport (and from picking); view state, saved with the model"
|
||||
),
|
||||
form: Some(super::forms::VIEW_ITEMS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| change(s, p, Change::Hide),
|
||||
};
|
||||
|
||||
pub const SHOW: CommandSpec = CommandSpec {
|
||||
id: "view.show",
|
||||
label: "Show",
|
||||
params: concat!(
|
||||
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
|
||||
" — shows hidden items again"
|
||||
),
|
||||
form: Some(super::forms::VIEW_ITEMS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| change(s, p, Change::Show),
|
||||
};
|
||||
|
||||
pub const ISOLATE: CommandSpec = CommandSpec {
|
||||
id: "view.isolate",
|
||||
label: "Isolate",
|
||||
params: concat!(
|
||||
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
|
||||
" — shows only these items: every other one is hidden (view.show_all brings them back)"
|
||||
),
|
||||
form: Some(super::forms::VIEW_ITEMS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, p| change(s, p, Change::Isolate),
|
||||
};
|
||||
|
||||
pub const SHOW_ALL: CommandSpec = CommandSpec {
|
||||
id: "view.show_all",
|
||||
label: "Show All",
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: |s, _| {
|
||||
s.view.hidden.clear();
|
||||
Ok(Value::Null)
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Change {
|
||||
Hide,
|
||||
Show,
|
||||
Isolate,
|
||||
}
|
||||
|
||||
/// Every item of `kind` the session has, by name.
|
||||
pub(crate) fn names(s: &Session, kind: ItemKind) -> Vec<String> {
|
||||
let m = &s.model;
|
||||
match kind {
|
||||
ItemKind::Body => s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(),
|
||||
ItemKind::Surface => s.surfaces.iter().map(|x| x.name.clone()).collect(),
|
||||
ItemKind::Datum => m.datums().keys().cloned().collect(),
|
||||
ItemKind::BoundaryCondition => m.boundary_conditions().keys().cloned().collect(),
|
||||
ItemKind::Load => m.loads().keys().cloned().collect(),
|
||||
ItemKind::ReferencePoint => m.reference_points().keys().cloned().collect(),
|
||||
ItemKind::Coupling => m.couplings().keys().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn exists(s: &Session, item: &Item) -> bool {
|
||||
let m = &s.model;
|
||||
match item.kind {
|
||||
ItemKind::Body => s.design.as_ref().is_some_and(|d| d.body(&item.name).is_some()),
|
||||
ItemKind::Surface => s.surfaces.iter().any(|x| x.name == item.name),
|
||||
ItemKind::Datum => m.datum(&item.name).is_some(),
|
||||
ItemKind::BoundaryCondition => m.boundary_conditions().contains_key(&item.name),
|
||||
ItemKind::Load => m.loads().contains_key(&item.name),
|
||||
ItemKind::ReferencePoint => m.reference_points().contains_key(&item.name),
|
||||
ItemKind::Coupling => m.couplings().contains_key(&item.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget hidden items the session no longer has.
|
||||
pub(crate) fn forget_missing(s: &mut Session) {
|
||||
if s.view.hidden.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gone: Vec<Item> = s.view.hidden.iter().filter(|i| !exists(s, i)).cloned().collect();
|
||||
for i in gone {
|
||||
s.view.hidden.remove(&i);
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The analysis setup's symbols, without the definitions the view hides.
|
||||
pub fn glyphs(&self) -> fieldcraft_render::glyphs::Glyphs {
|
||||
self.glyphs_of(None)
|
||||
}
|
||||
|
||||
/// The symbols of what solved step `step` used, over its results: its own boundary
|
||||
/// conditions and loads (none for a frequency step, which has no loads) and its own initial
|
||||
/// velocities and rigid walls; the interactions as always. Without the view's hidden items.
|
||||
pub fn step_glyphs(&self, step: &str) -> fieldcraft_render::glyphs::Glyphs {
|
||||
self.glyphs_of(Some(step))
|
||||
}
|
||||
|
||||
fn glyphs_of(&self, step: Option<&str>) -> fieldcraft_render::glyphs::Glyphs {
|
||||
use fieldcraft_model::Procedure;
|
||||
use fieldcraft_render::glyphs::Definition;
|
||||
let step = step.map(|name| self.model.step(name));
|
||||
let frequency = matches!(step, Some(Some(s)) if matches!(s.procedure, Procedure::Frequency { .. }));
|
||||
fieldcraft_render::glyphs::Glyphs::from_model_hiding(&self.model, &|definition, name| {
|
||||
let (kind, used) = match definition {
|
||||
Definition::BoundaryCondition => (ItemKind::BoundaryCondition, step.map(|s| s.is_some_and(|s| s.boundary_conditions.contains(name)))),
|
||||
Definition::Load => (ItemKind::Load, step.map(|s| !frequency && s.is_some_and(|s| s.loads.contains(name)))),
|
||||
Definition::ReferencePoint => (ItemKind::ReferencePoint, None),
|
||||
Definition::Coupling => (ItemKind::Coupling, None),
|
||||
Definition::Step => return step.is_some_and(|s| s.is_none_or(|s| s.name != name)),
|
||||
Definition::Contact | Definition::Tie => return false,
|
||||
};
|
||||
used == Some(false) || self.view.is_hidden(kind, name)
|
||||
})
|
||||
}
|
||||
|
||||
/// The elements of the bodies the view hides: hiding a body hides its mesh and its results.
|
||||
pub fn hidden_elements(&self) -> std::collections::BTreeSet<u32> {
|
||||
self.view.hidden.iter().filter(|i| i.kind == ItemKind::Body).flat_map(|i| self.model.body_elements(&i.name)).collect()
|
||||
}
|
||||
|
||||
/// Whether the view shows item `name` of `kind`.
|
||||
pub fn shows(&self, kind: ItemKind, name: &str) -> bool {
|
||||
!self.view.is_hidden(kind, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, p: &Value, how: Change) -> Result<Value> {
|
||||
let items: Vec<Item> = super::req(p, "items")?;
|
||||
if items.len() > MAX_HIDDEN {
|
||||
return Err(EngineError::Params(format!("at most {MAX_HIDDEN} items")));
|
||||
}
|
||||
if let Some(i) = items.iter().find(|i| !exists(s, i)) {
|
||||
let kind = serde_json::to_value(i.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(EngineError::Params(format!("no {} `{}`", kind.replace('_', " "), i.name.chars().take(100).collect::<String>())));
|
||||
}
|
||||
match how {
|
||||
Change::Hide => s.view.hidden.extend(items),
|
||||
Change::Show => {
|
||||
for i in &items {
|
||||
s.view.hidden.remove(i);
|
||||
}
|
||||
}
|
||||
Change::Isolate => {
|
||||
let keep: BTreeSet<Item> = items.into_iter().collect();
|
||||
let all = ItemKind::ALL.into_iter().flat_map(|kind| names(s, kind).into_iter().map(move |name| Item { kind, name }));
|
||||
let rest: BTreeSet<Item> = all.filter(|i| !keep.contains(i)).take(MAX_HIDDEN).collect();
|
||||
s.view.hidden = rest;
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::datum::tests::plate;
|
||||
|
||||
fn item(kind: ItemKind, name: &str) -> Value {
|
||||
json!({"kind": kind, "name": name})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_is_saved_view_state_not_an_edit() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Sym", "by": {"type": "offset", "plane": "zx"}})).unwrap();
|
||||
s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "coordinates", "point": [1, 2, 3]}})).unwrap();
|
||||
let file = dir.join("hide.fieldcraft");
|
||||
s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let (journal, revision) = (s.journal.len(), s.revision());
|
||||
assert!(!s.is_dirty());
|
||||
|
||||
s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
|
||||
assert_eq!(s.view.hidden.iter().collect::<Vec<_>>(), [&Item { kind: ItemKind::Datum, name: "Sym".into() }]);
|
||||
// Unsaved, but no edit: nothing journalled, the revision (and so any result) unchanged.
|
||||
assert!(s.is_dirty());
|
||||
assert_eq!((s.journal.len(), s.revision()), (journal, revision));
|
||||
// Showing it again is back to the saved state.
|
||||
s.execute("view.show", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
|
||||
// Hidden state survives save and open.
|
||||
s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert!(t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P"));
|
||||
assert!(!t.is_dirty());
|
||||
|
||||
// Isolate hides every other item; show_all brings them back.
|
||||
let body = names(&t, ItemKind::Body).remove(0);
|
||||
t.execute("view.isolate", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap();
|
||||
assert!(t.view.is_hidden(ItemKind::Body, &body) && t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P"));
|
||||
t.execute("view.show_all", &json!({})).unwrap();
|
||||
assert!(t.view.is_empty());
|
||||
|
||||
// An item deleted is forgotten.
|
||||
t.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap();
|
||||
t.execute("geometry.datum.delete", &json!({"name": "P"})).unwrap();
|
||||
assert!(t.view.is_empty());
|
||||
|
||||
// Unknown items and hostile parameters change nothing.
|
||||
for p in [json!({}), json!({"items": "x"}), json!({"items": [item(ItemKind::Load, "Nope")]}), json!({"items": [{"kind": "x", "name": "A"}]})]
|
||||
{
|
||||
assert!(t.execute("view.hide", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(t.view.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
fn fresh_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-crash-test-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn build() -> Build {
|
||||
Build { version: "9.9.9", commit: "abc1234" }
|
||||
}
|
||||
|
||||
fn node(label: u32) -> Value {
|
||||
json!({"label": label, "xyz": [f64::from(label), 0.0, 0.0]})
|
||||
}
|
||||
|
||||
/// The child half of the process tests: does nothing unless a parent asks, then installs the
|
||||
/// hook and crashes the way `FIELDCRAFT_CRASH_CHILD` says.
|
||||
#[test]
|
||||
fn child() {
|
||||
let (Some(dir), Ok(mode)) = (std::env::var_os("FIELDCRAFT_CRASH_CHILD_DIR"), std::env::var("FIELDCRAFT_CRASH_CHILD")) else {
|
||||
return;
|
||||
};
|
||||
let dir = PathBuf::from(dir);
|
||||
if mode == "replay" {
|
||||
// Replay the one report (in a process of its own: the replay replaces the panic hook).
|
||||
let id = list(&dir)[0].id.clone();
|
||||
let r = replay(&read(&dir, &id).unwrap(), &std::env::temp_dir()).unwrap();
|
||||
let steps: Vec<_> = r.steps.iter().map(|(c, r)| json!({"command": c, "ok": r.is_ok()})).collect();
|
||||
println!("REPLAY {}", json!({"reproduced": r.reproduced, "nodes": r.session.model.nodes().len(), "steps": steps}));
|
||||
return;
|
||||
}
|
||||
let fresh = install(dir.clone(), build());
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
|
||||
if mode == "idle" {
|
||||
end_session();
|
||||
return;
|
||||
}
|
||||
let mut s = Session { crash_journal: true, ..Default::default() };
|
||||
s.execute("mesh.node.add", &node(1)).unwrap();
|
||||
s.execute("mesh.node.add", &node(2)).unwrap();
|
||||
// Commands that only read are not kept.
|
||||
for _ in 0..50 {
|
||||
s.execute("model.info", &json!({})).unwrap();
|
||||
}
|
||||
match mode.as_str() {
|
||||
"command" => {
|
||||
for _ in 0..3 {
|
||||
let r = s.execute("diagnostics.crash_test", &json!({"mode": "panic"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::Internal { .. })), "{r:?}");
|
||||
}
|
||||
assert_eq!(s.model.nodes().len(), 2, "the model is unchanged");
|
||||
}
|
||||
"recovered" => {
|
||||
assert_eq!(s.execute("diagnostics.crash_test", &json!({"mode": "recovered"})).unwrap()["caught"], true);
|
||||
}
|
||||
"thread" => {
|
||||
let r = std::thread::Builder::new()
|
||||
.name("worker".into())
|
||||
.spawn(|| std::panic::panic_any(String::from("worker thread 42 failed")))
|
||||
.unwrap()
|
||||
.join();
|
||||
assert!(r.is_err());
|
||||
}
|
||||
"abort" => {
|
||||
activity("control ui.capture");
|
||||
let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"}));
|
||||
}
|
||||
"autosave" => {
|
||||
// The journal starts again from each autosave.
|
||||
let mut a = crate::autosave::Autosaver::new(&dir.join("recovery")).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
s.execute("mesh.node.add", &node(3)).unwrap();
|
||||
activity("control ui.capture");
|
||||
std::process::abort();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
end_session();
|
||||
}
|
||||
|
||||
/// Run [`child`] in a new process; returns its stdout and whether it succeeded.
|
||||
fn run_child(dir: &Path, mode: &str) -> (String, bool) {
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "crash::tests::child", "--test-threads=1", "--nocapture"])
|
||||
.env("FIELDCRAFT_CRASH_CHILD_DIR", dir)
|
||||
.env("FIELDCRAFT_CRASH_CHILD", mode)
|
||||
.env("FIELDCRAFT_CRASH_TEST", "1")
|
||||
.env("RUST_BACKTRACE", "0")
|
||||
.output()
|
||||
.unwrap();
|
||||
(String::from_utf8_lossy(&out.stdout).into_owned(), out.status.success())
|
||||
}
|
||||
|
||||
fn printed(out: &str, tag: &str) -> Value {
|
||||
let line = out.lines().find_map(|l| l.split_once(tag).map(|(_, rest)| rest)).unwrap_or_else(|| panic!("no {tag} in {out}"));
|
||||
serde_json::from_str(line.trim()).unwrap()
|
||||
}
|
||||
|
||||
fn only_report(dir: &Path) -> (Summary, Value) {
|
||||
let all = list(dir);
|
||||
assert_eq!(all.len(), 1, "{all:?}");
|
||||
let s = all.into_iter().next().unwrap();
|
||||
let v = read(dir, &s.id).unwrap();
|
||||
(s, v)
|
||||
}
|
||||
|
||||
fn nodes_in(model: &str) -> usize {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.open", &json!({"path": model})).unwrap();
|
||||
s.model.nodes().len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_panic_is_reported_once_with_the_model_and_the_command_and_replays() {
|
||||
let d = fresh_dir("command");
|
||||
let (_, ok) = run_child(&d, "command");
|
||||
assert!(ok, "the panics were caught");
|
||||
let (s, v) = only_report(&d);
|
||||
assert_eq!(s.kind, "command_panic");
|
||||
assert_eq!(s.count, 3, "repeats are counted, not written again");
|
||||
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"));
|
||||
assert!(s.replayable);
|
||||
assert_eq!(v["format"], FORMAT);
|
||||
assert_eq!(v["app"]["version"], "9.9.9");
|
||||
assert_eq!(v["system"]["os"], std::env::consts::OS);
|
||||
assert!(v["message"].as_str().unwrap().contains("an internal error in a command"), "{v}");
|
||||
assert!(v["location"].as_str().unwrap().contains("diagnostics.rs"), "{v}");
|
||||
let bt = v["backtrace"].as_array().unwrap();
|
||||
assert!(bt.iter().any(|f| f.as_str().unwrap().contains("crash_test")), "{bt:?}");
|
||||
assert!(!bt.iter().any(|f| f.as_str().unwrap().contains("crash::hook")), "the hook's own frames are dropped: {bt:?}");
|
||||
assert_eq!(v["recent_commands"][0]["command"], "mesh.node.add");
|
||||
assert_eq!(v["recent_commands"].as_array().unwrap().len(), 3, "read-only commands are not kept: {v}");
|
||||
assert_eq!(v["recent_commands"][2]["ok"], false);
|
||||
// The model from before the command, and the command: together they reproduce the crash.
|
||||
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2);
|
||||
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(replay["commands"], json!([{"command": "diagnostics.crash_test", "params": {"mode": "panic"}}]));
|
||||
assert_eq!(v["reproduce"], format!("fieldcraft-cli crashes replay {}", s.id));
|
||||
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "a clean exit leaves no journal");
|
||||
// The replay reaches the same internal error, from the same model.
|
||||
let (out, ok) = run_child(&d, "replay");
|
||||
assert!(ok, "{out}");
|
||||
let r = printed(&out, "REPLAY ");
|
||||
assert_eq!(r["reproduced"], true, "{r}");
|
||||
assert_eq!(r["nodes"], 2, "the replay ends in the state the crash left: {r}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_caught_inside_a_command_is_reported_as_recovered() {
|
||||
let d = fresh_dir("recovered");
|
||||
let (_, ok) = run_child(&d, "recovered");
|
||||
assert!(ok);
|
||||
let (s, v) = only_report(&d);
|
||||
assert_eq!(s.kind, "recovered_panic");
|
||||
assert!(!s.replayable, "the command did not fail");
|
||||
assert!(v["message"].as_str().unwrap().contains("caught inside"), "{v}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_outside_commands_is_reported_from_the_journal() {
|
||||
let d = fresh_dir("thread");
|
||||
let (_, ok) = run_child(&d, "thread");
|
||||
assert!(ok);
|
||||
let (s, v) = only_report(&d);
|
||||
assert_eq!(s.kind, "panic");
|
||||
assert_eq!(v["thread"], "worker");
|
||||
assert!(s.message.contains("worker thread 42 failed"));
|
||||
// The journal's commands from a new model rebuild it.
|
||||
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(replay["commands"].as_array().unwrap().len(), 2, "{replay}");
|
||||
// A panic the hook reported is no unclean exit at the next start.
|
||||
let (out, _) = run_child(&d, "abort");
|
||||
let fresh = printed(&out, "FRESH ");
|
||||
assert_eq!(fresh.as_array().unwrap().len(), 1, "the earlier report, seen once: {fresh}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclean_exit_is_found_at_the_next_start_with_its_commands() {
|
||||
let d = fresh_dir("abort");
|
||||
let (_, ok) = run_child(&d, "abort");
|
||||
assert!(!ok, "the child aborted");
|
||||
assert!(list(&d).is_empty(), "nothing wrote a report while it died");
|
||||
assert_eq!(fs::read_dir(d.join("sessions")).unwrap().count(), 1, "its journal is left behind");
|
||||
let (out, ok) = run_child(&d, "idle");
|
||||
assert!(ok);
|
||||
let fresh = printed(&out, "FRESH ");
|
||||
assert_eq!(fresh[0]["kind"], "unclean_exit", "{fresh}");
|
||||
let (s, v) = only_report(&d);
|
||||
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"), "the command in flight");
|
||||
assert!(s.message.contains("ended without a clean exit while running `diagnostics.crash_test`"), "{}", s.message);
|
||||
assert_eq!(v["last_activity"], "control ui.capture");
|
||||
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let ids: Vec<&str> = replay["commands"].as_array().unwrap().iter().map(|c| c["command"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["mesh.node.add", "mesh.node.add", "diagnostics.crash_test"]);
|
||||
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "the dead journal is gone");
|
||||
// Told once: the next start offers nothing new.
|
||||
let (out, _) = run_child(&d, "idle");
|
||||
assert_eq!(printed(&out, "FRESH "), json!([]));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclean_exit_after_an_autosave_starts_from_the_autosave() {
|
||||
let d = fresh_dir("autosave");
|
||||
let (_, ok) = run_child(&d, "autosave");
|
||||
assert!(!ok);
|
||||
run_child(&d, "idle");
|
||||
let (s, v) = only_report(&d);
|
||||
assert_eq!(s.kind, "unclean_exit");
|
||||
assert!(s.message.contains("between commands (last: control ui.capture)"), "{}", s.message);
|
||||
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2, "the autosaved model");
|
||||
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(replay["commands"], json!([{"command": "mesh.node.add", "params": node(3)}]), "only what came after the autosave");
|
||||
let (out, ok) = run_child(&d, "replay");
|
||||
assert!(ok, "{out}");
|
||||
assert_eq!(printed(&out, "REPLAY ")["nodes"], 3, "the replay rebuilds the model the app had");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journals_parse_and_hostile_ids_are_refused() {
|
||||
let text = [
|
||||
r#"{"start":"x","pid":1,"version":"1"}"#,
|
||||
r#"{"base":"new"}"#,
|
||||
r#"{"run":"a","params":{}}"#,
|
||||
r#"{"ok":true}"#,
|
||||
r#"{"activity":"control ui.capture"}"#,
|
||||
r#"{"run":"b","params":{"x":1}}"#,
|
||||
r#"{"ok":false,"error":"no"}"#,
|
||||
"not json",
|
||||
r#"{"run":"c","params":{}}"#,
|
||||
]
|
||||
.join("\n");
|
||||
let j = parse_journal(&text);
|
||||
assert_eq!(j.replay, vec![json!({"command": "a", "params": {}})]);
|
||||
assert_eq!(j.recent.len(), 2);
|
||||
assert_eq!(j.in_flight, Some(json!({"command": "c", "params": {}})));
|
||||
assert_eq!(j.activity.as_deref(), Some("control ui.capture"));
|
||||
assert!(j.complete && !j.reported);
|
||||
let d = fresh_dir("hostile");
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
for id in ["", "../x", "a/b", &"f".repeat(65)] {
|
||||
assert!(read(&d, id).is_err(), "{id}");
|
||||
assert_eq!(clear(&d, Some(id)), 0);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_ignore_numbers() {
|
||||
assert_eq!(signature("a.rs:1", "node 12 of 400"), signature("a.rs:1", "node 7 of 3"));
|
||||
assert_ne!(signature("a.rs:1", "node 12"), signature("a.rs:2", "node 12"));
|
||||
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(stamp(1_760_436_000), "20251014-100000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_are_pruned_to_the_limits() {
|
||||
let d = fresh_dir("prune");
|
||||
for i in 0..(MAX_REPORTS + 5) {
|
||||
let id = format!("20261014-1000{:02}-{:016x}", i % 60, i + 1);
|
||||
fs::create_dir_all(d.join(&id)).unwrap();
|
||||
let kind = if i == MAX_REPORTS + 4 { "recovered_panic" } else { "panic" };
|
||||
fs::write(d.join(&id).join("report.json"), json!({"kind": kind, "message": "m"}).to_string()).unwrap();
|
||||
}
|
||||
prune(&d, now());
|
||||
let left = list(&d);
|
||||
assert_eq!(left.len(), MAX_REPORTS);
|
||||
assert!(left.iter().all(|r| r.kind == "panic"), "recovered ones go first");
|
||||
assert_eq!(clear(&d, None), MAX_REPORTS);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||