Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ccf10692 | ||
|
|
1e5ff08c5f | ||
|
|
8f8124dfa1 | ||
|
|
1f2446935f | ||
|
|
1daee9d969 | ||
|
|
8cd37e0a68 | ||
|
|
60fd0998f5 | ||
|
|
74dce59c7d | ||
|
|
c1941ad349 | ||
|
|
8a178879ee | ||
|
|
1dd2afe525 | ||
|
|
53a370ed01 | ||
|
|
ee09ff2fca | ||
|
|
711e51074c | ||
|
|
e52eda728f | ||
|
|
99b0b0cfa4 | ||
|
|
190f958b23 | ||
|
|
939b64e660 | ||
|
|
ea284caf80 | ||
|
|
7d419b73f4 | ||
|
|
e315b2bef7 | ||
|
|
2729511cb5 | ||
|
|
b5f71612af | ||
|
|
b2fcf2c5bc | ||
|
|
8dce4b14ce | ||
|
|
18be60672b | ||
|
|
a0c7d6ce9d | ||
|
|
f7dcd46b2f | ||
|
|
0d30ddb747 | ||
|
|
1e0c21d088 | ||
|
|
48cc80bef5 | ||
|
|
b123f31d47 | ||
|
|
944a54c76b | ||
|
|
417c0c8897 | ||
|
|
d7444edac7 | ||
|
|
6b4a966e1a | ||
|
|
2ffbb9b978 | ||
|
|
e955b67bb7 | ||
|
|
3977a0b630 | ||
|
|
ec8e165fbb | ||
|
|
a2a4f8895e | ||
|
|
426df4a333 | ||
|
|
2e639336c3 | ||
|
|
8e5072edf0 | ||
|
|
f2ea44b62d | ||
|
|
8ad0800aa6 | ||
|
|
729a770d6c | ||
|
|
5db21aba7b | ||
|
|
2456912850 | ||
|
|
700a7e3d10 | ||
|
|
f297ffca3a | ||
|
|
3f0cae70a2 | ||
|
|
e9ef7f9d4d | ||
|
|
9c1c8f75c2 | ||
|
|
20281e5959 | ||
|
|
030799dfc8 | ||
|
|
b62be1ada6 | ||
|
|
c611080182 | ||
|
|
4d0790565c | ||
|
|
950d86c54e | ||
|
|
e5ac6d35ce | ||
|
|
85b117ec7c | ||
|
|
5a4c501524 | ||
|
|
effb5a7a14 | ||
|
|
54e367970f | ||
|
|
7cd3dcfaa0 | ||
|
|
a2e747b9ba | ||
|
|
09385f069f | ||
|
|
6c79498005 | ||
|
|
e8e3fc647c | ||
|
|
59a02b0172 | ||
|
|
a5c71bc5b0 | ||
|
|
cb4141d0f0 | ||
|
|
bb3def1cf5 | ||
|
|
72ebd5bc7f | ||
|
|
68e9bcde92 | ||
|
|
3da332be77 | ||
|
|
56553909f1 | ||
|
|
cd6c53adbb | ||
|
|
4908f5679c | ||
|
|
6f94346253 | ||
|
|
7f2d4cb6e1 | ||
|
|
b3a7f12472 | ||
|
|
3a5a8213a1 | ||
|
|
4e6fd59409 | ||
|
|
12b3128f53 | ||
|
|
8cd7b43c35 | ||
|
|
793fbe3b71 | ||
|
|
db0f0567a0 | ||
|
|
fea61fb180 | ||
|
|
58174ba168 | ||
|
|
ce02d263c7 | ||
|
|
22d709e604 | ||
|
|
b126c2293b | ||
|
|
f38a3be917 | ||
|
|
cbac4c7e27 | ||
|
|
38678253fa | ||
|
|
b88a8b7093 | ||
|
|
e4233ff794 | ||
|
|
7b7d38fabd | ||
|
|
6f07e4c09c | ||
|
|
e887e3a8e5 | ||
|
|
d5fb100182 | ||
|
|
937e557e5a | ||
|
|
18dbc29357 | ||
|
|
fcc068bce9 | ||
|
|
7c3107dec0 | ||
|
|
992bc71b6b | ||
|
|
a226b7dcac | ||
|
|
ac1037e1af | ||
|
|
a079e51f9d | ||
|
|
b74d15e44d | ||
|
|
7d269c58f9 | ||
|
|
3ee622f62a | ||
|
|
4bb2134b47 | ||
|
|
655a60c138 | ||
|
|
3ee542453e | ||
|
|
78b8fc8d6c | ||
|
|
6136770c1c | ||
|
|
b655af305a | ||
|
|
7f0af28e9c | ||
|
|
7714afbced | ||
|
|
eae709b3c0 | ||
|
|
80da2d968b | ||
|
|
c6bc4d2ba0 | ||
|
|
b905f1bc89 | ||
|
|
4ad793ca5e | ||
|
|
c160712343 | ||
|
|
19c0538dd8 | ||
|
|
1f183e04ea | ||
|
|
4fa6292749 | ||
|
|
927accce45 | ||
|
|
4a8d34d619 | ||
|
|
9a8469cf4b | ||
|
|
cbd60a7985 | ||
|
|
47e92eebb1 | ||
|
|
bf61ae8406 | ||
|
|
4d6d69ee6d | ||
|
|
e79ba866a1 | ||
|
|
f8b92fcd89 | ||
|
|
9b705f5334 | ||
|
|
98e96921e6 | ||
|
|
d17cf414f7 | ||
|
|
e4d1f6c7da | ||
|
|
b18195ff89 | ||
|
|
61f0d42ec4 | ||
|
|
1c18a463e7 | ||
|
|
af9b4bb222 | ||
|
|
ed25de6521 | ||
|
|
ead489592c | ||
|
|
7a5363e07b | ||
|
|
9a30c7d9ed | ||
|
|
0b7dda95f7 | ||
|
|
02b408c499 | ||
|
|
3d38753cad | ||
|
|
1af87f1f98 | ||
|
|
e9801e4528 | ||
|
|
75b8c7e26e | ||
|
|
33d69fc53d | ||
|
|
44de124d9d | ||
|
|
5cee94f947 | ||
|
|
06c4a8d73d | ||
|
|
f9a0282acc | ||
|
|
439dfc678f | ||
|
|
68016f69f7 | ||
|
|
92d49debec | ||
|
|
1d71a9f8af | ||
|
|
87710a83ab | ||
|
|
eecdcb5b96 | ||
|
|
464417d84d | ||
|
|
c107ea8105 | ||
|
|
390ddcd6cc | ||
|
|
c9163e98ad | ||
|
|
fee978c7e4 | ||
|
|
248e74ca9e | ||
|
|
575ae10dbf | ||
|
|
58d3b351ff | ||
|
|
5306450413 | ||
|
|
d29334a7a7 | ||
|
|
361ac2e7b3 | ||
|
|
febd40fadc | ||
|
|
4dadaa50aa | ||
|
|
e468133f9a | ||
|
|
c82c13019a | ||
|
|
d703cdde0b | ||
|
|
c077131285 | ||
|
|
4ac31d3988 | ||
|
|
600b2ff633 | ||
|
|
d6b336538a | ||
|
|
efe90ec50e | ||
|
|
8dd7446569 | ||
|
|
4cfb6dd3de | ||
|
|
d3ac6395bb | ||
|
|
62fc00755a | ||
|
|
d781d6e427 | ||
|
|
6efad8feeb | ||
|
|
8092c39892 | ||
|
|
4610876384 | ||
|
|
2be6aff2f9 | ||
|
|
991dd354cb | ||
|
|
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 |
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
|
||||
@@ -24,7 +24,8 @@ body:
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
|
||||
description: |
|
||||
Pictures first: for anything you can see (the view, a mesh, a panel, a plot), drag a screenshot or a short screen recording in here, and mark the problem on it. For a mesh, a section through it (`fieldcraft-cli snapshot … --section`) shows what the outside hides. Then log output and numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
@@ -41,6 +42,6 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version and platform
|
||||
placeholder: "v0.5.9 (or commit), macOS 15 arm64"
|
||||
placeholder: "v0.6.0 (or commit), macOS 15 arm64"
|
||||
validations:
|
||||
required: true
|
||||
@@ -21,6 +21,11 @@ body:
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What FieldCraft should do, as precisely as you can.
|
||||
- type: textarea
|
||||
id: pictures
|
||||
attributes:
|
||||
label: Pictures
|
||||
description: A screenshot of where it would go, a sketch, or a picture of the result you are after. Drag images in here.
|
||||
- type: textarea
|
||||
id: reference
|
||||
attributes:
|
||||
|
||||
@@ -2,10 +2,14 @@ Closes #
|
||||
|
||||
## What and why
|
||||
|
||||
## Before and after
|
||||
<!-- For any visible change (the view, meshes, panels, results, icons): a picture before and after. Drag them in here. -->
|
||||
|
||||
## How it was verified
|
||||
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
|
||||
|
||||
## Checklist
|
||||
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
|
||||
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
|
||||
- [ ] Before/after pictures for any visible change
|
||||
- [ ] No code, text or data from GPL-family projects or commercial CAE products
|
||||
@@ -1,13 +1,14 @@
|
||||
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
|
||||
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
|
||||
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
|
||||
[README](https://github.com/jyviko/fieldcraft#readme) ·
|
||||
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
|
||||
[issues](https://github.com/jyviko/fieldcraft/issues)
|
||||
[README](https://github.com/tangency-labs/fieldcraft#readme) ·
|
||||
[roadmap](https://github.com/tangency-labs/fieldcraft/blob/main/ROADMAP.md) ·
|
||||
[issues](https://github.com/tangency-labs/fieldcraft/issues)
|
||||
|
||||
**Which file?**
|
||||
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
|
||||
drag `FieldCraft.app` onto Applications. The command-line tool is in
|
||||
drag `FieldCraft.app` onto Applications. The command-line tool is included at
|
||||
`/Applications/FieldCraft.app/Contents/MacOS/fieldcraft-cli` and also available separately in
|
||||
`fieldcraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
|
||||
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
|
||||
|
||||
@@ -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,56 +54,128 @@ 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: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || '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]')
|
||||
runs-on: ubuntu-24.04
|
||||
needs: plan
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || '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
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
# Shared keys: release.yml's verify jobs restore these caches (they save only on main).
|
||||
shared-key: ci
|
||||
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: Software GPU (lavapipe), so headless snapshots run the GPU pipeline
|
||||
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: cargo xtask ci (the affected crates, or everything)
|
||||
env:
|
||||
BASE: ${{ needs.plan.outputs.base }}
|
||||
# The drawing tests run on lavapipe and fail, instead of skipping, without it.
|
||||
FIELDCRAFT_GPU: software
|
||||
FIELDCRAFT_REQUIRE_GPU: "1"
|
||||
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'
|
||||
runs-on: ubuntu-24.04
|
||||
needs: plan
|
||||
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || '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:
|
||||
shared-key: heavy
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask test --heavy
|
||||
|
||||
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]')
|
||||
runs-on: ubuntu-24.04
|
||||
needs: plan
|
||||
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
shared-key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
@@ -113,20 +193,37 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle-macos
|
||||
shared-key: oracle-macos
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
windows:
|
||||
# The desktop app's tests on Windows, nightly and on demand: the Windows-only code
|
||||
# (apps/fieldcraft/src/native_crash.rs, the exception filter) builds and a child process that
|
||||
# faults on purpose leaves its native crash report (#299). Releases build Windows too.
|
||||
name: Windows app tests
|
||||
if: github.event.schedule == '23 3 * * *' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-latest
|
||||
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:
|
||||
shared-key: windows
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo test --locked --profile ci -p fieldcraft
|
||||
|
||||
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
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
- 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}"
|
||||
@@ -1,4 +1,4 @@
|
||||
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://jyviko.github.io/fieldcraft/
|
||||
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://tangency-labs.github.io/fieldcraft/
|
||||
# After SolidCraft's pages.yml (MIT OR Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# A push to `main` (a release; main holds released code only) builds the app with Trunk (release,
|
||||
@@ -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:
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# 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, .AppImage, .deb and .rpm packaging/linux
|
||||
@@ -16,7 +17,14 @@
|
||||
# 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
|
||||
# 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. The packaging jobs build in
|
||||
# parallel with them and only upload workflow artifacts; the `release` job, the only one that
|
||||
# signs SHA256SUMS.txt or touches a GitHub Release, needs every verify and packaging job, so a
|
||||
# failing verify means no draft and no signature.
|
||||
#
|
||||
# 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
|
||||
@@ -40,8 +48,10 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# The same CARGO_* environment as ci.yml: rust-cache hashes it into the cache key, so a
|
||||
# difference would stop the verify jobs restoring develop's caches. (rust-cache already sets
|
||||
# CARGO_INCREMENTAL=0 for every job.)
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -70,9 +80,68 @@ 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: the `release` job needs them,
|
||||
# so a release never drafts or signs a commit that fails any of them. Each restores the cache of
|
||||
# its ci.yml counterpart on develop (same shared-key), so it starts warm.
|
||||
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:
|
||||
shared-key: ci
|
||||
# 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:
|
||||
shared-key: heavy
|
||||
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: oracle
|
||||
- os: macos-14
|
||||
cache: 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:
|
||||
shared-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
|
||||
# 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:
|
||||
@@ -89,6 +158,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 }}
|
||||
@@ -118,6 +189,9 @@ jobs:
|
||||
name: Linux x86_64
|
||||
needs: version
|
||||
# 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:
|
||||
@@ -137,6 +211,8 @@ jobs:
|
||||
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: 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
|
||||
@@ -201,6 +277,9 @@ jobs:
|
||||
windows:
|
||||
name: Windows x64
|
||||
needs: version
|
||||
# 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:
|
||||
@@ -215,6 +294,8 @@ 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:
|
||||
@@ -229,7 +310,11 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft release (or dry-run artifacts)
|
||||
needs: [version, macos, linux, windows]
|
||||
# The only job that publishes anything: it waits for every verify and packaging job.
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle, 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
|
||||
@@ -246,12 +331,16 @@ jobs:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- 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}_amd64.deb" "fieldcraft-$VERSION.x86_64.rpm" \
|
||||
"fieldcraft-$VERSION-windows-x64.zip"; do
|
||||
"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)
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
__pycache__/
|
||||
/THIRD-PARTY-LICENSES.txt
|
||||
@@ -24,7 +24,9 @@ FieldCraft everywhere: crates `fieldcraft-*`, binaries `fieldcraft` and `fieldcr
|
||||
## Never crash
|
||||
People trust FieldCraft with their models; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception, in the app crate
|
||||
(which denies it instead), has no safe API: the Windows-only `apps/fieldcraft/src/native_crash.rs`
|
||||
(the exception filter that records crashes inside native code such as graphics drivers).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, model files, solver decks,
|
||||
@@ -119,20 +121,25 @@ 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
|
||||
- **Publishing**: `origin` is github.com/tangency-labs/fieldcraft. Changes land through pull requests
|
||||
as the workflow above describes; the coordinator reads every merged diff and sends findings
|
||||
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
|
||||
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
|
||||
|
||||
@@ -24,13 +24,27 @@ is committed and listed here with its exports.
|
||||
| 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 |
|
||||
| crates/ui-egui/thumbnails/bracket-hex.png | FieldCraft contributors | Original: the viewport of docs/media/examples/bracket-hex.png 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 viewport of docs/media/examples/crank-arm.png 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 viewport of docs/media/examples/crank-mirror.png 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 viewport of docs/media/examples/drop-test.png 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 viewport of docs/media/examples/heated-bracket.png 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 viewport of docs/media/examples/rubber-seal.png 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 |
|
||||
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.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 |
|
||||
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | 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 |
|
||||
@@ -44,6 +58,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/file.save_as.svg, assets/icons/file.save_as.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save_as.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| 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.check.svg, assets/icons/geometry.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.check.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 |
|
||||
@@ -51,6 +66,11 @@ is committed and listed here with its exports.
|
||||
| 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 |
|
||||
| assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.defeature.svg, assets/icons/geometry.defeature.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.defeature.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -58,6 +78,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 |
|
||||
@@ -74,19 +96,26 @@ is committed and listed here with its exports.
|
||||
| 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.node.move.svg, assets/icons/mesh.node.move.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.move.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.quality.mark.svg, assets/icons/mesh.quality.mark.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.mark.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.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.set.svg, assets/icons/mesh.size.set.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.set.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 |
|
||||
| assets/icons/mesh.tet.generate.svg, assets/icons/mesh.tet.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tet.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.extrude.svg, assets/icons/mesh.hex.extrude.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.extrude.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.plan.svg, assets/icons/mesh.plan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.plan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.verify.svg, assets/icons/mesh.verify.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.verify.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.activity.svg, assets/icons/model.activity.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.activity.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -182,12 +211,12 @@ is committed and listed here with its exports.
|
||||
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
|
||||
| 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/gpu/src/view.rs (viewport pipelines and shaders), 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 |
|
||||
| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, startup steps, native crash reports, report folders and limits, replay), apps/fieldcraft/src/native_crash.rs (Windows exception filter and minidump), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft/src/native_crash.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 |
|
||||
|
||||
@@ -15,7 +15,7 @@ needed most, and how to claim work. The rules every change follows, by a person
|
||||
You need stable Rust 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
|
||||
git clone https://github.com/tangency-labs/fieldcraft && cd fieldcraft
|
||||
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
|
||||
cargo run -p fieldcraft-cli -- commands # every command and its parameters
|
||||
cargo test -p fieldcraft-solver # the tests of one crate
|
||||
@@ -28,32 +28,32 @@ cargo xtask oracle --quick # the verification ca
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
|
||||
See the [`help wanted`](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) labels, and
|
||||
the `area:` labels for your field. In order of how much they would help users:
|
||||
|
||||
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
|
||||
1. **Reproducible meshes** ([#115](https://github.com/tangency-labs/fieldcraft/issues/115)). The same part
|
||||
must give the same tet mesh on every platform and every run, as the solvers already do.
|
||||
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
|
||||
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/tangency-labs/fieldcraft/issues/85)).
|
||||
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
|
||||
are next. Hex meshing is the centre of gravity of the project.
|
||||
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
|
||||
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
|
||||
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
|
||||
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
|
||||
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
|
||||
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
|
||||
3. **Getting results in and out**: ParaView export ([#90](https://github.com/tangency-labs/fieldcraft/issues/90)),
|
||||
Gmsh import ([#91](https://github.com/tangency-labs/fieldcraft/issues/91)), Abaqus deck reading
|
||||
([#98](https://github.com/tangency-labs/fieldcraft/issues/98)), CSV and STL
|
||||
([#92](https://github.com/tangency-labs/fieldcraft/issues/92), [#93](https://github.com/tangency-labs/fieldcraft/issues/93)).
|
||||
4. **Verification benchmarks** ([#97](https://github.com/tangency-labs/fieldcraft/issues/97),
|
||||
[#106](https://github.com/tangency-labs/fieldcraft/issues/106)). An engineer with a published benchmark
|
||||
can add it as an oracle case without touching solver code.
|
||||
5. **Nonlinear elements and materials**: incompatible modes at finite strain
|
||||
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
|
||||
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
|
||||
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
|
||||
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
|
||||
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
|
||||
([#82](https://github.com/tangency-labs/fieldcraft/issues/82)), kinematic hardening
|
||||
([#81](https://github.com/tangency-labs/fieldcraft/issues/81)), and the material library
|
||||
([#99](https://github.com/tangency-labs/fieldcraft/issues/99)).
|
||||
6. **The app**: typed command forms ([#112](https://github.com/tangency-labs/fieldcraft/issues/112)), XY and
|
||||
path plots ([#94](https://github.com/tangency-labs/fieldcraft/issues/94), [#95](https://github.com/tangency-labs/fieldcraft/issues/95)).
|
||||
|
||||
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
|
||||
propagation, turbulence — are open to anyone who wants to own one: start a thread in
|
||||
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
|
||||
[Discussions](https://github.com/tangency-labs/fieldcraft/discussions).
|
||||
|
||||
## Before you start: claim an issue
|
||||
|
||||
@@ -68,6 +68,13 @@ Every change starts from an issue, and only the maintainer merges.
|
||||
|
||||
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
|
||||
|
||||
## Reporting a problem or an idea
|
||||
|
||||
Use the templates under **New issue**. Show it: for anything you can see — the view, a mesh, a
|
||||
panel, a plot — add a screenshot or a short screen recording (drag it into the issue) and mark
|
||||
the problem on it. For a mesh, a section (`fieldcraft-cli snapshot … --section`) shows what the
|
||||
outside hides. A picture is often the fastest way for someone else to see what you see.
|
||||
|
||||
## Making the change
|
||||
|
||||
1. Branch from `develop` (the trunk; `main` holds released code only) and open the pull request
|
||||
@@ -78,7 +85,9 @@ Issues labelled **in progress** are being worked on by the core team; please don
|
||||
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
|
||||
parameters.
|
||||
4. Meshing and solver algorithms cite the paper they come from in the module docs.
|
||||
5. Never contribute code, text or data from GPL-family projects or commercial CAE products
|
||||
5. A change you can see (the view, meshes, panels, results, icons) shows a picture before and after
|
||||
in the pull request.
|
||||
6. Never contribute code, text or data from GPL-family projects or commercial CAE products
|
||||
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
|
||||
|
||||
By contributing you agree your work is licensed under MIT OR Apache-2.0.
|
||||
|
||||
@@ -425,12 +425,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
@@ -1052,7 +1046,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1270,37 +1264,44 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-ui-egui",
|
||||
"fieldcraft-update",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"rfd",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"signal-hook",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-mcp",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
"png",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1309,7 +1310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1328,12 +1329,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1344,7 +1345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1352,10 +1353,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-engine",
|
||||
@@ -1363,12 +1365,24 @@ dependencies = [
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-gpu"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-render",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
@@ -1376,12 +1390,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1389,11 +1403,25 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm-gpu"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-lbm",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-testkit",
|
||||
@@ -1402,9 +1430,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-parallel",
|
||||
"libm",
|
||||
"num-bigint",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
@@ -1414,7 +1444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1423,14 +1453,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1440,7 +1470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1452,18 +1482,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-parallel",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1472,6 +1503,7 @@ dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-explicit",
|
||||
"fieldcraft-geometry",
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
@@ -1484,7 +1516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-update"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -1496,14 +1528,14 @@ dependencies = [
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"zip",
|
||||
"zip 9.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fieldcraft-engine",
|
||||
@@ -2208,7 +2240,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -2241,9 +2273,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "minisign"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586"
|
||||
checksum = "3062dbbf4d8f57899e2252804f7512a4fd89c8211c58fa6c786d38794a0c809d"
|
||||
dependencies = [
|
||||
"ct-codecs",
|
||||
"getrandom 0.4.3",
|
||||
@@ -2355,6 +2387,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"
|
||||
@@ -3087,6 +3131,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"
|
||||
@@ -3639,6 +3692,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -3792,7 +3855,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3809,7 +3872,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3826,7 +3889,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3835,9 +3898,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
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",
|
||||
@@ -3845,13 +3908,13 @@ dependencies = [
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3870,9 +3933,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"png",
|
||||
"serde",
|
||||
"solidcraft-geom",
|
||||
]
|
||||
@@ -3880,7 +3944,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"solidcraft-geom",
|
||||
@@ -4228,7 +4292,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-meshalgo"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"array-macro",
|
||||
"derive_more",
|
||||
@@ -4278,7 +4342,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-shapeops"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"itertools 0.13.0",
|
||||
@@ -4293,7 +4357,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-stepio"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=b1695873f5d0031f51a8d5e7ec036fced6f000e2#b1695873f5d0031f51a8d5e7ec036fced6f000e2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"truck-geometry",
|
||||
@@ -4333,6 +4397,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"
|
||||
@@ -4386,7 +4456,7 @@ version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
@@ -4402,7 +4472,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
@@ -4697,7 +4767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -5356,7 +5426,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5507,6 +5577,21 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -3,12 +3,12 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.9"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
authors = ["FieldCraft contributors"]
|
||||
repository = "https://github.com/jyviko/fieldcraft"
|
||||
repository = "https://github.com/tangency-labs/fieldcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
@@ -28,6 +28,8 @@ fieldcraft-render = { path = "crates/render" }
|
||||
fieldcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
fieldcraft-mcp = { path = "crates/mcp" }
|
||||
fieldcraft-update = { path = "crates/update" }
|
||||
fieldcraft-gpu = { path = "crates/gpu" }
|
||||
fieldcraft-lbm-gpu = { path = "crates/lbm-gpu" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -35,9 +37,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"
|
||||
@@ -51,6 +53,9 @@ resvg = { version = "0.48", default-features = false }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
# the wgpu egui-wgpu uses, for the offscreen viewport (crates/gpu); backends come per target there
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
|
||||
pollster = "1.0"
|
||||
# Shared Craft-family egui components (icon buttons, tabs, widget palette); the same rev as
|
||||
# VectorCraft. MIT OR Apache-2.0.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675" }
|
||||
@@ -59,13 +64,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 = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
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 = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -129,10 +134,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 = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
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 = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
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.
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ see the results — with a nonlinear implicit solver (large deformation, plastic
|
||||
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
|
||||
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
|
||||
**Want to help?** Pick an issue labelled
|
||||
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
|
||||
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
|
||||
[good first issue](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) or
|
||||
[help wanted](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and read
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||

|
||||
@@ -28,15 +28,15 @@ theory with shear 0.177 mm).
|
||||
|
||||
## Download
|
||||
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://jyviko.github.io/fieldcraft/)
|
||||
[](https://github.com/tangency-labs/fieldcraft/releases/latest)
|
||||
[](https://github.com/tangency-labs/fieldcraft/releases/latest)
|
||||
[](https://github.com/tangency-labs/fieldcraft/releases/latest)
|
||||
[](https://tangency-labs.github.io/fieldcraft/)
|
||||
|
||||
- **macOS:** open the `.dmg` and drag FieldCraft into Applications (Apple silicon and Intel).
|
||||
- **Windows:** unzip and run `fieldcraft.exe`.
|
||||
- **Linux:** the `.AppImage` (allow it to run as a program), or the `.tar.gz`.
|
||||
- **Browser:** [try it at jyviko.github.io/fieldcraft](https://jyviko.github.io/fieldcraft/), no install.
|
||||
- **Browser:** [try it at tangency-labs.github.io/fieldcraft](https://tangency-labs.github.io/fieldcraft/), no install.
|
||||
- **Command line:** `fieldcraft-cli` ships in every package; AI agents connect to it over MCP
|
||||
([docs/mcp.md](docs/mcp.md)).
|
||||
|
||||
@@ -114,10 +114,11 @@ against a closed form or a reference solution ([docs/examples.md](docs/examples.
|
||||
## 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
|
||||
cargo run -p fieldcraft-cli -- snapshot script.json --out shot.png # draw it to a PNG, no window
|
||||
cargo run -p fieldcraft-cli -- mcp # MCP server on stdio for AI agents (docs/mcp.md)
|
||||
```
|
||||
|
||||
@@ -148,7 +149,8 @@ you review and submit yourself, with your session or model attached only if you
|
||||
## 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,17 +28,17 @@ 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, 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 |
|
||||
| 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; size controls done (#435): `mesh.size.set` on faces, bodies, a box or a sphere with a growth rate (`mesh.size.growth`), saved with the model, listed by `mesh.size.list`, drawn in the viewport, honoured by the tet mesher's graded field, the structured hex meshers' and the block projection's divisions and the octree's depth); 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); 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 and orphan-mesh import | 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 |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
|
||||
@@ -50,10 +50,47 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
|
||||
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
|
||||
| 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: 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; 62 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), 11 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | 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; 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
|
||||
|
||||
In progress, in this order of landing:
|
||||
@@ -86,3 +123,10 @@ What exists and what is missing, by area; each gap is in a milestone above.
|
||||
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
|
||||
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
|
||||
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
|
||||
|
||||
### Heated flow results (#157)
|
||||
|
||||
Temperature is available on fluid slices, obstacle surfaces and streamline colours when the
|
||||
saved frames carry it. Signed face-average wall heat follows recorded histories and the
|
||||
model's power/area units. Commands, the Results panel and headless pictures share the view;
|
||||
initial frames without recorded wall heat remain uncoloured.
|
||||
@@ -3,7 +3,7 @@
|
||||
FieldCraft is young; only `main` and the latest release get fixes.
|
||||
|
||||
Please **don't open a public issue** for a security problem. Report it privately through the
|
||||
repository's [Security tab](https://github.com/jyviko/fieldcraft/security/advisories/new) with
|
||||
repository's [Security tab](https://github.com/tangency-labs/fieldcraft/security/advisories/new) with
|
||||
what you found, how to reproduce it (a model, deck or script), the version and platform, and the
|
||||
impact you expect.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fieldcraft-cli"
|
||||
description = "FieldCraft from the command line: list commands, run scripts, serve MCP"
|
||||
description = "FieldCraft from the command line: list commands, run scripts, draw snapshots, serve MCP"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -10,10 +10,14 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
fieldcraft-render = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-mcp = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
png = { workspace = true }
|
||||
fieldcraft-update = { workspace = true, features = ["mock"] }
|
||||
@@ -1,5 +1,6 @@
|
||||
//! fieldcraft-cli commands
|
||||
//! fieldcraft-cli run <script.json>
|
||||
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V | --camera JSON] [--size WxH] [--section x|y|z[=V]] [--field F]
|
||||
//! 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>]]
|
||||
@@ -9,6 +10,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod crashes;
|
||||
mod snapshot;
|
||||
mod update;
|
||||
|
||||
use std::path::Path;
|
||||
@@ -23,6 +25,19 @@ usage:
|
||||
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
|
||||
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
|
||||
and print the model summary
|
||||
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V | --camera JSON]
|
||||
[--size WxH] [--section x|y|z[=VALUE]] [--field FIELD]
|
||||
run a script and draw the model to a PNG with no
|
||||
window: the viewport's GPU pipeline on a hardware or
|
||||
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
|
||||
auto|hardware|software|cpu); V: iso (default), x, y, z,
|
||||
front, back, left, right, top, bottom; size 1280x800;
|
||||
--section hides the elements beyond a plane (at the
|
||||
mesh's middle, or VALUE); --field contours a nodal
|
||||
field of the solved step (results.contour);
|
||||
--camera '{\"yaw\":30,\"pitch\":20,\"target\":...,
|
||||
\"fit\":true,\"fov\":35}' aims as MCP screenshot's camera
|
||||
(default target: the model's centre of mass)
|
||||
fieldcraft-cli features print docs/features.md (every capability, ready or
|
||||
coming soon) from the capability catalogue
|
||||
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
|
||||
@@ -48,6 +63,8 @@ usage:
|
||||
const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP).
|
||||
fieldcraft_lbm_gpu::register();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
|
||||
@@ -67,6 +84,7 @@ fn main() -> ExitCode {
|
||||
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("snapshot") => snapshot::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"));
|
||||
@@ -135,7 +153,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
|
||||
}
|
||||
|
||||
fn run(path: &str) -> Result<(), String> {
|
||||
/// The JSON report of a picture: what drew it and what it shows (the MCP `screenshot` reply's
|
||||
/// fields).
|
||||
pub fn picture_report(r: &fieldcraft_gpu::Renderer, shown: &fieldcraft_engine::view::Shown) -> serde_json::Value {
|
||||
fieldcraft_mcp::picture_report(r, shown)
|
||||
}
|
||||
|
||||
/// The session after running the script at `path`.
|
||||
fn session_of(path: &str) -> Result<Session, String> {
|
||||
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
if size > MAX_SCRIPT_BYTES {
|
||||
return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024)));
|
||||
@@ -144,6 +169,11 @@ fn run(path: &str) -> Result<(), String> {
|
||||
let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::default();
|
||||
session.run_script(&script).map_err(|e| e.to_string())?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn run(path: &str) -> Result<(), String> {
|
||||
let mut session = session_of(path)?;
|
||||
let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?;
|
||||
println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! `fieldcraft-cli snapshot`: a script in, a PNG out, with no window and no display. The picture
|
||||
//! is the viewport's: the engine's scene ([`fieldcraft_engine::view::frame`]) drawn with the
|
||||
//! window's GPU pipelines ([`fieldcraft_gpu::capture`]) on a hardware GPU or a software one
|
||||
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
|
||||
//! [`fieldcraft_gpu::select`]).
|
||||
|
||||
use fieldcraft_engine::view::{self, Aim, Palette, Section, Target};
|
||||
use fieldcraft_gpu::Renderer;
|
||||
use fieldcraft_gpu::capture::{self, Capturer};
|
||||
use fieldcraft_gpu::select::Policy;
|
||||
use fieldcraft_render::{Orientation, StandardView};
|
||||
|
||||
/// The picture size when `--size` is not given.
|
||||
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Options {
|
||||
pub script: String,
|
||||
pub out: String,
|
||||
pub view: StandardView,
|
||||
/// A camera aimed as the MCP `screenshot`'s (`--camera JSON`; not with `--view`).
|
||||
pub camera: Option<Aim>,
|
||||
pub size: (u32, u32),
|
||||
/// A section through the mesh (`--section x|y|z[=VALUE]`).
|
||||
pub section: Option<Section>,
|
||||
/// A nodal field contoured on the last solved step (`--field von_mises`).
|
||||
pub field: Option<String>,
|
||||
}
|
||||
|
||||
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
|
||||
/// front, `z`: from the top).
|
||||
fn view_of(s: &str) -> Option<StandardView> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"x" => Some(StandardView::Right),
|
||||
"y" => Some(StandardView::Front),
|
||||
"z" => Some(StandardView::Top),
|
||||
other => StandardView::parse(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// `WxH`, each side 1..=the renderer's limit.
|
||||
fn size_of(s: &str) -> Option<(u32, u32)> {
|
||||
let (w, h) = s.split_once(['x', 'X'])?;
|
||||
let (w, h) = (w.trim().parse::<u32>().ok()?, h.trim().parse::<u32>().ok()?);
|
||||
let max = fieldcraft_gpu::capture::MAX_SIDE;
|
||||
((1..=max).contains(&w) && (1..=max).contains(&h) && u64::from(w) * u64::from(h) <= fieldcraft_gpu::capture::MAX_PIXELS).then_some((w, h))
|
||||
}
|
||||
|
||||
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
|
||||
pub fn options(args: &[String]) -> Result<Options, String> {
|
||||
let (mut script, mut out, mut view, mut size, mut section, mut field, mut camera) = (None, None, None, None, None, None, None);
|
||||
let mut it = args.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if !arg.starts_with("--") {
|
||||
if script.replace(arg.clone()).is_some() {
|
||||
return Err(format!("snapshot takes one script, not `{arg}` too"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((f, v)) => (f, Some(v.to_string())),
|
||||
None => (arg.as_str(), None),
|
||||
};
|
||||
let value = inline.or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value"))?;
|
||||
let twice = match flag {
|
||||
"--out" => out.replace(value).is_some(),
|
||||
"--view" => view
|
||||
.replace(
|
||||
view_of(&value).ok_or_else(|| format!("--view: unknown view `{value}` (iso, x, y, z, front, back, left, right, top, bottom)"))?,
|
||||
)
|
||||
.is_some(),
|
||||
"--size" => size
|
||||
.replace(size_of(&value).ok_or_else(|| {
|
||||
format!(
|
||||
"--size: `{value}` is not WxH with each side 1..={} and at most {} pixels",
|
||||
fieldcraft_gpu::capture::MAX_SIDE,
|
||||
fieldcraft_gpu::capture::MAX_PIXELS
|
||||
)
|
||||
})?)
|
||||
.is_some(),
|
||||
"--section" => section
|
||||
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
|
||||
.is_some(),
|
||||
"--field" => field.replace(value).is_some(),
|
||||
"--camera" => {
|
||||
let v: serde_json::Value = serde_json::from_str(&value).map_err(|e| format!("--camera: not JSON: {e}"))?;
|
||||
camera.replace(view::camera(&v).map_err(|e| format!("--camera: {e}"))?).is_some()
|
||||
}
|
||||
other => return Err(format!("snapshot: unknown option `{other}`")),
|
||||
};
|
||||
if twice {
|
||||
return Err(format!("{flag} is given twice"));
|
||||
}
|
||||
}
|
||||
if view.is_some() && camera.is_some() {
|
||||
return Err("give --view or --camera, not both".into());
|
||||
}
|
||||
Ok(Options {
|
||||
script: script.ok_or("snapshot needs a script")?,
|
||||
out: out.ok_or("snapshot needs --out FILE.png")?,
|
||||
view: view.unwrap_or(StandardView::Iso),
|
||||
camera,
|
||||
size: size.unwrap_or(DEFAULT_SIZE),
|
||||
section,
|
||||
field,
|
||||
})
|
||||
}
|
||||
|
||||
/// The PNG of the session `o` describes, drawn as `policy` says ([`capture::png`]).
|
||||
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::Shown), String> {
|
||||
let mut session = crate::session_of(&o.script)?;
|
||||
if let Some(field) = &o.field {
|
||||
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
|
||||
}
|
||||
let (w, h) = o.size;
|
||||
let f = match &o.camera {
|
||||
Some(aim) => view::frame_at(&session, aim, w as usize, h as usize, &Palette::LIGHT, o.section).map_err(|e| e.to_string())?,
|
||||
None => view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section),
|
||||
};
|
||||
let capturer = Capturer::open(policy);
|
||||
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
|
||||
Ok((png, renderer, f.shown))
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let o = options(args)?;
|
||||
let policy = Policy::from_env().map_err(|e| e.to_string())?;
|
||||
let (png, renderer, shown) = picture(&o, policy)?;
|
||||
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
|
||||
let mut report = crate::picture_report(&renderer, &shown);
|
||||
if let Some(r) = report.as_object_mut() {
|
||||
r.insert("out".into(), o.out.clone().into());
|
||||
r.insert("width".into(), o.size.0.into());
|
||||
r.insert("height".into(), o.size.1.into());
|
||||
match &o.camera {
|
||||
Some(aim) => r.insert("camera".into(), Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() }.json()),
|
||||
None => r.insert("view".into(), o.view.label().into()),
|
||||
};
|
||||
}
|
||||
println!("{report}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn opts(args: &[&str]) -> Result<Options, String> {
|
||||
options(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_are_checked() {
|
||||
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
|
||||
assert_eq!(
|
||||
o,
|
||||
Options {
|
||||
script: "s.json".into(),
|
||||
out: "a.png".into(),
|
||||
view: StandardView::Iso,
|
||||
camera: None,
|
||||
size: DEFAULT_SIZE,
|
||||
section: None,
|
||||
field: None
|
||||
}
|
||||
);
|
||||
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
|
||||
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
|
||||
assert_eq!(opts(&["s", "--out", "o", "--section=Z"]).unwrap().section, Some(Section { axis: 2, at: None }));
|
||||
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
|
||||
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
|
||||
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
|
||||
assert_eq!(opts(&["s", "--out", "o", "--view", "front"]).unwrap().view, StandardView::Front);
|
||||
let o = opts(&["s", "--out", "o", "--camera", r#"{"yaw": 30, "pitch": 10, "target": [1, 2, 3], "fit": true}"#]).unwrap();
|
||||
assert_eq!(o.camera.and_then(|a| a.target), Some(Target::Point(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0))));
|
||||
for bad in [
|
||||
vec![],
|
||||
vec!["s.json"],
|
||||
vec!["--out", "a.png"],
|
||||
vec!["s", "t", "--out", "o"],
|
||||
vec!["s", "--out"],
|
||||
vec!["s", "--out", "o", "--out", "p"],
|
||||
vec!["s", "--out", "o", "--view", "diagonal"],
|
||||
vec!["s", "--out", "o", "--size", "0x10"],
|
||||
vec!["s", "--out", "o", "--size", "10"],
|
||||
vec!["s", "--out", "o", "--size", "99999x10"],
|
||||
vec!["s", "--out", "o", "--size", "8192x8192"],
|
||||
vec!["s", "--out", "o", "--size", "-5x10"],
|
||||
vec!["s", "--out", "o", "--nope", "1"],
|
||||
vec!["s", "--out", "o", "--section", "w"],
|
||||
vec!["s", "--out", "o", "--section", "x=inf"],
|
||||
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
|
||||
vec!["s", "--out", "o", "--camera", "{yaw: 1}"],
|
||||
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0}"#],
|
||||
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0, "pitch": 0}"#, "--view", "x"],
|
||||
] {
|
||||
assert!(opts(&bad).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// PNG width, height and pixels (RGBA).
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||||
let info = r.next_frame(&mut buf).unwrap();
|
||||
buf.truncate(info.buffer_size());
|
||||
(info.width, info.height, buf)
|
||||
}
|
||||
|
||||
fn script(dir: &std::path::Path, name: &str, solve: bool) -> String {
|
||||
let mut steps = fieldcraft_engine::sample::cantilever();
|
||||
if solve && let Some(a) = steps.as_array_mut() {
|
||||
a.push(serde_json::json!({"command": "solve.static", "params": {}}));
|
||||
}
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, steps.to_string()).unwrap();
|
||||
path.to_string_lossy().into()
|
||||
}
|
||||
|
||||
fn opts_for(script: String) -> Options {
|
||||
Options { script, out: String::new(), view: StandardView::Iso, camera: None, size: (320, 200), section: None, field: None }
|
||||
}
|
||||
|
||||
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
|
||||
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
|
||||
/// machine has one and on the CPU otherwise (it must be the GPU under `FIELDCRAFT_REQUIRE_GPU=1`); `cpu` always
|
||||
/// on the CPU, and both pictures show the same model. A section and a field change it.
|
||||
#[test]
|
||||
fn snapshots_the_model_and_its_mesh_headless() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let o = opts_for(script(&dir, "cantilever.json", false));
|
||||
for policy in [Policy::Auto, Policy::Cpu] {
|
||||
let (png, drawn, _) = picture(&o, policy).unwrap();
|
||||
let (w, h, px) = decode(&png);
|
||||
assert_eq!((w, h), (320, 200), "{drawn:?}");
|
||||
let hex = Palette::LIGHT.hex;
|
||||
let near = |p: &[u8], c: fieldcraft_render::Rgb, d: u8| p[0].abs_diff(c.0) <= d && p[1].abs_diff(c.1) <= d && p[2].abs_diff(c.2) <= d;
|
||||
// Shaded faces vary in brightness around the hex colour; the edges are near black.
|
||||
let faces = px.chunks(4).filter(|p| near(p, hex, 60) && p[2] > p[0]).count();
|
||||
let edges = px.chunks(4).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 90).count();
|
||||
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
|
||||
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
|
||||
if policy == Policy::Cpu {
|
||||
assert_eq!(drawn, Renderer::Cpu { why: None });
|
||||
} else if fieldcraft_gpu::select::gpu_required() {
|
||||
assert!(matches!(drawn, Renderer::Gpu { .. }), "FIELDCRAFT_REQUIRE_GPU=1 but drawn by {drawn:?}");
|
||||
}
|
||||
}
|
||||
// A section along the beam draws half of it.
|
||||
let whole = picture(&o, Policy::Cpu).unwrap().0;
|
||||
let cut = picture(&Options { section: Some(Section { axis: 0, at: None }), ..o.clone() }, Policy::Cpu).unwrap().0;
|
||||
assert_ne!(whole, cut);
|
||||
// A field needs a solved step; on a solved one it is the contour reported.
|
||||
assert!(picture(&Options { field: Some("von_mises".into()), ..o.clone() }, Policy::Cpu).is_err());
|
||||
let solved = Options { field: Some("displacement".into()), ..opts_for(script(&dir, "solved.json", true)) };
|
||||
let (_, _, shown) = picture(&solved, Policy::Auto).unwrap();
|
||||
assert_eq!(shown.contour.map(|c| c.0).as_deref(), Some("displacement"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ fn cli(host: &str, args: &[&str]) -> std::process::Output {
|
||||
#[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": []}));
|
||||
server.release(
|
||||
"latest",
|
||||
&json!({"tag_name": "v99.0.0", "html_url": "https://github.com/tangency-labs/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();
|
||||
|
||||
@@ -31,4 +31,4 @@ 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", "Crypto", "Headers", "Request", "RequestInit", "RequestCache", "Response"] }
|
||||
# Autosaves kept in the browser's local storage, which holds text.
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
@@ -9,15 +9,15 @@
|
||||
<meta property="og:site_name" content="FieldCraft">
|
||||
<meta property="og:title" content="FieldCraft">
|
||||
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://jyviko.github.io/fieldcraft/">
|
||||
<meta property="og:image" content="https://jyviko.github.io/fieldcraft/og.png">
|
||||
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
|
||||
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FieldCraft">
|
||||
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://jyviko.github.io/fieldcraft/og.png">
|
||||
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
|
||||
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
|
||||
|
||||
@@ -6,11 +6,22 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/native_crash.rs
|
||||
# (the Windows exception filter, which has no safe API) can allow it. Keep the rest in sync with
|
||||
# [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
@@ -23,7 +34,27 @@ log = { workspace = true }
|
||||
# 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"
|
||||
getrandom = "0.4"
|
||||
|
||||
# A termination signal (SIGTERM, SIGINT) closes the window the normal way (terminate.rs).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
signal-hook = "0.4"
|
||||
|
||||
# The exception filter and minidump of a native crash (src/native_crash.rs).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
# WriteFile's OVERLAPPED.
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Kernel",
|
||||
"Win32_System_LibraryLoader",
|
||||
# MiniDumpWriteDump.
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[build-dependencies]
|
||||
# The Windows executable's icon resource (build.rs).
|
||||
@@ -71,7 +102,7 @@ assets = [
|
||||
[package.metadata.generate-rpm]
|
||||
name = "fieldcraft"
|
||||
license = "MIT OR Apache-2.0"
|
||||
url = "https://github.com/jyviko/fieldcraft"
|
||||
url = "https://github.com/tangency-labs/fieldcraft"
|
||||
assets = [
|
||||
{ source = "target/release/fieldcraft", dest = "/usr/bin/fieldcraft", mode = "755" },
|
||||
{ source = "target/release/fieldcraft-cli", dest = "/usr/bin/fieldcraft-cli", mode = "755" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
//! `fieldcraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
// Unsafe code is denied (Cargo.toml): only the Windows exception filter allows it (native_crash.rs).
|
||||
|
||||
mod control_server;
|
||||
#[cfg(windows)]
|
||||
mod native_crash;
|
||||
mod terminate;
|
||||
mod update;
|
||||
mod usage;
|
||||
|
||||
@@ -49,8 +52,8 @@ 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,
|
||||
/// Frames drawn so far, up to 2 (the second means the first was presented: the app is up).
|
||||
frames: u8,
|
||||
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
|
||||
usage: Option<usage::Sender>,
|
||||
}
|
||||
@@ -67,9 +70,13 @@ 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");
|
||||
if self.frames < 2 {
|
||||
self.frames += 1;
|
||||
// A second frame right away, which means the first was presented: the app is up.
|
||||
ctx.request_repaint();
|
||||
if self.frames == 2 {
|
||||
crash::phase(crash::RUNNING);
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
@@ -162,10 +169,18 @@ fn start_reporting() -> Vec<crash::Summary> {
|
||||
b.version_line(),
|
||||
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
|
||||
);
|
||||
crash::activity("startup");
|
||||
crashed
|
||||
}
|
||||
|
||||
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface
|
||||
/// up: the crash journal keeps it, and the adapter once it is picked (#299).
|
||||
fn startup_step(step: &'static str, detail: &str) {
|
||||
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
|
||||
crash::set_environment(&format!("Renderer: {detail}"));
|
||||
}
|
||||
crash::phase(step);
|
||||
}
|
||||
|
||||
/// Command-line options.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
@@ -209,6 +224,8 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`.
|
||||
fieldcraft_lbm_gpu::register();
|
||||
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Ok(()),
|
||||
@@ -223,6 +240,12 @@ fn main() -> eframe::Result {
|
||||
fieldcraft_io::recovery::use_automation_dir();
|
||||
}
|
||||
let crashed = start_reporting();
|
||||
// A crash in native code (a graphics driver) says where it happened: the exception and the
|
||||
// module on Windows, and on every system the startup step the app was in.
|
||||
#[cfg(windows)]
|
||||
native_crash::install();
|
||||
eframe::egui_wgpu::set_startup_hook(startup_step);
|
||||
crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FieldCraft")
|
||||
@@ -245,6 +268,7 @@ fn main() -> eframe::Result {
|
||||
"FieldCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
crash::phase("app setup");
|
||||
let mut session = Session::default();
|
||||
// The window's model: its commands go into the crash journal.
|
||||
session.crash_journal = crash::enabled();
|
||||
@@ -265,7 +289,21 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
|
||||
crash::set_environment(&format!("Renderer: {:?} {} ({}), MSAA {}x", info.backend, info.name, info.driver, rs.msaa_samples));
|
||||
crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(windows)]
|
||||
native_crash::reinstall();
|
||||
}
|
||||
// `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
if let Err(e) = terminate::watch(move || {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ctx.request_repaint();
|
||||
}) {
|
||||
log::warn!("termination signals: {e}");
|
||||
}
|
||||
if let Some(port) = args.control {
|
||||
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
|
||||
@@ -285,7 +323,8 @@ fn main() -> eframe::Result {
|
||||
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
|
||||
});
|
||||
app.announce_crashes(&crashed);
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage }))
|
||||
crash::phase("first frame");
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, frames: 0, usage }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md, #299).
|
||||
//!
|
||||
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
|
||||
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
|
||||
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
|
||||
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
|
||||
//! crash journal (`fieldcraft_engine::crash::native_paths`). The next start puts both into the
|
||||
//! unclean exit's report, with the startup step the journal says the app was in. Everything that
|
||||
//! can be tested without Windows (reading the file, the report, the startup steps) lives in
|
||||
//! `fieldcraft_engine::crash` and is tested there.
|
||||
//!
|
||||
//! Installing an exception filter and asking the system which module holds an address have no
|
||||
//! safe API, hence the `unsafe_code` allowance on this module, the only one in FieldCraft (the
|
||||
//! app crate denies unsafe code instead of forbidding it for this). The filter runs on a thread
|
||||
//! that may have a broken heap or almost no stack left: it doesn't allocate, formats into a buffer
|
||||
//! on the stack, and skips the minidump after a stack overflow.
|
||||
//!
|
||||
//! Adapted from SolidCraft's `apps/solidcraft/src/native_crash.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{
|
||||
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
|
||||
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
|
||||
};
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
|
||||
|
||||
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
|
||||
struct Paths {
|
||||
info: Vec<u16>,
|
||||
dump: Vec<u16>,
|
||||
}
|
||||
|
||||
static PATHS: OnceLock<Paths> = OnceLock::new();
|
||||
/// The filter that was installed before ours (called after ours), as an address.
|
||||
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The filter has started writing (a second exception while it does is left alone).
|
||||
static BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
|
||||
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
|
||||
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
|
||||
|
||||
/// Catch native crashes from now on (after `fieldcraft_engine::crash::install`; nothing without it).
|
||||
pub fn install() {
|
||||
let Some((info, dump)) = fieldcraft_engine::crash::native_paths() else { return };
|
||||
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
|
||||
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
|
||||
/// which is then called after ours.
|
||||
pub fn reinstall() {
|
||||
if PATHS.get().is_some() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_filter() {
|
||||
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
|
||||
// process's life.
|
||||
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
|
||||
if let Some(p) = previous
|
||||
&& p as usize != filter as *const () as usize
|
||||
{
|
||||
PREVIOUS.store(p as usize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
|
||||
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
|
||||
if !BUSY.swap(true, Ordering::SeqCst) {
|
||||
// SAFETY: the system passes valid exception pointers for the duration of the call.
|
||||
unsafe { record(pointers) };
|
||||
}
|
||||
let previous = PREVIOUS.load(Ordering::SeqCst);
|
||||
if previous != 0 && previous != filter as *const () as usize {
|
||||
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
|
||||
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
|
||||
// SAFETY: as for this filter.
|
||||
return unsafe { previous(pointers) };
|
||||
}
|
||||
EXCEPTION_CONTINUE_SEARCH
|
||||
}
|
||||
|
||||
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
|
||||
struct Buf {
|
||||
bytes: [u8; 2048],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Buf {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let room = self.bytes.len().saturating_sub(self.len);
|
||||
let n = s.len().min(room);
|
||||
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
|
||||
to.copy_from_slice(from);
|
||||
self.len += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual names of the exception codes a crash shows.
|
||||
fn code_name(code: u32) -> &'static str {
|
||||
match code {
|
||||
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
|
||||
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
|
||||
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
|
||||
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
|
||||
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
|
||||
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
|
||||
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
|
||||
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
|
||||
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
|
||||
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
|
||||
0x8000_0003 => "EXCEPTION_BREAKPOINT",
|
||||
0xE06D_7363 => "C++ exception",
|
||||
_ => "exception",
|
||||
}
|
||||
}
|
||||
|
||||
/// Write what `pointers` says into the info file, then the minidump.
|
||||
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
|
||||
let Some(paths) = PATHS.get() else { return };
|
||||
if pointers.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: checked non-null; the system keeps it valid during the filter.
|
||||
let rec = unsafe { (*pointers).ExceptionRecord };
|
||||
if rec.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let rec = unsafe { &*rec };
|
||||
let code = rec.ExceptionCode as u32;
|
||||
let address = rec.ExceptionAddress as usize;
|
||||
let mut module: HMODULE = std::ptr::null_mut();
|
||||
let mut name = [0u16; 512];
|
||||
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
|
||||
// handle to release; `name` is as long as we say.
|
||||
let len = unsafe {
|
||||
if GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
address as *const u16,
|
||||
&mut module,
|
||||
) != 0
|
||||
{
|
||||
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
// SAFETY: no preconditions.
|
||||
let thread = unsafe { GetCurrentThreadId() };
|
||||
let mut b = Buf { bytes: [0; 2048], len: 0 };
|
||||
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
|
||||
if len > 0 {
|
||||
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
|
||||
for c in char::decode_utf16(name.iter().take(len).copied()) {
|
||||
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
|
||||
'"' => b.write_str("\\\""),
|
||||
'\\' => b.write_str("\\\\"),
|
||||
c if (c as u32) < 0x20 => b.write_str("?"),
|
||||
c => b.write_char(c),
|
||||
};
|
||||
}
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
|
||||
let what = match rec.ExceptionInformation[0] {
|
||||
0 => "read of",
|
||||
1 => "write to",
|
||||
8 => "execution at",
|
||||
_ => "access to",
|
||||
};
|
||||
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
|
||||
}
|
||||
let _ = b.write_str("}");
|
||||
let bytes = b.bytes.get(..b.len).unwrap_or_default();
|
||||
// SAFETY: NUL-terminated path; the handle is closed below.
|
||||
unsafe {
|
||||
let file = create(&paths.info);
|
||||
if !file.is_null() {
|
||||
let mut written = 0u32;
|
||||
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
// After a stack overflow there is no stack left for writing a minidump.
|
||||
if code == STATUS_STACK_OVERFLOW {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above; the exception information points at this thread's exception.
|
||||
unsafe {
|
||||
let file = create(&paths.dump);
|
||||
if !file.is_null() {
|
||||
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
|
||||
MiniDumpWriteDump(
|
||||
GetCurrentProcess(),
|
||||
GetCurrentProcessId(),
|
||||
file,
|
||||
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
|
||||
&exception,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file at `path` (NUL-terminated) for writing, or null.
|
||||
unsafe fn create(path: &[u16]) -> HANDLE {
|
||||
// SAFETY: the caller passes a NUL-terminated path.
|
||||
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
|
||||
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The child half: install crash reporting and the filter, then fault in native code.
|
||||
#[test]
|
||||
fn child() {
|
||||
let Some(dir) = std::env::var_os("FIELDCRAFT_NATIVE_CRASH_CHILD") else { return };
|
||||
let build = fieldcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234" };
|
||||
let fresh = fieldcraft_engine::crash::install(PathBuf::from(dir), build);
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
|
||||
if std::env::var_os("FIELDCRAFT_NATIVE_CRASH_FAULT").is_none() {
|
||||
fieldcraft_engine::crash::end_session();
|
||||
return;
|
||||
}
|
||||
super::install();
|
||||
fieldcraft_engine::crash::phase("device creation");
|
||||
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
|
||||
// pointer's field would be).
|
||||
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
|
||||
println!("unreachable {v}");
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
|
||||
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_NATIVE_CRASH_CHILD", dir);
|
||||
if fault {
|
||||
c.env("FIELDCRAFT_NATIVE_CRASH_FAULT", "1");
|
||||
}
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-native-crash-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let out = run(&dir, true);
|
||||
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
|
||||
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
|
||||
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
|
||||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
|
||||
assert_eq!(v["code"], "0xC0000005", "{v}");
|
||||
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
|
||||
assert!(v["module"].as_str().unwrap().to_lowercase().contains("fieldcraft"), "the fault is in the test binary: {v}");
|
||||
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
|
||||
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
|
||||
// The next start reports it.
|
||||
let out = run(&dir, false);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let reports = fieldcraft_engine::crash::list(&dir);
|
||||
assert_eq!(reports.len(), 1, "{reports:?}");
|
||||
let r = fieldcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
|
||||
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in fieldcraft"), "{r}");
|
||||
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
|
||||
assert!(r.get("minidump_path").is_some(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! A request to terminate (SIGTERM, SIGINT on Unix) closes the window the normal way.
|
||||
//!
|
||||
//! Without this, `kill PID`, Ctrl-C in the terminal, a system shutdown or a test harness's timeout
|
||||
//! ended the process on the spot: no `on_exit`, so no last autosave and no clean end of the crash
|
||||
//! journal, and the next start filed an `unclean_exit` crash report for what was no crash (#248:
|
||||
//! "ended without a clean exit between commands (last: control ui.capture)" was an automation run
|
||||
//! stopped with SIGTERM after its capture). Now the first signal asks the window to close, which
|
||||
//! autosaves, ends the journal and exits as a click on Close does; the journal notes the signal,
|
||||
//! so a run that still dies while closing says why. A second signal, for a window that does not
|
||||
//! close, ends the process at once with the signal's default action.
|
||||
//!
|
||||
//! SIGHUP is left alone: `nohup fieldcraft &` ignores it on purpose so the app outlives its
|
||||
//! terminal, and a handler would override that (a safe API cannot ask whether a signal was
|
||||
//! ignored at launch).
|
||||
//!
|
||||
//! Windows has no such signals: a window there is closed through its messages, which eframe
|
||||
//! already turns into a normal close.
|
||||
|
||||
use std::io;
|
||||
|
||||
/// The signals that ask the app to terminate.
|
||||
#[cfg(unix)]
|
||||
const SIGNALS: [i32; 2] = [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT];
|
||||
|
||||
/// The signal's usual name, for the log and the crash journal.
|
||||
#[cfg(unix)]
|
||||
fn name(signal: i32) -> &'static str {
|
||||
match signal {
|
||||
signal_hook::consts::SIGTERM => "SIGTERM",
|
||||
signal_hook::consts::SIGINT => "SIGINT",
|
||||
_ => "signal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `close` (on a thread of its own) on the first termination signal; a second one ends the
|
||||
/// process with the signal's default action.
|
||||
#[cfg(unix)]
|
||||
pub fn watch(close: impl Fn() + Send + 'static) -> io::Result<()> {
|
||||
let mut signals = signal_hook::iterator::Signals::new(SIGNALS)?;
|
||||
std::thread::Builder::new().name("terminate".into()).spawn(move || {
|
||||
let mut asked = false;
|
||||
for signal in signals.forever() {
|
||||
if asked {
|
||||
log::warn!("{}: the window did not close; ending now", name(signal));
|
||||
if let Err(e) = signal_hook::low_level::emulate_default_handler(signal) {
|
||||
log::error!("{}: {e}", name(signal));
|
||||
std::process::exit(128 + signal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
asked = true;
|
||||
log::info!("{}: closing", name(signal));
|
||||
fieldcraft_engine::crash::activity(&format!("terminating ({})", name(signal)));
|
||||
close();
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nothing to watch without Unix signals.
|
||||
#[cfg(not(unix))]
|
||||
pub fn watch(_close: impl Fn() + Send + 'static) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use fieldcraft_engine::crash;
|
||||
|
||||
/// The child half: start crash reporting as the app does, then get SIGTERM. With the watcher
|
||||
/// the "window" (here the main thread) closes the normal way; without it the process dies.
|
||||
#[test]
|
||||
fn child() {
|
||||
let Some(dir) = std::env::var_os("FIELDCRAFT_TERMINATE_CHILD") else { return };
|
||||
let build = crash::Build { version: "9.9.9", commit: "abc1234" };
|
||||
let fresh = crash::install(PathBuf::from(dir), build);
|
||||
if std::env::var_os("FIELDCRAFT_TERMINATE_NEXT").is_some() {
|
||||
// The next start: what it reports of the run before.
|
||||
println!("FRESH {}", fresh.iter().map(|s| s.message.as_str()).collect::<Vec<_>>().join(" | "));
|
||||
crash::end_session();
|
||||
return;
|
||||
}
|
||||
crash::activity("control ui.capture");
|
||||
if std::env::var_os("FIELDCRAFT_TERMINATE_WATCH").is_some() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
super::watch(move || {
|
||||
let _ = tx.send(());
|
||||
})
|
||||
.unwrap();
|
||||
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
|
||||
// What `on_exit` does once the window has closed.
|
||||
rx.recv().unwrap();
|
||||
crash::end_session();
|
||||
println!("CLOSED");
|
||||
return;
|
||||
}
|
||||
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
|
||||
// Not reached: SIGTERM's default action ends the process.
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigterm_closes_cleanly_and_leaves_no_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("fieldcraft-terminate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let run = |dir: &Path, mode: &str| {
|
||||
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
c.args(["--exact", "terminate::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_TERMINATE_CHILD", dir);
|
||||
match mode {
|
||||
"watch" => c.env("FIELDCRAFT_TERMINATE_WATCH", "1"),
|
||||
"next" => c.env("FIELDCRAFT_TERMINATE_NEXT", "1"),
|
||||
_ => &mut c,
|
||||
};
|
||||
c.output().unwrap()
|
||||
};
|
||||
// What the next start in `dir` reports of the run before.
|
||||
let reported_at_next_start = |dir: &Path| {
|
||||
let out = run(dir, "next");
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
stdout.lines().find_map(|l| l.split_once("FRESH ").map(|(_, r)| r.trim().to_string())).unwrap_or_else(|| panic!("{stdout}"))
|
||||
};
|
||||
|
||||
// Without the watcher (the bug): killed, and the journal it leaves reads as a crash.
|
||||
let unwatched = root.join("unwatched");
|
||||
let out = run(&unwatched, "kill");
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
assert_eq!(out.status.signal(), Some(signal_hook::consts::SIGTERM), "{out:?}");
|
||||
let reported = reported_at_next_start(&unwatched);
|
||||
assert!(reported.contains("ended without a clean exit") && reported.contains("control ui.capture"), "{reported}");
|
||||
|
||||
// With it: the close runs, the journal ends, and nothing is reported as a crash.
|
||||
let watched = root.join("watched");
|
||||
let out = run(&watched, "watch");
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("CLOSED"), "{out:?}");
|
||||
assert_eq!(reported_at_next_start(&watched), "");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -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.check</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 6.5 L2 9.5" fill="none" stroke="#f0b44a" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5 17 C13.5 15.067 15.067 13.5 17 13.5 C18.933 13.5 20.5 15.067 20.5 17 C20.5 18.933 18.933 20.5 17 20.5 C15.067 20.5 13.5 18.933 13.5 17 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19.5 19.5 L22 22" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 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.defeature</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 8 C9 8.828 10.343 9.5 12 9.5 C13.657 9.5 15 8.828 15 8 C15 7.172 13.657 6.5 12 6.5 C10.343 6.5 9 7.172 9 8 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 11 L16.5 5" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 886 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>geometry.face.absorb</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 823 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>geometry.face.split</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 10 L16.5 6" fill="none" stroke="#f0b44a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 4.5 L9 2.5 M15 9.5 L19 7.5" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 845 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>geometry.faces.merge</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 6 L16.5 10" fill="none" stroke="#f0b44a" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 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>geometry.stitch</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 6 L10.5 6 L10.5 18 L3 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5 6 L21 6 L21 18 L13.5 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 8.5 L15 8.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 12 L15 12" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 15.5 L15 15.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 923 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 |
@@ -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.export.stl</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 9 L9 5 L15 9 L9 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9 L9 21 M9 13 L15 17" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 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.hex.extrude</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M6 20 L18 20 L18 11.6 C14 9.467 10 9.467 6 11.6 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 17.2 L18 17.2 M6 14.4 C10 12.8 14 12.8 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9.2 C9 5.333 15 5.333 21 9.2" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 879 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.move</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M5 4 L19 4 C19.552 4 20 4.448 20 5 L20 19 C20 19.552 19.552 20 19 20 L5 20 C4.448 20 4 19.552 4 19 L4 5 C4 4.448 4.448 4 5 4 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12 L20 12 M12 4 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12 L15 9 L20 12 M12 4 L15 9 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.2 9 C13.2 8.006 14.006 7.2 15 7.2 C15.994 7.2 16.8 8.006 16.8 9 C16.8 9.994 15.994 10.8 15 10.8 C14.006 10.8 13.2 9.994 13.2 9 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1022 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 |
@@ -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.plan</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"/>
|
||||
<path d="M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.8 7 C8.8 7.884 10.233 8.6 12 8.6 C13.767 8.6 15.2 7.884 15.2 7 C15.2 6.116 13.767 5.4 12 5.4 C10.233 5.4 8.8 6.116 8.8 7 Z" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 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>mesh.quality.mark</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 3 L11 3 L11 11 L3 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 3 L21 3 L21 11 L13 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 13 L11 13 L11 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 14 L21 13 L20 21 L14 19 Z" fill="#f0b44a" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 822 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.size.set</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L12 21 M3 12 L21 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 12 L21 12 L21 21 L12 21 Z" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 12 L16.5 21 M12 16.5 L21 16.5" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 777 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.transition</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 4 L21 4 L21 20 L3 20 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 4 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 816 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>results.export.vtu</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M4 3 L12 3 L16 7 L16 21 L4 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L12 7 L16 7" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 17 L7 14 M10 17 L10 11 M13 17 L13 13" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z", "fill": "soft", "stroke": "line"}, {"d": "M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5", "stroke": "line"}, {"line": [2, 6.5, 2, 9.5], "stroke": "warm", "width": 2.25}, {"circle": [17, 17, 3.5], "stroke": "accent", "width": 1.75}, {"line": [19.5, 19.5, 22, 22], "stroke": "accent", "width": 2}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M9 8 A3 1.5 0 1 0 15 8 A3 1.5 0 1 0 9 8 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M7.5 11 L16.5 5", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 10, 16.5, 6], "stroke": "warm", "width": 2}, {"d": "M5 4.5 L9 2.5 M15 9.5 L19 7.5", "stroke": "accent", "width": 1.5, "dash": [1.5, 1.5]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 6, 16.5, 10], "stroke": "warm", "width": 1.25, "dash": [1.5, 2]}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 6 L10.5 6 L10.5 18 L3 18 Z", "fill": "soft", "stroke": "line"}, {"d": "M13.5 6 L21 6 L21 18 L13.5 18 Z", "fill": "soft", "stroke": "line"}, {"line": [9, 8.5, 15, 8.5], "stroke": "warm", "width": 1.75}, {"line": [9, 12, 15, 12], "stroke": "warm", "width": 1.75}, {"line": [9, 15.5, 15, 15.5], "stroke": "warm", "width": 1.75}]}
|
||||
@@ -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}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 9 L9 5 L15 9 L9 13 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9", "stroke": "line"}, {"d": "M3 9 L9 21 M9 13 L15 17", "stroke": "line", "width": 1}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M6 20 L18 20 L18 11.6 Q12 8.4 6 11.6 Z", "fill": "soft", "stroke": "line"}, {"d": "M6 17.2 L18 17.2 M6 14.4 Q12 12 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9", "stroke": "accent", "width": 1}, {"d": "M3 9.2 Q12 3.4 21 9.2", "stroke": "accent", "width": 2}, {"d": "M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5", "stroke": "line"}]}
|
||||
@@ -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": [{"rect": [4, 4, 16, 16], "radius": 1, "stroke": "line", "width": 1.5}, {"d": "M4 12 L20 12 M12 4 L12 20", "stroke": "line", "width": 1.5}, {"d": "M4 12 L15 9 L20 12 M12 4 L15 9 L12 20", "stroke": "accent", "width": 1.5}, {"circle": [15, 9, 1.8], "fill": "accent", "stroke": "accent"}]}
|
||||
@@ -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": "M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z", "fill": "soft", "stroke": "line"}, {"d": "M4 7 L12 11 L20 7 M12 11 L12 20", "stroke": "line"}, {"d": "M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18", "stroke": "accent", "width": 1}, {"d": "M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7", "stroke": "accent", "width": 1}, {"d": "M8.8 7 A3.2 1.6 0 1 0 15.2 7 A3.2 1.6 0 1 0 8.8 7 Z", "stroke": "warm", "width": 1.5}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"rect": [3, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [13, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [3, 13, 8, 8], "fill": "soft", "stroke": "line"}, {"d": "M13 14 L21 13 L20 21 L14 19 Z", "fill": "warm", "stroke": "line"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"rect": [3, 3, 18, 18], "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 21 M3 12 L21 12", "stroke": "line", "width": 1}, {"rect": [12, 12, 9, 9], "fill": null, "stroke": "accent", "width": 1.5}, {"d": "M16.5 12 L16.5 21 M12 16.5 L21 16.5", "stroke": "accent", "width": 1}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"shapes": [{"rect": [3, 4, 18, 16], "fill": "soft", "stroke": "line"}, {"d": "M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67", "stroke": "line", "width": 1}, {"d": "M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4", "stroke": "line", "width": 1}, {"d": "M12 4 L12 20", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M4 3 L12 3 L16 7 L16 21 L4 21 Z", "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 7 L16 7", "stroke": "line"}, {"d": "M7 17 L7 14 M10 17 L10 11 M13 17 L13 13", "stroke": "accent", "width": 2}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -85,6 +85,7 @@
|
||||
//! | tet4 | C3D4 (H) | | | |
|
||||
//! | tet10 | C3D10 (H) | | | |
|
||||
//! | wedge6 | C3D6 (H) | | | |
|
||||
//! | pyramid5 | C3D5 (H) | | | |
|
||||
//! | hex8 | C3D8, C3D8R, C3D8I (H) | | | |
|
||||
//! | hex20 | C3D20, C3D20R (H) | | | |
|
||||
//! | tri3, quad4 | | S3 / S3R, S4 / S4R | | |
|
||||
@@ -148,7 +149,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use fieldcraft_model::{
|
||||
BeamProfile, BoundaryCondition, ContactSurfaces, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, InitialPenetration,
|
||||
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
|
||||
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Target,
|
||||
};
|
||||
|
||||
use crate::{Error, Result, unsupported};
|
||||
@@ -193,6 +194,7 @@ pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
|
||||
d.couplings(model, r);
|
||||
d.contacts(model, r)?;
|
||||
d.ties(model)?;
|
||||
d.equations(model, r)?;
|
||||
d.steps(model, r)?;
|
||||
Ok(d.out)
|
||||
}
|
||||
@@ -230,11 +232,12 @@ pub fn element_type(kind: ElementKind, section: &Section) -> std::result::Result
|
||||
(S::Solid {}, _, true, true) => return Err("incompatible modes with reduced integration".into()),
|
||||
(S::Solid {}, _, _, true) => return Err(format!("incompatible modes on {} elements (hex8 only)", kind.name())),
|
||||
(S::Solid {}, K::Tet4, ..) => "C3D4",
|
||||
(S::Solid {}, K::Tet10 | K::Wedge6, true, _) => {
|
||||
(S::Solid {}, K::Tet10 | K::Wedge6 | K::Pyramid5, true, _) => {
|
||||
return Err(format!("reduced integration of {} elements (Abaqus has no reduced-integration variant)", kind.name()));
|
||||
}
|
||||
(S::Solid {}, K::Tet10, ..) => "C3D10",
|
||||
(S::Solid {}, K::Wedge6, ..) => "C3D6",
|
||||
(S::Solid {}, K::Pyramid5, ..) => "C3D5",
|
||||
(S::Solid {}, K::Hex8, r, _) => ["C3D8", "C3D8R"][usize::from(r)],
|
||||
(S::Solid {}, K::Hex20, r, _) => ["C3D20", "C3D20R"][usize::from(r)],
|
||||
(S::Shell { .. }, K::Tri3, r, _) => ["S3", "S3R"][usize::from(r)],
|
||||
@@ -370,9 +373,36 @@ fn check_expressible(model: &Model) -> Result<()> {
|
||||
bad.insert(format!("coupling `{name}`: an Abaqus distributing coupling always couples translations 1-3"));
|
||||
}
|
||||
}
|
||||
for name in inclined_symmetries(model) {
|
||||
if !model.steps().iter().all(|s| s.boundary_conditions.contains(name)) {
|
||||
bad.insert(format!(
|
||||
"boundary condition `{name}`: a symmetry plane not normal to X, Y or Z active in some steps only (it is written as `*EQUATION`, which is model data and holds in every step)"
|
||||
));
|
||||
}
|
||||
}
|
||||
unsupported(FORMAT, bad)
|
||||
}
|
||||
|
||||
/// The symmetry conditions on inclined planes some step uses: linear equations rather than held
|
||||
/// degrees of freedom.
|
||||
fn inclined_symmetries(model: &Model) -> BTreeSet<&String> {
|
||||
model
|
||||
.boundary_conditions()
|
||||
.iter()
|
||||
.filter(|(n, bc)| {
|
||||
matches!(bc, BoundaryCondition::Symmetry { plane, .. } if plane.axis().is_none()) && !model.boundary_condition_users(n).is_empty()
|
||||
})
|
||||
.map(|(n, _)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boundary conditions of `names` that `*BOUNDARY` writes (inclined symmetry planes are
|
||||
/// `*EQUATION`s).
|
||||
fn held_conditions(model: &Model, names: &BTreeSet<String>) -> BTreeSet<String> {
|
||||
let inclined = inclined_symmetries(model);
|
||||
names.iter().filter(|n| !inclined.contains(n)).cloned().collect()
|
||||
}
|
||||
|
||||
/// The property of a group of discrete elements.
|
||||
enum DiscreteProperty {
|
||||
Mass(f64),
|
||||
@@ -1076,10 +1106,12 @@ impl Deck {
|
||||
match bc {
|
||||
BoundaryCondition::Fixed { .. } => self.line(&format!("{target}, ENCASTRE")),
|
||||
BoundaryCondition::Symmetry { plane, .. } => {
|
||||
let s = match plane {
|
||||
Symmetry::X => "XSYMM",
|
||||
Symmetry::Y => "YSYMM",
|
||||
Symmetry::Z => "ZSYMM",
|
||||
let s = match plane.axis() {
|
||||
Some(0) => "XSYMM",
|
||||
Some(1) => "YSYMM",
|
||||
Some(2) => "ZSYMM",
|
||||
// An inclined plane is an `*EQUATION` (model data), never here.
|
||||
_ => continue,
|
||||
};
|
||||
self.line(&format!("{target}, {s}"));
|
||||
}
|
||||
@@ -1097,8 +1129,74 @@ impl Deck {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The inclined symmetry planes: one `*EQUATION` per node and held direction (n · u = 0, and
|
||||
/// the rotations about the plane's axes at shell and beam nodes), whose first term Abaqus
|
||||
/// eliminates; it is chosen among the degrees of freedom no boundary condition, coupling
|
||||
/// or tie constrains ([`crate::symmetry_equations`]).
|
||||
fn equations(&mut self, model: &Model, r: &Resolved) -> Result<()> {
|
||||
let inclined = inclined_symmetries(model);
|
||||
if inclined.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let steps = model.steps();
|
||||
// Per node and degree of freedom: the value it is held at in each step that holds it.
|
||||
let mut held: BTreeMap<(u32, u8), Vec<f64>> = BTreeMap::new();
|
||||
for step in steps {
|
||||
for name in held_conditions(model, &step.boundary_conditions) {
|
||||
let Some(bc) = model.boundary_condition(&name) else { continue };
|
||||
for &node in r.target_nodes(bc.target()) {
|
||||
for dof in bc.dofs().list() {
|
||||
let v = if matches!(step.procedure, Procedure::Frequency { .. } | Procedure::Buckle { .. }) {
|
||||
0.0
|
||||
} else {
|
||||
bc.value(dof).unwrap_or(0.0)
|
||||
};
|
||||
held.entry((node, dof)).or_default().push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dependent: BTreeSet<(u32, u8)> = BTreeSet::new();
|
||||
for c in model.couplings().values().filter(|c| c.kind == CouplingKind::Kinematic) {
|
||||
for &node in r.sets.get(&c.set).map(|s| s.nodes.as_slice()).unwrap_or_default() {
|
||||
dependent.extend(c.dofs.list().into_iter().map(|d| (node, d)));
|
||||
}
|
||||
}
|
||||
for t in model.ties().values() {
|
||||
for &node in r.sets.get(&t.secondary).map(|s| s.nodes.as_slice()).unwrap_or_default() {
|
||||
dependent.extend((1..=6).map(|d| (node, d)));
|
||||
}
|
||||
}
|
||||
let state = |node: u32, dof: u8| {
|
||||
if dependent.contains(&(node, dof)) {
|
||||
return crate::Held::Sometimes;
|
||||
}
|
||||
match held.get(&(node, dof)) {
|
||||
None => crate::Held::Free,
|
||||
Some(v) if v.len() == steps.len() && v.iter().all(|x| *x == v[0]) => crate::Held::Always(v[0]),
|
||||
Some(_) => crate::Held::Sometimes,
|
||||
}
|
||||
};
|
||||
let mut problems = BTreeSet::new();
|
||||
let equations = crate::symmetry_equations(model, r, inclined, &model.rotating_nodes(), state, &mut problems);
|
||||
unsupported(FORMAT, problems)?;
|
||||
self.comment("Symmetry planes not normal to X, Y or Z: per node, no motion along the normal (and no rotation about the plane's axes)");
|
||||
for e in &equations {
|
||||
self.line("*EQUATION");
|
||||
self.line(&e.terms.len().to_string());
|
||||
let mut fields = Vec::new();
|
||||
for (dof, c) in &e.terms {
|
||||
fields.push(format!("{}, {dof}", e.node));
|
||||
fields.push(number(*c)?);
|
||||
}
|
||||
self.line(&fields.join(", "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Boundary conditions of a step against the state it starts from.
|
||||
fn step_boundaries(&mut self, model: &Model, r: &Resolved, base: &BTreeSet<String>, active: &BTreeSet<String>) -> Result<()> {
|
||||
let (base, active) = (&held_conditions(model, base), &held_conditions(model, active));
|
||||
if active == base {
|
||||
self.comment("boundary conditions: as in the base state");
|
||||
Ok(())
|
||||
@@ -1188,7 +1286,8 @@ impl Deck {
|
||||
BTreeSet::new()
|
||||
} else {
|
||||
let in_every_step = |n: &String| steps.iter().all(|s| s.boundary_conditions.contains(n));
|
||||
model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect()
|
||||
let all = model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect();
|
||||
held_conditions(model, &all)
|
||||
};
|
||||
if !initial.is_empty() {
|
||||
self.comment("Initial state: boundary conditions active in every step");
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod nastran;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_model::nodal::edge_pressure;
|
||||
use fieldcraft_model::{Diagnostic, Load, Model, Resolved, ResolvedDirection};
|
||||
use fieldcraft_model::{BoundaryCondition, Diagnostic, Load, Model, Resolved, ResolvedDirection};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
@@ -176,6 +176,120 @@ pub fn edge_pressure_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIte
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How a degree of freedom is constrained apart from the inclined symmetry planes, for choosing
|
||||
/// the dependent one of a plane's equation (formats eliminate it, so it may carry no other
|
||||
/// constraint).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum Held {
|
||||
Free,
|
||||
/// Held at this value wherever the equation applies: it cannot depend on the others, and an
|
||||
/// equation of held ones alone is redundant when the values satisfy it.
|
||||
Always(f64),
|
||||
/// Held in some steps only, or made dependent by a coupling or tie: it cannot be dependent,
|
||||
/// nor can the equation be dropped.
|
||||
Sometimes,
|
||||
}
|
||||
|
||||
/// A linear equation Σ cⱼ u(node, dofⱼ) = 0 of an inclined symmetry plane, dependent degree of
|
||||
/// freedom first.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Equation {
|
||||
pub node: u32,
|
||||
pub terms: Vec<(u8, f64)>,
|
||||
}
|
||||
|
||||
/// A coefficient below this fraction of its row's largest is round-off of the elimination.
|
||||
const NEGLIGIBLE: f64 = 1e-12;
|
||||
|
||||
/// The equations of the inclined symmetry conditions `names` (others are skipped), node by node
|
||||
/// in label order: n · u = 0 and, at a node in `rotating`, t₁ · θ = 0 and t₂ · θ = 0 for the
|
||||
/// plane's in-plane axes ([`fieldcraft_model::Symmetry`]). The rows of one node (several planes
|
||||
/// meet at an edge) are reduced against each other, so each has a dependent degree of freedom
|
||||
/// of its own: the largest coefficient among those `held` says are free. A row of held degrees
|
||||
/// of freedom only is left out when their values satisfy it; otherwise it is a problem, as is a
|
||||
/// row with none free that cannot be left out.
|
||||
pub(crate) fn symmetry_equations<'a>(
|
||||
model: &Model,
|
||||
r: &Resolved,
|
||||
names: impl IntoIterator<Item = &'a String>,
|
||||
rotating: &BTreeSet<u32>,
|
||||
held: impl Fn(u32, u8) -> Held,
|
||||
problems: &mut BTreeSet<String>,
|
||||
) -> Vec<Equation> {
|
||||
let mut rows: BTreeMap<u32, Vec<(&str, [f64; 6])>> = BTreeMap::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for name in names {
|
||||
if !seen.insert(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(BoundaryCondition::Symmetry { target, plane }) = model.boundary_condition(name) else { continue };
|
||||
let (Some(n), Some([t1, t2])) = (plane.inclined(), plane.in_plane()) else { continue };
|
||||
for &node in r.target_nodes(target) {
|
||||
let at = rows.entry(node).or_default();
|
||||
at.push((name, [n[0], n[1], n[2], 0.0, 0.0, 0.0]));
|
||||
if rotating.contains(&node) || target.reference_point().is_some() {
|
||||
at.push((name, [0.0, 0.0, 0.0, t1[0], t1[1], t1[2]]));
|
||||
at.push((name, [0.0, 0.0, 0.0, t2[0], t2[1], t2[2]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (node, rows) in rows {
|
||||
// The equations of this node so far: (dependent index, coefficients).
|
||||
let mut done: Vec<(usize, [f64; 6])> = Vec::new();
|
||||
for (name, mut c) in rows {
|
||||
let scale = c.iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
for (d, e) in &done {
|
||||
let f = c[*d] / e[*d];
|
||||
for k in 0..6 {
|
||||
c[k] -= f * e[k];
|
||||
}
|
||||
c[*d] = 0.0;
|
||||
}
|
||||
for v in &mut c {
|
||||
if v.abs() <= NEGLIGIBLE * scale {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
if c.iter().all(|v| *v == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let dof = |k: usize| k as u8 + 1;
|
||||
let pivot = (0..6).filter(|&k| c[k] != 0.0 && held(node, dof(k)) == Held::Free).fold(None, |best: Option<usize>, k| match best {
|
||||
Some(b) if c[b].abs() >= c[k].abs() => Some(b),
|
||||
_ => Some(k),
|
||||
});
|
||||
let Some(pivot) = pivot else {
|
||||
let mut residual = 0.0;
|
||||
let mut size = 0.0;
|
||||
let mut always = true;
|
||||
for k in (0..6).filter(|&k| c[k] != 0.0) {
|
||||
match held(node, dof(k)) {
|
||||
Held::Always(v) => {
|
||||
residual += c[k] * v;
|
||||
size += (c[k] * v).abs();
|
||||
}
|
||||
_ => always = false,
|
||||
}
|
||||
}
|
||||
if !always {
|
||||
problems.insert(format!(
|
||||
"boundary condition `{name}`: at node {node} every degree of freedom the inclined symmetry plane relates is held in some steps only or follows a coupling or tie, so none can be eliminated by its equation"
|
||||
));
|
||||
} else if residual.abs() > 1e-9 * size {
|
||||
problems.insert(format!("boundary condition `{name}`: node {node} is held off the inclined symmetry plane"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let mut terms = vec![(dof(pivot), c[pivot])];
|
||||
terms.extend((0..6).filter(|&k| k != pivot && c[k] != 0.0).map(|k| (dof(k), c[k])));
|
||||
out.push(Equation { node, terms });
|
||||
done.push((pivot, c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The global degree of freedom (1–6) a spring or dashpot direction acts along, when it is one
|
||||
/// (a vector or line along an axis counts: the sign does not matter to a spring).
|
||||
pub(crate) fn axis_dof(d: ResolvedDirection) -> Option<u8> {
|
||||
|
||||
@@ -36,7 +36,7 @@ impl Keyword {
|
||||
pub(crate) fn of(kind: ElementKind) -> Keyword {
|
||||
match kind {
|
||||
ElementKind::Hex20 => Keyword::SolidH20,
|
||||
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 => Keyword::Solid,
|
||||
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Pyramid5 | ElementKind::Hex8 => Keyword::Solid,
|
||||
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => Keyword::Shell,
|
||||
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => Keyword::Beam,
|
||||
}
|
||||
@@ -64,6 +64,8 @@ pub(crate) fn order(kind: ElementKind) -> &'static [usize] {
|
||||
ElementKind::Tet4 => &[0, 1, 2, 3, 3, 3, 3, 3],
|
||||
ElementKind::Tet10 => &[0, 1, 2, 3, 4, 5, 6, 8, 9, 7],
|
||||
ElementKind::Wedge6 => &[1, 0, 3, 4, 2, 2, 5, 5],
|
||||
// The degenerate hexahedron: the base, then the apex four times.
|
||||
ElementKind::Pyramid5 => &[0, 1, 2, 3, 4, 4, 4, 4],
|
||||
ElementKind::Hex8 => &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
ElementKind::Hex20 => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
|
||||
}
|
||||
@@ -95,7 +97,7 @@ pub(crate) struct Elform {
|
||||
///
|
||||
/// Solids: hex8 2 (eight-point), reduced 1 (one-point) with hourglass control, incompatible
|
||||
/// modes −18 (eight-point enhanced strain with incompatible modes); wedges 2 (as degenerate
|
||||
/// hexahedra), reduced 15 (two-point pentahedron); tet4 10 (one point, exact for a linear
|
||||
/// hexahedra), reduced 15 (two-point pentahedron); pyramids 2 (as degenerate hexahedra); tet4 10 (one point, exact for a linear
|
||||
/// tetrahedron), hybrid 13 (nodal pressure); tet10 16; hex20 23. Shells: quad4 16 (fully
|
||||
/// integrated), reduced 2 (one-point) with hourglass control; tri3 4 (C0 triangle); quad8 23;
|
||||
/// tri6 24. Rods: 3 (truss). 2D (in the XY plane): plane stress 12, plane strain 13, both one-point
|
||||
@@ -122,6 +124,8 @@ pub(crate) fn elform(kind: ElementKind, section: &SectionKind, f: Formulation, s
|
||||
ElementKind::Hex8 => plain(2),
|
||||
ElementKind::Wedge6 if reduced => plain(15),
|
||||
ElementKind::Wedge6 => plain(2),
|
||||
ElementKind::Pyramid5 if reduced => no("there is no reduced-integration form of"),
|
||||
ElementKind::Pyramid5 => plain(2),
|
||||
ElementKind::Tet4 => plain(10),
|
||||
_ if reduced && kind.is_quadratic() => no("there is no reduced-integration form of"),
|
||||
ElementKind::Tet10 => plain(16),
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
//! LS-DYNA's defaults; a step without nonlinear geometry and hyperelastic materials in nonlinear
|
||||
//! steps are refused); boundary conditions
|
||||
//! as `*BOUNDARY_SPC_*_ID` (named) and `*BOUNDARY_PRESCRIBED_MOTION_*_ID` for non-zero
|
||||
//! displacements; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
|
||||
//! displacements, a symmetry plane not normal to X, Y or Z as `*BOUNDARY_SPC_*_ID` in a
|
||||
//! `*DEFINE_COORDINATE_SYSTEM` whose x axis is the plane's normal; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
|
||||
//! resolved nodal values; pressure as `*LOAD_SEGMENT_SET`; gravity as `*LOAD_BODY_X/Y/Z`
|
||||
//! (with `*LOAD_BODY_PARTS` when it acts on a set); output as `*DATABASE_BINARY_D3PLOT`,
|
||||
//! `*DATABASE_SPCFORC` and `*DATABASE_EXTENT_BINARY`.
|
||||
@@ -1048,6 +1049,24 @@ fn boundary_condition(
|
||||
(r.reference_point_nodes.get(p).copied().ok_or_else(|| broken(format!("boundary condition `{name}`: reference point `{p}`")))?, "NODE")
|
||||
}
|
||||
};
|
||||
// A symmetry plane not normal to an axis: held in a coordinate system whose x axis is its
|
||||
// normal and whose xy plane holds its first in-plane axis (translation x, rotations y and z).
|
||||
if let BoundaryCondition::Symmetry { plane, .. } = bc
|
||||
&& let (Some(n), Some([t1, _])) = (plane.inclined(), plane.in_plane())
|
||||
{
|
||||
*constraint_id = constraint_id.saturating_add(1);
|
||||
let cid = *constraint_id;
|
||||
d.line("*DEFINE_COORDINATE_SYSTEM");
|
||||
d.header(&["cid", "xo", "yo", "zo", "xl", "yl", "zl", "cidl"]);
|
||||
d.card([Field::from(cid), Field::from(0.0), Field::from(0.0), Field::from(0.0), Field::from(n[0]), Field::from(n[1]), Field::from(n[2])]);
|
||||
d.header(&["xp", "yp", "zp"]);
|
||||
d.card([Field::from(t1[0]), Field::from(t1[1]), Field::from(t1[2])]);
|
||||
d.line(&format!("*BOUNDARY_SPC_{suffix}_ID"));
|
||||
id_card(d, constraint_id, name);
|
||||
d.header(&["nsid", "cid", "dofx", "dofy", "dofz", "dofrx", "dofry", "dofrz"]);
|
||||
d.card([Field::from(entity), Field::from(cid)].into_iter().chain([1, 0, 0, 0, 1, 1].map(|v: i32| Field::from(v))));
|
||||
return Ok(());
|
||||
}
|
||||
let dofs = bc.dofs();
|
||||
// Held degrees of freedom (in a frequency step every constrained one is held).
|
||||
let held: Vec<u8> = dofs.list().into_iter().filter(|dof| !is_static || bc.value(*dof).is_none_or(|v| v == 0.0)).collect();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! data (the entries). What the model holds becomes:
|
||||
//!
|
||||
//! - nodes and reference points: `GRID`;
|
||||
//! - solids: `CTETRA` (tet4, tet10), `CPENTA` (wedge6), `CHEXA` (hex8, hex20) with `PSOLID`;
|
||||
//! - solids: `CTETRA` (tet4, tet10), `CPENTA` (wedge6), `CPYRAM` (pyramid5), `CHEXA` (hex8, hex20) with `PSOLID`;
|
||||
//! shells: `CTRIA3`, `CQUAD4`, `CTRIA6`, `CQUAD8` with `PSHELL`; beams: `CBEAM` with `PBEAML`;
|
||||
//! rods: `CROD` with `PROD`. Nastran numbers a 20-node hexahedron's mid-side nodes bottom
|
||||
//! edges, vertical edges, top edges, where the model has bottom, top, vertical
|
||||
@@ -22,6 +22,9 @@
|
||||
//! the basic X axis through a `CORD2R`;
|
||||
//! - boundary conditions: per step, one constraint set of `SPC1` (held degrees of freedom) and
|
||||
//! `SPC` (prescribed values); in a modes step every constrained degree of freedom is held;
|
||||
//! symmetry about a plane not normal to X, Y or Z: one `MPC` set per step, an equation per node
|
||||
//! and held direction (n · u = 0, and t · θ = 0 about the plane's two axes at shell and beam
|
||||
//! grids), its dependent component one no `SPC` or rigid element constrains;
|
||||
//! - forces, moments and tractions: `FORCE` and `MOMENT` at the nodal values the model resolves,
|
||||
//! so every writer and the solver share one distribution; pressure: `PLOAD4` per element face;
|
||||
//! gravity: `GRAV`; a step with several loads combines them with `LOAD`;
|
||||
@@ -110,6 +113,7 @@ pub fn element_card(kind: ElementKind) -> &'static str {
|
||||
ElementKind::Quad8 => "CQUAD8",
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => "CTETRA",
|
||||
ElementKind::Wedge6 => "CPENTA",
|
||||
ElementKind::Pyramid5 => "CPYRAM",
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => "CHEXA",
|
||||
}
|
||||
}
|
||||
@@ -241,6 +245,8 @@ type Constraints = BTreeMap<u32, [Option<f64>; 6]>;
|
||||
struct Subcase<'m> {
|
||||
step: &'m Step,
|
||||
spc: Option<u32>,
|
||||
/// The equations of its inclined symmetry planes.
|
||||
mpc: Option<u32>,
|
||||
load: Option<u32>,
|
||||
method: Option<u32>,
|
||||
}
|
||||
@@ -269,6 +275,8 @@ struct Deck<'m> {
|
||||
dependent: BTreeMap<u32, (Dofs, &'m str)>,
|
||||
/// Constraint sets by id.
|
||||
spc_sets: Vec<(u32, Constraints)>,
|
||||
/// Multipoint constraint sets (inclined symmetry planes) by id.
|
||||
mpc_sets: Vec<(u32, Vec<crate::Equation>)>,
|
||||
/// Load name → load set id.
|
||||
load_sids: BTreeMap<&'m str, u32>,
|
||||
/// Combinations: (id, load set ids).
|
||||
@@ -310,7 +318,7 @@ impl<'m> Deck<'m> {
|
||||
model,
|
||||
r,
|
||||
sol,
|
||||
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, load: None, method: None }).collect(),
|
||||
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, mpc: None, load: None, method: None }).collect(),
|
||||
problems,
|
||||
next_sid: 1,
|
||||
materials: BTreeMap::new(),
|
||||
@@ -321,6 +329,7 @@ impl<'m> Deck<'m> {
|
||||
discrete: Vec::new(),
|
||||
dependent: BTreeMap::new(),
|
||||
spc_sets: Vec::new(),
|
||||
mpc_sets: Vec::new(),
|
||||
load_sids: BTreeMap::new(),
|
||||
combinations: Vec::new(),
|
||||
}
|
||||
@@ -587,6 +596,7 @@ impl<'m> Deck<'m> {
|
||||
/// One constraint set per distinct step constraint, checked against coupled nodes.
|
||||
fn plan_constraints(&mut self) -> Result<()> {
|
||||
let (model, r) = (self.model, self.r);
|
||||
let rotating = model.rotating_nodes();
|
||||
// Conflicts, one problem per pair of definitions: the nodes they meet on.
|
||||
let mut clashes: BTreeMap<(&str, &str, &str), BTreeSet<u32>> = BTreeMap::new();
|
||||
let mut on_dependent: BTreeMap<(&str, &str), BTreeSet<u32>> = BTreeMap::new();
|
||||
@@ -598,6 +608,10 @@ impl<'m> Deck<'m> {
|
||||
for name in &step.boundary_conditions {
|
||||
let Some(bc) = model.boundary_condition(name) else { continue };
|
||||
let dofs = bc.dofs();
|
||||
// Inclined symmetry planes hold no global degree of freedom: they are equations.
|
||||
if dofs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// In a modes step every constrained degree of freedom is held.
|
||||
let values: Vec<(u8, f64)> = dofs.list().into_iter().filter_map(|d| Some((d, if modes { 0.0 } else { bc.value(d)? }))).collect();
|
||||
for &node in r.target_nodes(bc.target()) {
|
||||
@@ -622,6 +636,19 @@ impl<'m> Deck<'m> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let dependent = &self.dependent;
|
||||
let held = |node: u32, dof: u8| match (
|
||||
dependent.get(&node),
|
||||
set.get(&node).and_then(|v| v.get(usize::from(dof).checked_sub(1)?).copied().flatten()),
|
||||
) {
|
||||
(Some((d, _)), _) if d.contains(dof) => crate::Held::Sometimes,
|
||||
(_, Some(v)) => crate::Held::Always(v),
|
||||
_ => crate::Held::Free,
|
||||
};
|
||||
let equations = crate::symmetry_equations(model, r, &step.boundary_conditions, &rotating, held, &mut self.problems);
|
||||
if !equations.is_empty() {
|
||||
sub.mpc = Some(intern(&mut self.mpc_sets, &mut self.next_sid, equations)?);
|
||||
}
|
||||
if !set.is_empty() {
|
||||
sub.spc = Some(intern(&mut self.spc_sets, &mut self.next_sid, set)?);
|
||||
}
|
||||
@@ -707,7 +734,7 @@ impl<'m> Deck<'m> {
|
||||
let _ = writeln!(out, "SUBCASE {}", i + 1);
|
||||
let title: String = format!(" SUBTITLE = {}", sub.step.name).chars().take(72).collect();
|
||||
let _ = writeln!(out, "{title}");
|
||||
for (key, sid) in [("SPC", sub.spc), ("LOAD", sub.load), ("METHOD", sub.method)] {
|
||||
for (key, sid) in [("SPC", sub.spc), ("MPC", sub.mpc), ("LOAD", sub.load), ("METHOD", sub.method)] {
|
||||
if let Some(sid) = sid {
|
||||
let _ = writeln!(out, " {key} = {sid}");
|
||||
}
|
||||
@@ -715,6 +742,7 @@ impl<'m> Deck<'m> {
|
||||
for v in &sub.step.output {
|
||||
let request = match v {
|
||||
OutputVariable::Displacement => "DISPLACEMENT",
|
||||
OutputVariable::ReactionForce if sub.mpc.is_some() => "SPCFORCES = ALL\n MPCFORCES",
|
||||
OutputVariable::ReactionForce => "SPCFORCES",
|
||||
OutputVariable::Stress => "STRESS",
|
||||
OutputVariable::Strain => "STRAIN",
|
||||
@@ -892,6 +920,23 @@ impl<'m> Deck<'m> {
|
||||
Card::new("SPC").int(*sid).int(node).int(component(Dofs::from_bits(mask))).real(value).write(out)?;
|
||||
}
|
||||
}
|
||||
for (sid, equations) in &self.mpc_sets {
|
||||
for sub in self.subcases.iter().filter(|s| s.mpc == Some(*sid)) {
|
||||
comment(out, &format!("step {}: inclined symmetry planes, MPC {sid} (the first grid component is dependent)", sub.step.name));
|
||||
}
|
||||
for e in equations {
|
||||
// SID, then (G, C, A) triples: two on the first line (its last field blank), two
|
||||
// per continuation after a blank first field.
|
||||
let mut card = Card::new("MPC").int(*sid);
|
||||
for (i, (dof, c)) in e.terms.iter().enumerate() {
|
||||
if i % 2 == 0 && i > 0 {
|
||||
card = card.blank().blank();
|
||||
}
|
||||
card = card.int(e.node).int(i64::from(*dof)).real(*c);
|
||||
}
|
||||
card.write(out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -461,10 +461,11 @@ fn written_connectivity_has_a_positive_jacobian_in_abaqus_conventions() {
|
||||
#[test]
|
||||
fn model_faces_are_abaqus_faces() {
|
||||
// Each face's node positions (1-based, Abaqus element library, our transcription).
|
||||
let table: [(ElementKind, &[&[usize]]); 3] = [
|
||||
let table: [(ElementKind, &[&[usize]]); 4] = [
|
||||
(ElementKind::Hex8, &[&[1, 2, 3, 4], &[5, 8, 7, 6], &[1, 5, 6, 2], &[2, 6, 7, 3], &[3, 7, 8, 4], &[4, 8, 5, 1]]),
|
||||
(ElementKind::Tet4, &[&[1, 2, 3], &[1, 4, 2], &[2, 4, 3], &[3, 4, 1]]),
|
||||
(ElementKind::Wedge6, &[&[1, 2, 3], &[4, 6, 5], &[1, 4, 5, 2], &[2, 5, 6, 3], &[3, 6, 4, 1]]),
|
||||
(ElementKind::Pyramid5, &[&[1, 2, 3, 4], &[1, 5, 2], &[2, 5, 3], &[3, 5, 4], &[4, 5, 1]]),
|
||||
];
|
||||
for (kind, faces) in table {
|
||||
assert_eq!(kind.faces().len(), faces.len());
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Symmetry about a plane not normal to X, Y or Z in the three decks (#242): Abaqus `*EQUATION`s,
|
||||
//! Nastran `MPC` sets and an LS-DYNA `*BOUNDARY_SPC` in a local coordinate system, on a small
|
||||
//! shell plate of our own whose diagonal x = y is the plane.
|
||||
|
||||
use fieldcraft_decks::{Error, abaqus, lsdyna, nastran};
|
||||
use fieldcraft_model::{
|
||||
BoundaryCondition, Elastic, ElementFace, ElementKind, Formulation, Load, Material, Model, Procedure, Section, SectionKind, Set, Step, Symmetry,
|
||||
Target,
|
||||
};
|
||||
|
||||
/// A 2 × 2 quad4 shell plate in the XY plane (nodes 1–9, x and y in 0, 1, 2), clamped on x = 0,
|
||||
/// the inclined plane (normal (1, −1, 0)) on the diagonal nodes 1, 5 and 9, under pressure;
|
||||
/// steps `Load` (static) and `Modes`, `in_modes` saying whether the plane is active in both.
|
||||
fn plate(in_modes: bool) -> Model {
|
||||
let mut m = Model::default();
|
||||
for j in 0..3u32 {
|
||||
for i in 0..3u32 {
|
||||
m.set_node(1 + i + 3 * j, [f64::from(i), f64::from(j), 0.0]).unwrap();
|
||||
}
|
||||
}
|
||||
for (e, n) in [(1, [1, 2, 5, 4]), (2, [2, 3, 6, 5]), (3, [4, 5, 8, 7]), (4, [5, 6, 9, 8])] {
|
||||
m.set_element(e, ElementKind::Quad4, n.to_vec()).unwrap();
|
||||
}
|
||||
m.add_material(
|
||||
"Steel",
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210e3, poisson: 0.3 }), density: Some(7.85e-9), ..Material::default() },
|
||||
)
|
||||
.unwrap();
|
||||
m.add_set("all", Set { elements: vec![1, 2, 3, 4], ..Set::default() }).unwrap();
|
||||
let kind = SectionKind::Shell { thickness: 0.1, offset: 0.0, points: 5 };
|
||||
m.add_section("plate", Section { set: "all".into(), material: "Steel".into(), kind, formulation: Formulation::default() }).unwrap();
|
||||
m.add_set("edge", Set { nodes: vec![1, 4, 7], ..Set::default() }).unwrap();
|
||||
m.add_set("diagonal", Set { nodes: vec![1, 5, 9], ..Set::default() }).unwrap();
|
||||
let faces = (1..=4).map(|element| ElementFace { element, face: 1 }).collect();
|
||||
m.add_set("top", Set { faces, ..Set::default() }).unwrap();
|
||||
m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap();
|
||||
let plane = Symmetry::from_normal([1.0, -1.0, 0.0]).unwrap();
|
||||
m.add_boundary_condition("sym", BoundaryCondition::Symmetry { target: Target::Set("diagonal".into()), plane }).unwrap();
|
||||
m.add_load("q", Load::Pressure { set: "top".into(), magnitude: 0.01 }).unwrap();
|
||||
let mut load = Step::new("Load", Procedure::Static {});
|
||||
load.boundary_conditions = ["clamp", "sym"].map(String::from).into();
|
||||
load.loads = ["q".to_string()].into();
|
||||
m.add_step(load, None).unwrap();
|
||||
let mut modes = Step::new("Modes", Procedure::Frequency { modes: 3, min_hz: None, max_hz: None });
|
||||
modes.boundary_conditions = if in_modes { ["clamp", "sym"].map(String::from).into() } else { ["clamp".to_string()].into() };
|
||||
m.add_step(modes, None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// A Nastran real: an exponent may come without `E`.
|
||||
fn real(s: &str) -> f64 {
|
||||
let split = s.char_indices().skip(1).find(|(i, c)| (*c == '+' || *c == '-') && !s[..*i].ends_with(['E', 'e']));
|
||||
match split {
|
||||
Some((i, _)) => format!("{}E{}", &s[..i], &s[i..]).parse().unwrap(),
|
||||
None => s.parse().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
(0..3).map(|k| a[k] * b[k]).sum()
|
||||
}
|
||||
|
||||
/// Every equation is one of the plane's at a free node: its terms on one node, its coefficients
|
||||
/// along the normal (translations) or along an in-plane axis (rotations), unit length; nodes 5
|
||||
/// and 9 get three each (n · u, t₁ · θ, t₂ · θ); node 1 is clamped, so its equations are left out;
|
||||
/// no dependent degree of freedom repeats.
|
||||
fn check(equations: &[(u32, Vec<(u8, f64)>)]) {
|
||||
let n = [1.0 / 2f64.sqrt(), -1.0 / 2f64.sqrt(), 0.0];
|
||||
let mut dependents = std::collections::BTreeSet::new();
|
||||
let mut per_node = std::collections::BTreeMap::<u32, usize>::new();
|
||||
for (node, terms) in equations {
|
||||
assert!(dependents.insert((*node, terms[0].0)), "{node} {terms:?}");
|
||||
*per_node.entry(*node).or_default() += 1;
|
||||
let mut v = [0.0; 3];
|
||||
let rotation = terms[0].0 > 3;
|
||||
for (dof, c) in terms {
|
||||
assert_eq!(*dof > 3, rotation, "{terms:?}");
|
||||
v[usize::from((dof - 1) % 3)] = *c;
|
||||
}
|
||||
assert!((dot(v, v) - 1.0).abs() < 1e-12, "{terms:?}");
|
||||
if rotation {
|
||||
assert!(dot(v, n).abs() < 1e-12, "{terms:?}");
|
||||
} else {
|
||||
assert!((dot(v, n).abs() - 1.0).abs() < 1e-12, "{terms:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(per_node, [(5, 3), (9, 3)].into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abaqus_writes_equations_in_the_model_data() {
|
||||
let deck = abaqus::write(&plate(true)).unwrap();
|
||||
let lines: Vec<&str> = deck.lines().collect();
|
||||
let mut equations = Vec::new();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if *l == "*EQUATION" {
|
||||
let count: usize = lines[i + 1].trim().parse().unwrap();
|
||||
let f: Vec<&str> = lines[i + 2].split(',').map(str::trim).collect();
|
||||
assert_eq!(f.len(), 3 * count, "{}", lines[i + 2]);
|
||||
let node: u32 = f[0].parse().unwrap();
|
||||
let terms = f.chunks(3).map(|t| (t[1].parse().unwrap(), t[2].parse().unwrap())).collect();
|
||||
assert!(f.chunks(3).all(|t| t[0] == f[0]));
|
||||
equations.push((node, terms));
|
||||
}
|
||||
}
|
||||
check(&equations);
|
||||
// Model data, before the first step; the plane is no `*BOUNDARY` line.
|
||||
let first_step = deck.find("*STEP").unwrap();
|
||||
assert!(deck.rfind("*EQUATION").unwrap() < first_step);
|
||||
assert!(!deck.contains("SYMM"), "{deck}");
|
||||
|
||||
let e = abaqus::write(&plate(false)).unwrap_err();
|
||||
assert!(matches!(&e, Error::Unsupported { what, .. } if what.contains("boundary condition `sym`") && what.contains("some steps only")), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nastran_writes_one_mpc_set_per_step() {
|
||||
// A static deck and a modes deck (one solution per run).
|
||||
for (steps, sol) in [("Load", 101), ("Modes", 103)] {
|
||||
let m = fieldcraft_decks::with_steps(&plate(true), &[steps.to_string()]).unwrap();
|
||||
let deck = nastran::write(&m).unwrap();
|
||||
assert!(deck.contains(&format!("SOL {sol}")));
|
||||
let sid = deck.lines().find_map(|l| l.trim().strip_prefix("MPC = ")).unwrap().to_string();
|
||||
// MPC entries: SID, then (grid, component, coefficient) triples; the continuation after
|
||||
// two triples starts with a blank field. Large-field lines are 16 columns per field.
|
||||
let mut equations = Vec::new();
|
||||
let mut fields: Vec<String> = Vec::new();
|
||||
let flush = |fields: &mut Vec<String>, equations: &mut Vec<(u32, Vec<(u8, f64)>)>| {
|
||||
if fields.is_empty() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(fields[0], sid);
|
||||
let data: Vec<&String> = fields[1..].iter().enumerate().filter(|(i, _)| !matches!(i % 8, 6 | 7)).map(|(_, f)| f).collect();
|
||||
let triples: Vec<(u32, u8, f64)> = data
|
||||
.chunks(3)
|
||||
.filter(|t| t.len() == 3 && !t[0].is_empty())
|
||||
.map(|t| (t[0].parse().unwrap(), t[1].parse().unwrap(), real(t[2])))
|
||||
.collect();
|
||||
assert!(triples.iter().all(|t| t.0 == triples[0].0));
|
||||
equations.push((triples[0].0, triples.iter().map(|t| (t.1, t.2)).collect()));
|
||||
fields.clear();
|
||||
};
|
||||
for line in deck.lines() {
|
||||
let (head, rest) = line.split_at(8.min(line.len()));
|
||||
let row: Vec<String> = rest.as_bytes().chunks(16).map(|c| String::from_utf8_lossy(c).trim().to_string()).collect();
|
||||
let mut row = row;
|
||||
row.resize(4, String::new());
|
||||
match head.trim() {
|
||||
"MPC*" => {
|
||||
flush(&mut fields, &mut equations);
|
||||
fields.extend(row);
|
||||
}
|
||||
"*" if !fields.is_empty() => fields.extend(row),
|
||||
_ => flush(&mut fields, &mut equations),
|
||||
}
|
||||
}
|
||||
flush(&mut fields, &mut equations);
|
||||
check(&equations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsdyna_holds_the_plane_in_a_local_system() {
|
||||
let deck = lsdyna::write(&plate(true)).unwrap().text;
|
||||
let block = |keyword: &str| -> Vec<Vec<f64>> {
|
||||
let at = deck.find(keyword).unwrap();
|
||||
deck[at..]
|
||||
.lines()
|
||||
.skip(1)
|
||||
.filter(|l| !l.starts_with('$'))
|
||||
.take_while(|l| !l.starts_with('*'))
|
||||
.map(|l| l.as_bytes().chunks(20).filter_map(|c| String::from_utf8_lossy(c).trim().parse().ok()).collect())
|
||||
.collect()
|
||||
};
|
||||
let system = block("*DEFINE_COORDINATE_SYSTEM");
|
||||
let (cid, x, p) = (system[0][0], [system[0][4], system[0][5], system[0][6]], [system[1][0], system[1][1], system[1][2]]);
|
||||
assert!((x[0] + x[1]).abs() < 1e-12 && x[2] == 0.0 && (dot(x, x) - 1.0).abs() < 1e-12, "{x:?}");
|
||||
assert!(dot(x, p).abs() < 1e-12, "{p:?}");
|
||||
// The SPC after the system: its id card, then nsid, cid and the held local directions.
|
||||
let at = deck.find("*DEFINE_COORDINATE_SYSTEM").unwrap();
|
||||
let spc = deck[at..].find("*BOUNDARY_SPC_SET_ID").unwrap() + at;
|
||||
let card: Vec<f64> = deck[spc..]
|
||||
.lines()
|
||||
.skip(2)
|
||||
.find(|l| !l.starts_with('$'))
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
.chunks(20)
|
||||
.map(|c| String::from_utf8_lossy(c).trim().parse().unwrap())
|
||||
.collect();
|
||||
assert_eq!(card[1], cid);
|
||||
assert_eq!(&card[2..], [1.0, 0.0, 0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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";
|
||||
pub const ISSUE_URL: &str = "https://github.com/tangency-labs/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.
|
||||
@@ -329,7 +329,7 @@ mod tests {
|
||||
..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.url.starts_with("https://github.com/tangency-labs/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}");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! 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>) {
|
||||
cancel_when(s, command, params, stage.map(|stage| move |now: &progress::State| now.stage.starts_with(stage) && now.done > 0.0));
|
||||
}
|
||||
|
||||
/// [`cancel`] once the reported state passes `when` (before it starts with `None`).
|
||||
fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option<impl Fn(&progress::State) -> bool>) {
|
||||
let (model, journal) = (s.model.clone(), s.journal.len());
|
||||
let design = s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
|
||||
let p = Progress::new();
|
||||
if when.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(when) = &when {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
let now = p.snapshot();
|
||||
if when(&now) {
|
||||
at = Instant::now();
|
||||
p.cancel();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(p.is_cancelled(), "{command}: never reached the 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");
|
||||
assert_eq!(s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>()), design, "{command}: the design changed");
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
/// The partition, the cells mesher and the plan's apply (#545) stop at their stages: between
|
||||
/// the kernel's cuts, between cells, between the projection's phases.
|
||||
#[test]
|
||||
fn partition_cells_and_plan_apply_stop_and_change_nothing() {
|
||||
let (_dir, mut s) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "Y5", "by": {"type": "offset", "plane": "zx", "offset": 5}})).unwrap();
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Body1"})).unwrap();
|
||||
cancel(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), None);
|
||||
cancel_when(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), Some(|now: &progress::State| now.stage.starts_with("Projection: ")));
|
||||
cancel(&mut s, "geometry.partition", json!({"name": "Half", "datum": "X30"}), None);
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "X30"})).unwrap();
|
||||
// The second partition cuts again by both: cancelled once the first cut is made.
|
||||
cancel(&mut s, "geometry.partition", json!({"name": "Strip", "datum": "Y5"}), Some("Partition cuts"));
|
||||
let cells = json!({"bodies": ["Body1/a", "Body1/b"], "default": "tet", "size": 0.5});
|
||||
cancel(&mut s, "mesh.cells", cells.clone(), None);
|
||||
// Inside a cell's tet mesher: the cancel reaches the command, never a fallback.
|
||||
cancel(&mut s, "mesh.cells", cells, Some("Refinement round"));
|
||||
assert!(s.model.nodes().is_empty());
|
||||
}
|
||||
@@ -238,8 +238,22 @@ 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", "geometry.features"],
|
||||
),
|
||||
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
|
||||
ready(
|
||||
"geometry.check",
|
||||
"Check Geometry",
|
||||
Geometry,
|
||||
"Inspect",
|
||||
"Short edges, small and sliver faces and near faces against the element size, located",
|
||||
),
|
||||
ready(
|
||||
"mesh.surface.check",
|
||||
"Check Surface",
|
||||
@@ -254,7 +268,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
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.edit", "geometry.datum.delete", "geometry.datum.suppress"],
|
||||
),
|
||||
covering(
|
||||
"geometry.datum.axis",
|
||||
@@ -278,7 +292,46 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
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.delete", "geometry.partition.suppress"],
|
||||
),
|
||||
covering(
|
||||
"geometry.faces.merge",
|
||||
"Merge Faces",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged",
|
||||
&["geometry.faces.merge.delete"],
|
||||
),
|
||||
ready(
|
||||
"geometry.face.absorb",
|
||||
"Absorb Faces",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found",
|
||||
),
|
||||
covering(
|
||||
"geometry.face.split",
|
||||
"Split Face",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them",
|
||||
&["geometry.face.split.delete"],
|
||||
),
|
||||
covering(
|
||||
"geometry.defeature",
|
||||
"Defeature",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off",
|
||||
&["geometry.defeature.propose", "geometry.defeature.delete"],
|
||||
),
|
||||
covering(
|
||||
"geometry.stitch",
|
||||
"Stitch",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Close an imported surface's gaps under a tolerance: open points welded, T-junctions split",
|
||||
&["geometry.stitch.delete"],
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
@@ -291,6 +344,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"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"],
|
||||
),
|
||||
ready_more(
|
||||
"mesh.size.set",
|
||||
"Local Size",
|
||||
Mesh,
|
||||
"Size",
|
||||
"Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it",
|
||||
&["mesh.size.growth", "mesh.size.clear", "mesh.size.list"],
|
||||
),
|
||||
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
|
||||
ready(
|
||||
"mesh.tet.generate",
|
||||
@@ -322,9 +383,32 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Partitioned Mesh",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
|
||||
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions, tet cells to hex cells through conforming pyramids",
|
||||
&["mesh.hex.blocks"],
|
||||
),
|
||||
ready(
|
||||
"mesh.hex.extrude",
|
||||
"Extrude to Surface",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"Hex layers from a meshed or planar section up to a curved target surface, following the side faces",
|
||||
),
|
||||
ready_more(
|
||||
"mesh.plan",
|
||||
"Mesh Plan",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"A hex-meshing plan from the features that built a body (sweeps, blocks, O-grids, layers, imprints, copies); saved, editable, applied with the structured meshers",
|
||||
&["mesh.plan.apply"],
|
||||
),
|
||||
ready_more(
|
||||
"mesh.block",
|
||||
"Mesh Block",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"A mapped block of hex, wedge or quad elements between corner points; projection blocking (a block topology projected onto the part)",
|
||||
&["mesh.hex.project"],
|
||||
),
|
||||
soon(
|
||||
"mesh.hex.decompose",
|
||||
"Decompose",
|
||||
@@ -333,7 +417,6 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"M5",
|
||||
"Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh)",
|
||||
),
|
||||
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
|
||||
ready(
|
||||
"mesh.hex.octree",
|
||||
"Octree Hex",
|
||||
@@ -346,20 +429,44 @@ 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(
|
||||
"mesh.node.move",
|
||||
"Move Nodes",
|
||||
Mesh,
|
||||
"Improve",
|
||||
"Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes",
|
||||
),
|
||||
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", "mesh.mirror"],
|
||||
"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"),
|
||||
ready(
|
||||
"mesh.quality.mark",
|
||||
"Mark Bad Elements",
|
||||
Mesh,
|
||||
"Verify",
|
||||
"Mark the elements below quality limits as a set, located and drawn in the marker colour",
|
||||
),
|
||||
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
|
||||
ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"),
|
||||
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
|
||||
// ---- Properties ----
|
||||
ready_more(
|
||||
@@ -443,12 +550,13 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces",
|
||||
&["model.fluid_domain.edit", "model.fluid_domain.delete"],
|
||||
),
|
||||
ready(
|
||||
ready_more(
|
||||
"model.fluid_domain.boundary",
|
||||
"Inlet & Outlet",
|
||||
Loads,
|
||||
"Flow",
|
||||
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces",
|
||||
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces; held temperatures, heat fluxes and adiabatic faces",
|
||||
&["model.fluid_domain.thermal_boundary"],
|
||||
),
|
||||
// ---- Interactions ----
|
||||
ready_more(
|
||||
@@ -498,7 +606,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",
|
||||
@@ -542,7 +657,13 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Transient dynamics of an explicit step by central differences (contact and erosion coming in M10)",
|
||||
),
|
||||
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
|
||||
ready("solve.fluid", "Lattice Boltzmann", Solve, "Fluids", "Lattice Boltzmann flow of a fluid step on the CPU, with wall forces"),
|
||||
ready(
|
||||
"solve.fluid",
|
||||
"Lattice Boltzmann",
|
||||
Solve,
|
||||
"Fluids",
|
||||
"Lattice Boltzmann flow of a fluid step on the CPU or a GPU, with wall forces, and the heat it carries",
|
||||
),
|
||||
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
|
||||
// ---- Results ----
|
||||
ready_more("results.deformed", "Deformed", Results, "Display", "The deformed shape of a solved step, to scale", &["results.clear"]),
|
||||
@@ -564,18 +685,28 @@ 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"),
|
||||
ready("results.xy", "XY Plot", Results, "Plots", "Plot a quantity against the load factor, time or mode"),
|
||||
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
|
||||
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
|
||||
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
|
||||
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
|
||||
ready_more(
|
||||
"results.streamlines",
|
||||
"Streamlines",
|
||||
Results,
|
||||
"Plots",
|
||||
"A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area",
|
||||
&["results.slice", "results.surface"],
|
||||
),
|
||||
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
|
||||
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"),
|
||||
@@ -604,7 +735,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
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_test", "app.update.check"],
|
||||
&["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_reports.delete", "diagnostics.crash_test", "app.update.check"],
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! 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: a set on
|
||||
//! each side's partition faces and a tie between them (`interaction.tie`; dual mortar
|
||||
//! constraints in the implicit analyses, so a constant stress crosses the interface exactly).
|
||||
//! 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
|
||||
@@ -17,20 +27,21 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::cdt::CdtOptions;
|
||||
use fieldcraft_mesh::hex::{HexMesh, HexMethod};
|
||||
use fieldcraft_mesh::hex::{Cell, CellsMesh, HexMesh, HexMethod, HexOptions, hex_mesh_swept_cells};
|
||||
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_hex, hex_body};
|
||||
use super::mesh::{BodyTets, add_tets, mesh_body, quality_options};
|
||||
use super::hex::{Input, add_cells, add_hex, hex_body};
|
||||
use super::mesh::{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, a set on each side and a tie between them are made (`tie_<partition>_<k>`, sets `<tie>_main` and `<tie>_secondary`: the side with fewer nodes is the main surface), replacing ties of those names from an earlier run. Reports each cell's method (and why auto fell back to tet), and each interface: `merged` with the nodes merged, or `tied`"#,
|
||||
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), "transition": "pyramid" | "tie" (how a tet cell meets hex cells; default pyramid)} — 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); between a tet cell and the hex cells across its partitions, with "transition": "pyramid" (linear meshes), the tet cell is meshed after them with a pyramid (pyramid5) on every hex element face of the cut, its tetrahedra sharing the pyramids' sides, so the mesh is conforming there (`joined`: `pyramids`, with their count); where that cannot be built (a cut the hex faces do not cover exactly, quadratic elements, a pyramid that would reach through a thin cell) the cell says why in `no_pyramids` and is tied; otherwise, as between a hex and a tet cell with "transition": "tie", 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` (with `why` when both cells are of one design body: the tet cell and why auto fell back, or why the body's cells were not swept together)"#,
|
||||
form: Some(super::forms::CELLS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
@@ -68,10 +79,22 @@ impl Method {
|
||||
}
|
||||
}
|
||||
|
||||
/// A meshed cell.
|
||||
mod transition;
|
||||
|
||||
/// A meshed cell; tet cells are meshed after the hex cells are in the model, so they can meet
|
||||
/// them through pyramids.
|
||||
enum Meshed<'a> {
|
||||
Hex(Input<'a>, HexMesh),
|
||||
Tet(BodyTets),
|
||||
Hex(Box<(Input<'a>, HexMesh)>),
|
||||
Tet,
|
||||
}
|
||||
|
||||
/// How a tet cell joins the hex cells across its partitions.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Transition {
|
||||
/// Pyramids on the hex faces, conforming (falls back to a tie with the reason).
|
||||
Pyramid,
|
||||
/// A tie (dual mortar), each mesh as its method made it.
|
||||
Tie,
|
||||
}
|
||||
|
||||
fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -93,6 +116,11 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(EngineError::invalid("order", "must be 1 or 2")),
|
||||
},
|
||||
};
|
||||
let transition = match p.get("transition").map(|v| v.as_str()) {
|
||||
None | Some(Some("pyramid")) => Transition::Pyramid,
|
||||
Some(Some("tie")) => Transition::Tie,
|
||||
Some(_) => return Err(EngineError::invalid("transition", "must be \"pyramid\" or \"tie\"")),
|
||||
};
|
||||
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(),
|
||||
@@ -144,37 +172,95 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = hex_params.as_object_mut() {
|
||||
o.remove("order");
|
||||
}
|
||||
// The cells are meshed at one size, so the size controls apply to all of them alike.
|
||||
let sizing = super::sizes::sizing(s)?.joint(&names.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
|
||||
let tet_options = sizing.tet_options("", &tet_params, &tet_options);
|
||||
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;
|
||||
}
|
||||
fieldcraft_model::progress::stage(&format!("Cells: sweeping {source}'s cells together"), None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
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, &sizing)? {
|
||||
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 shape in shapes {
|
||||
let method = methods.get(&shape.name).copied().unwrap_or(default);
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
if joint.get(i).copied().flatten().is_some() {
|
||||
continue;
|
||||
}
|
||||
fieldcraft_model::progress::stage(&format!("Cells: meshing {}", shape.name), Some(shapes.len() as f64), "cells");
|
||||
fieldcraft_model::progress::count(i as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
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)?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size, &sizing)?;
|
||||
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)
|
||||
(Meshed::Hex(Box::new((b, mesh))), info)
|
||||
}
|
||||
Method::Tet => (Meshed::Tet(tet(s)?), json!({"method": "tet"})),
|
||||
Method::Tet => (Meshed::Tet, 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)
|
||||
(Meshed::Hex(Box::new((b, mesh))), info)
|
||||
}
|
||||
Err(e) => (Meshed::Tet(tet(s)?), json!({"method": "tet", "why": e.to_string()})),
|
||||
// A cancelled job stops; any other failure falls back to tet.
|
||||
Err(e) if e.is_cancelled() => return Err(e),
|
||||
Err(e) => (Meshed::Tet, 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));
|
||||
}
|
||||
@@ -183,14 +269,68 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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::with_capacity(meshed.len());
|
||||
for (cell, mut info) in meshed {
|
||||
let (kind, elements, nodes) = match &cell {
|
||||
Meshed::Hex(b, m) => {
|
||||
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));
|
||||
}
|
||||
// The cells meshed alone, in the order of `shapes`: the hex ones first, then the tet ones,
|
||||
// which may meet the hex cells through pyramids.
|
||||
let alone: Vec<usize> = (0..shapes.len()).filter(|&i| joint.get(i).copied().flatten().is_none()).collect();
|
||||
let mut hex_cells: Vec<String> = swept.iter().flat_map(|(_, _, inputs, _)| inputs.iter().map(|b| b.name.clone())).collect();
|
||||
let mut tet_cells = Vec::new();
|
||||
for (&i, (cell, info)) in alone.iter().zip(meshed.iter_mut()) {
|
||||
let mut info = std::mem::take(info);
|
||||
match &*cell {
|
||||
Meshed::Hex(hex) => {
|
||||
let (b, m) = &**hex;
|
||||
add_hex(model, b, m)?;
|
||||
(m.kind, m.element_count(), m.nodes.len())
|
||||
hex_cells.push(b.name.clone());
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("kind".into(), json!(m.kind.name()));
|
||||
o.insert("elements".into(), json!(m.element_count()));
|
||||
o.insert("nodes".into(), json!(m.nodes.len()));
|
||||
}
|
||||
report.push((i, info));
|
||||
}
|
||||
Meshed::Tet(t) => {
|
||||
Meshed::Tet => tet_cells.push((i, info)),
|
||||
}
|
||||
}
|
||||
for (i, mut info) in tet_cells {
|
||||
let Some(shape) = shapes.get(i) else { continue };
|
||||
fieldcraft_model::progress::stage(&format!("Cells: meshing {} (tet)", shape.name), Some(shapes.len() as f64), "cells");
|
||||
fieldcraft_model::progress::count(i as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let input = Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? };
|
||||
let pyramids = if transition == Transition::Tie {
|
||||
Err("`transition` is \"tie\"".to_string())
|
||||
} else if quadratic {
|
||||
Err("pyramid transitions are linear (pyramid5); order 2 is tied".to_string())
|
||||
} else {
|
||||
transition::mesh_with_pyramids(&design, model, &tet_params, &input, &hex_cells, &cdt, &tet_options)?
|
||||
};
|
||||
let (kind, elements, nodes) = match pyramids {
|
||||
Ok(c) => {
|
||||
interfaces.extend(c.interfaces.iter().cloned());
|
||||
let kind = c.tets.mesh.kind;
|
||||
let (elements, nodes) = transition::add(model, c)?;
|
||||
(kind, elements, nodes)
|
||||
}
|
||||
Err(why) => {
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("no_pyramids".into(), json!(why));
|
||||
}
|
||||
let t = mesh_body(Some(&design), &tet_params, input, &cdt, &tet_options, quality, true)?;
|
||||
add_tets(model, &t.name, &t.face_names, &t.mesh)?;
|
||||
(t.mesh.kind, t.mesh.elements.len(), t.mesh.points.len())
|
||||
}
|
||||
@@ -200,13 +340,140 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("elements".into(), json!(elements));
|
||||
o.insert("nodes".into(), json!(nodes));
|
||||
}
|
||||
report.push(info);
|
||||
report.push((i, info));
|
||||
}
|
||||
let interfaces = join(model, &names, COINCIDENT * diagonal)?;
|
||||
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();
|
||||
fieldcraft_model::progress::stage("Cells: joining the cells", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
|
||||
explain_ties(&design, &report, &mut interfaces);
|
||||
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],
|
||||
sizing: &super::sizes::Sizing,
|
||||
) -> Result<std::result::Result<CellsMesh, String>> {
|
||||
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
|
||||
let field = sizing.field("", h)?;
|
||||
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, &field, &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,
|
||||
@@ -268,54 +535,160 @@ fn sides(model: &Model, cells: &[String]) -> BTreeMap<String, [Vec<Side>; 2]> {
|
||||
|
||||
/// Join the cells across every partition: merge where the two sides match node for node, tie
|
||||
/// where they do not. Returns the interfaces.
|
||||
fn join(model: &mut Model, cells: &[String], tol: f64) -> Result<Vec<Value>> {
|
||||
///
|
||||
/// 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) {
|
||||
let mut k = 0;
|
||||
for a in &a_sides {
|
||||
for b in &b_sides {
|
||||
let overlap = (0..3).all(|i| a.lo[i] <= b.hi[i] + tol && b.lo[i] <= a.hi[i] + tol);
|
||||
if !overlap {
|
||||
// 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;
|
||||
}
|
||||
k += 1;
|
||||
let matched = matching(model, a, b, tol);
|
||||
if let Some(map) = matched {
|
||||
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}));
|
||||
} else {
|
||||
let name = format!("tie_{partition}_{k}");
|
||||
let (main, secondary) = if a.nodes.len() <= b.nodes.len() { (a, b) } else { (b, a) };
|
||||
let set = |side: &Side| -> Result<Set> { Ok(Set::of_geometry(vec![GeometryRef::face(side.body.as_str(), side.face.as_str())?])) };
|
||||
let (main_set, secondary_set) = (format!("{name}_main"), format!("{name}_secondary"));
|
||||
for (n, side) in [(&main_set, main), (&secondary_set, secondary)] {
|
||||
if model.set(n).is_some() {
|
||||
model.replace_set(n, set(side)?)?;
|
||||
} else {
|
||||
model.add_set(n, set(side)?)?;
|
||||
}
|
||||
}
|
||||
if model.tie(&name).is_some() {
|
||||
model.remove_tie(&name)?;
|
||||
}
|
||||
model.add_tie(&name, Tie::new(&main_set, &secondary_set))?;
|
||||
out.push(json!({
|
||||
"partition": partition,
|
||||
"cells": [a.body, b.body],
|
||||
"joined": "tied",
|
||||
"tie": name,
|
||||
"main": main.body,
|
||||
"secondary": secondary.body,
|
||||
"faces": [a.faces.len(), b.faces.len()],
|
||||
}));
|
||||
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)
|
||||
}
|
||||
|
||||
/// Give every tied interface between cells of one design body its reason (`why`), from the
|
||||
/// cells' reports (#396): a cell meshed with tet (and why auto fell back), the reason the body's
|
||||
/// cells did not sweep together, or a cell whose method does not sweep with the others; so an
|
||||
/// agent knows which cut or method to change for a conforming mesh.
|
||||
fn explain_ties(design: &fieldcraft_geometry::Design, report: &[Value], interfaces: &mut [Value]) {
|
||||
let info = |cell: &str| report.iter().find(|r| r.get("body").and_then(Value::as_str) == Some(cell));
|
||||
let text = |r: Option<&Value>, key: &str| r.and_then(|r| r.get(key)).and_then(Value::as_str).map(String::from);
|
||||
for interface in interfaces.iter_mut() {
|
||||
if interface.get("joined").and_then(Value::as_str) != Some("tied") {
|
||||
continue;
|
||||
}
|
||||
let cells: Vec<String> = interface
|
||||
.get("cells")
|
||||
.and_then(Value::as_array)
|
||||
.map(|c| c.iter().filter_map(Value::as_str).map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
let [a, b] = cells.as_slice() else { continue };
|
||||
let Some(source) = design.source_of(a).filter(|s| design.source_of(b) == Some(*s)) else { continue };
|
||||
let mut why: Vec<String> = Vec::new();
|
||||
for cell in [a, b] {
|
||||
let r = info(cell);
|
||||
if text(r, "kind").is_some_and(|k| k.starts_with("tet")) {
|
||||
why.push(match text(r, "why") {
|
||||
Some(w) => format!("`{cell}` is meshed with tet: {w}"),
|
||||
None => format!("`{cell}` is meshed with tet"),
|
||||
});
|
||||
}
|
||||
}
|
||||
if why.is_empty() {
|
||||
let not_together = [a, b].into_iter().find_map(|c| text(info(c), "not_swept_together"));
|
||||
let alone = [a, b].into_iter().find(|c| text(info(c), "swept_together").is_none() && text(info(c), "not_swept_together").is_none());
|
||||
if let Some(w) = not_together {
|
||||
why.push(format!("the cells of `{source}` do not sweep together ({w}), so each was meshed alone with its own divisions"));
|
||||
} else if let Some(c) = alone {
|
||||
let method = text(info(c), "method").unwrap_or_default();
|
||||
why.push(format!("`{c}` is meshed alone (method `{method}`), not swept with the other cells of `{source}`"));
|
||||
} else {
|
||||
why.push(format!("the element faces of `{a}` and `{b}` do not match node for node"));
|
||||
}
|
||||
}
|
||||
if let Some(o) = interface.as_object_mut() {
|
||||
o.insert("why".into(), json!(why.join("; ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -349,14 +722,16 @@ mod tests {
|
||||
#[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, main the coarser side.
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
|
||||
// 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, "transition": "tie"})).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!(i["why"].as_str().is_some_and(|w| w.contains("`Body1/a` is meshed with tet")), "{r}");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
for set in ["tie_P_1_main", "tie_P_1_secondary"] {
|
||||
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.
|
||||
@@ -367,10 +742,37 @@ mod tests {
|
||||
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();
|
||||
s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet"}, "size": 2.5, "transition": "tie"})).unwrap();
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
}
|
||||
|
||||
/// A tie between cells of one design body says why they were not conforming (#396): the
|
||||
/// reason the body's cells did not sweep together, or the cell meshed alone by its method.
|
||||
#[test]
|
||||
fn ties_inside_one_body_say_why() {
|
||||
let (_dir, s) = cut_plate();
|
||||
let design = s.design.clone().unwrap();
|
||||
let tied = || vec![json!({"cells": ["Body1/a", "Body1/b"], "joined": "tied"}), json!({"cells": ["Body1/a", "Body1/b"], "joined": "merged"})];
|
||||
let mut interfaces = tied();
|
||||
let report = [
|
||||
json!({"body": "Body1/a", "kind": "hex8", "method": "extrude", "not_swept_together": "extrude: the levels differ"}),
|
||||
json!({"body": "Body1/b", "kind": "hex8", "method": "extrude", "not_swept_together": "extrude: the levels differ"}),
|
||||
];
|
||||
explain_ties(&design, &report, &mut interfaces);
|
||||
assert_eq!(
|
||||
interfaces[0]["why"],
|
||||
"the cells of `Body1` do not sweep together (extrude: the levels differ), so each was meshed alone with its own divisions"
|
||||
);
|
||||
assert!(interfaces[1].get("why").is_none());
|
||||
let mut interfaces = tied();
|
||||
let report = [
|
||||
json!({"body": "Body1/a", "kind": "hex8", "method": "mapped"}),
|
||||
json!({"body": "Body1/b", "kind": "hex8", "method": "extrude", "swept_together": "Body1"}),
|
||||
];
|
||||
explain_ties(&design, &report, &mut interfaces);
|
||||
assert_eq!(interfaces[0]["why"], "`Body1/a` is meshed alone (method `mapped`), not swept with the other cells of `Body1`");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -423,11 +825,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
/// joined by `transition` (`joined` in the reply). With ν = 0, symmetry conditions on three
|
||||
/// faces and the end load spread by consistent weights, the stress is uniform, which the
|
||||
/// elements, the pyramids and the dual mortar tie all carry exactly: the end moves F L / (E A).
|
||||
fn hex_tet_bar_in_tension(transition: &str, joined: &str) -> Session {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
@@ -447,8 +848,8 @@ mod tests {
|
||||
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 r = s.execute("mesh.cells", &json!({"methods": {"Bar/a": "mapped", "Bar/b": "tet"}, "size": 2.5, "transition": transition})).unwrap();
|
||||
assert_eq!(r["interfaces"][0]["joined"], joined, "{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() };
|
||||
@@ -490,5 +891,118 @@ mod tests {
|
||||
let u = n["displacement"][axis].as_f64().unwrap();
|
||||
assert!((u - expected).abs() < 1e-6 * expected, "{u} vs {expected}: {n}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tied_hex_tet_bar_in_tension_stretches_as_the_formula_says() {
|
||||
let s = hex_tet_bar_in_tension("tie", "tied");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
}
|
||||
|
||||
/// Through pyramids the bar is one conforming mesh: no tie, no free faces inside, every
|
||||
/// element valid.
|
||||
#[test]
|
||||
fn a_hex_tet_bar_joined_by_pyramids_is_conforming_and_stretches_as_the_formula_says() {
|
||||
let mut s = hex_tet_bar_in_tension("pyramid", "pyramids");
|
||||
assert!(s.model.ties().is_empty());
|
||||
let kinds: BTreeSet<&str> = s.model.elements().values().map(|e| e.kind.name()).collect();
|
||||
assert_eq!(kinds.into_iter().collect::<Vec<_>>(), ["hex8", "pyramid5", "tet4"]);
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!(d["counts"], json!({"coincident": 0, "cracks": 0, "duplicate_elements": 0, "free_edges": 0}), "{d}");
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["invalid"]["count"], 0, "{q}");
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
assert!(tied.iter().all(|i| i["why"].as_str().is_some_and(|w| w.starts_with("`Box/b/a/a` is meshed with tet"))), "{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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Pyramid transitions between a tet cell and the hex cells across its partitions (#439).
|
||||
//!
|
||||
//! The tet cell is meshed after the hex cells: its cut faces that face hex cells are replaced by
|
||||
//! a pyramid on every hex element face there ([`fieldcraft_mesh::transition`]), the tetrahedra
|
||||
//! fill the rest and keep the pyramids' sides, and the pyramids join the hex nodes to the tet
|
||||
//! nodes. The mesh is conforming across the cut, so no tie is needed there.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_mesh::tet::{QualityMesh, QualityOptions, quality_mesh};
|
||||
use fieldcraft_mesh::transition::{Transition, apex_points, pyramid_surface};
|
||||
use fieldcraft_model::{ElementKind, GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::hex::Input;
|
||||
use super::super::mesh::{BodyTets, add_tets, analytic_surfaces};
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// A tet cell meshed against the pyramids on its hex neighbours' faces.
|
||||
pub(super) struct Pyramids {
|
||||
pub(super) tets: BodyTets,
|
||||
transition: Transition,
|
||||
/// Each pyramid's base: the hex nodes of its face, oriented into the tet cell.
|
||||
bases: Vec<[u32; 4]>,
|
||||
/// The interfaces it makes (reply entries).
|
||||
pub(super) interfaces: Vec<Value>,
|
||||
}
|
||||
|
||||
/// The base name of a cut face (`partition:P:a` of `partition:P:a#1`): its partition and side.
|
||||
fn cut_of(name: &str) -> Option<(&str, usize)> {
|
||||
let base = name.split_once('#').map_or(name, |(b, _)| b);
|
||||
let (partition, side) = base.strip_prefix("partition:")?.rsplit_once(':')?;
|
||||
Some((partition, usize::from(side == "b")))
|
||||
}
|
||||
|
||||
/// Mesh tet cell `input` with pyramids on the faces of the hex cells `hex` (already in `model`)
|
||||
/// across its cut faces. `Ok(Err(why))` when there is no such face or the transition cannot be
|
||||
/// built (the caller meshes the cell plainly and ties it); limits and a cancel are errors.
|
||||
pub(super) fn mesh_with_pyramids(
|
||||
design: &fieldcraft_geometry::Design,
|
||||
model: &Model,
|
||||
p: &Value,
|
||||
input: &Input,
|
||||
hex: &[String],
|
||||
cdt: &CdtOptions,
|
||||
options: &QualityOptions,
|
||||
) -> Result<std::result::Result<Pyramids, String>> {
|
||||
// The hex element faces across each of the cell's cut faces.
|
||||
let sides = super::sides(model, hex);
|
||||
let mut cut: Vec<u32> = Vec::new();
|
||||
let mut quads: Vec<[u32; 4]> = Vec::new();
|
||||
let mut interfaces = Vec::new();
|
||||
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
|
||||
for (i, name) in input.face_names.iter().enumerate() {
|
||||
let Some((partition, side)) = cut_of(name) else { continue };
|
||||
let Some(across) = sides.get(partition).and_then(|s| s.get(1 - side)) else { continue };
|
||||
if across.is_empty() {
|
||||
continue;
|
||||
}
|
||||
cut.push(i as u32);
|
||||
for other in across {
|
||||
if !seen.insert((partition.to_string(), other.body.clone())) {
|
||||
continue;
|
||||
}
|
||||
for f in &other.faces {
|
||||
let nodes = model.face_nodes(*f).unwrap_or_default();
|
||||
let Ok(q) = <[u32; 4]>::try_from(nodes.as_slice()) else {
|
||||
return Ok(Err(format!("`{}` has quadratic or non-quadrilateral faces on the cut; pyramid transitions are linear", other.body)));
|
||||
};
|
||||
quads.push(q);
|
||||
}
|
||||
interfaces.push((partition.to_string(), other.body.clone(), other.faces.len()));
|
||||
}
|
||||
}
|
||||
if quads.is_empty() {
|
||||
return Ok(Err("no hex cell faces it across a partition".into()));
|
||||
}
|
||||
// The hex faces must cover the cut faces exactly (a cut shared with a tet cell, or a hex
|
||||
// cell larger than this one, is tied instead).
|
||||
let xyz = |n: u32| model.node(n).map(|x| x.xyz).unwrap_or([f64::NAN; 3]);
|
||||
let positions: Vec<[[f64; 3]; 4]> = quads.iter().map(|q| q.map(xyz)).collect();
|
||||
let quad_area: f64 = positions.iter().map(|q| 0.5 * norm(cross(sub(q[2], q[0]), sub(q[3], q[1])))).sum();
|
||||
let m = &input.mesh;
|
||||
let cut_area: f64 = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| cut.contains(f))
|
||||
.map(|(t, _)| {
|
||||
let c = t.map(|v| m.positions.get(v as usize).copied().unwrap_or([f64::NAN; 3]));
|
||||
0.5 * norm(cross(sub(c[1], c[0]), sub(c[2], c[0])))
|
||||
})
|
||||
.sum();
|
||||
if !((quad_area - cut_area).abs() <= 1e-3 * cut_area) {
|
||||
return Ok(Err(format!("the hex cells' faces ({quad_area:.6} mm²) do not cover its cut faces ({cut_area:.6} mm²)")));
|
||||
}
|
||||
let tag = u32::try_from(input.face_names.len()).map_err(|_| EngineError::Params("too many faces".into()))?;
|
||||
let welding = super::super::mesh::surface_options(p, m)?;
|
||||
let built = || -> fieldcraft_mesh::Result<(QualityMesh, Transition, usize, usize, f64)> {
|
||||
let surface = BoundarySurface::new(&m.positions, &m.triangles, &m.tri_face, &welding)?;
|
||||
let t = pyramid_surface(&surface, &cut, &positions, tag)?;
|
||||
// Already welded: nothing more merges.
|
||||
let exact = fieldcraft_mesh::surface::SurfaceOptions { weld_tolerance: 0.0, weld_distance: 0.0, ..welding };
|
||||
let region = BoundarySurface::new(&t.positions, &t.triangles, &t.tri_face, &exact)?;
|
||||
let tets = tetrahedralize(®ion, cdt)?;
|
||||
let mut analytic = design.body(&input.name).map(analytic_surfaces).unwrap_or_default();
|
||||
analytic.resize(tag as usize, None);
|
||||
analytic.push(None);
|
||||
let o = QualityOptions { seeds: input.seeds.clone(), frozen_faces: vec![tag], ..options.clone() };
|
||||
let mesh = quality_mesh(®ion, &tets, analytic, &o)?;
|
||||
Ok((mesh, t, tets.steiner.len(), tets.stats.cavities, region.volume()))
|
||||
};
|
||||
let (mesh, transition, steiner, cavities, volume) = match built() {
|
||||
Ok(x) => x,
|
||||
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => return Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
|
||||
Err(e @ fieldcraft_mesh::Error::Limit(_)) => return Err(EngineError::Params(e.to_string())),
|
||||
Err(e) => return Ok(Err(e.to_string())),
|
||||
};
|
||||
if let Err(e) = apex_points(&mesh, &transition) {
|
||||
return Ok(Err(e.to_string()));
|
||||
}
|
||||
let tets = BodyTets::new(input.name.clone(), input.face_names.clone(), mesh, steiner, cavities, volume);
|
||||
let interfaces = interfaces
|
||||
.into_iter()
|
||||
.map(|(partition, other, faces)| json!({"partition": partition, "cells": [other, input.name], "joined": "pyramids", "pyramids": faces}))
|
||||
.collect();
|
||||
Ok(Ok(Pyramids { tets, transition, bases: quads, interfaces }))
|
||||
}
|
||||
|
||||
/// Add the cell's tetrahedra and pyramids to `model`: the tetrahedra's nodes on the hex faces
|
||||
/// are the hex nodes, the pyramids join them to the apexes.
|
||||
pub(super) fn add(model: &mut Model, mut c: Pyramids) -> Result<(usize, usize)> {
|
||||
let tag = c.tets.face_names.len() as u32;
|
||||
let apexes = apex_points(&c.tets.mesh, &c.transition).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
// The pyramid sides are inside the part: no geometry face.
|
||||
c.tets.mesh.boundary.retain(|b| b.face != tag);
|
||||
let (first_node, _) = add_tets(model, &c.tets.name, &c.tets.face_names, &c.tets.mesh)?;
|
||||
// The tet mesh keeps the hex nodes' coordinates exactly: merge its copies into them.
|
||||
let hex: BTreeMap<[u64; 3], u32> = c.bases.iter().flatten().filter_map(|&n| model.node(n).map(|x| (x.xyz.map(f64::to_bits), n))).collect();
|
||||
let mut merge = BTreeMap::new();
|
||||
for (i, x) in c.tets.mesh.points.iter().enumerate() {
|
||||
if let Some(&h) = hex.get(&x.map(f64::to_bits)) {
|
||||
merge.insert(first_node + i as u32, h);
|
||||
}
|
||||
}
|
||||
let shared = merge.len();
|
||||
model.merge_nodes(&merge)?;
|
||||
let (_, first) = crate::cmd::fresh_labels(model, 0, c.bases.len())?;
|
||||
let body = model.geometry_tag(&GeometryRef::body(c.tets.name.as_str())?)?;
|
||||
for (k, (base, apex)) in c.bases.iter().zip(apexes).enumerate() {
|
||||
let label = first + k as u32;
|
||||
let mut nodes = base.to_vec();
|
||||
nodes.push(first_node + apex);
|
||||
model.set_element(label, ElementKind::Pyramid5, nodes)?;
|
||||
model.tag_element(label, body)?;
|
||||
}
|
||||
Ok((c.tets.mesh.elements.len() + c.bases.len(), c.tets.mesh.points.len() - shared))
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn cross(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]]
|
||||
}
|
||||
|
||||
fn norm(a: [f64; 3]) -> f64 {
|
||||
(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
|
||||
}
|
||||
@@ -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
|
||||
@@ -16,7 +18,7 @@ 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};
|
||||
@@ -38,6 +40,88 @@ fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
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 {
|
||||
id: "interaction.contact.pair",
|
||||
label: "Contact Pair",
|
||||
@@ -322,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) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use crate::schema::ProblemKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::{Cut, Design};
|
||||
use fieldcraft_geometry::{Cut, Design, Merge};
|
||||
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
|
||||
use fieldcraft_model::{Datum, Model, Partition};
|
||||
use serde::Deserialize;
|
||||
@@ -44,7 +44,7 @@ pub const DATUM_AXIS: CommandSpec = CommandSpec {
|
||||
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": "v3"} | {"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 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"#,
|
||||
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: &[],
|
||||
@@ -84,7 +84,7 @@ pub const DATUM_POINT_EDIT: CommandSpec = CommandSpec {
|
||||
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"#,
|
||||
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],
|
||||
@@ -94,17 +94,37 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec {
|
||||
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; a plane that divides no body, or a listed body it does not divide, is an error"#,
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides; a body earlier partitions cut stands for its cells)} — 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"#,
|
||||
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],
|
||||
@@ -214,18 +234,20 @@ fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
|
||||
/// The coordinates of datum point `name`.
|
||||
fn datum_point_at(m: &Model, name: &str) -> Result<[f64; 3]> {
|
||||
match m.datum(name) {
|
||||
Some(d) => d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind()))),
|
||||
None => Err(EngineError::Params(format!("no datum point `{name}`"))),
|
||||
}
|
||||
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])> {
|
||||
match m.datum(name) {
|
||||
Some(d) => d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind()))),
|
||||
None => Err(EngineError::Params(format!("no datum axis `{name}`"))),
|
||||
}
|
||||
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.
|
||||
@@ -296,14 +318,28 @@ fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
|
||||
let point = match by {
|
||||
PointBy::Coordinates { point } => point,
|
||||
PointBy::Vertex { body, vertex } => {
|
||||
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!("vertex `{vertex}` of body `{body}` is on no mesh node (vertices are named on meshed bodies)")),
|
||||
)?;
|
||||
node.xyz
|
||||
// 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) {
|
||||
@@ -316,10 +352,7 @@ fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
|
||||
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) = match s.model.datum(&plane) {
|
||||
Some(x) => x.plane().ok_or_else(|| EngineError::Params(format!("datum `{plane}` is a datum {}, not a plane", x.kind())))?,
|
||||
None => return Err(EngineError::Params(format!("no datum plane `{plane}`"))),
|
||||
};
|
||||
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.
|
||||
@@ -342,6 +375,18 @@ fn datum_of(s: &Session, kind: &str, p: &Value) -> Result<Datum> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -392,10 +437,9 @@ fn datum_edit(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
}
|
||||
let datum = datum_of(s, kind, p)?;
|
||||
s.model.replace_datum(name, datum)?;
|
||||
if !s.model.datum_users(name).is_empty() {
|
||||
recut(s)?;
|
||||
}
|
||||
Ok(datum_json(name, s.model.datum(name).unwrap_or(&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> {
|
||||
@@ -408,10 +452,9 @@ fn datum_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.model.remove_datum(name)?;
|
||||
if cascade && !partitions.is_empty() {
|
||||
recut(s)?;
|
||||
}
|
||||
Ok(json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }}))
|
||||
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.
|
||||
@@ -419,6 +462,7 @@ 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() })
|
||||
@@ -426,26 +470,49 @@ fn cuts(model: &Model) -> Vec<Cut> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `design` cut by `model`'s partitions (the design as opened when the model has none).
|
||||
/// `design` without the features `model` defeatures, cut by its partitions, then with its face
|
||||
/// merges (after those of the features defeatured virtually) and its face splits (those along a
|
||||
/// suppressed datum left out; the design as opened when the model has none).
|
||||
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
Ok(design.partitioned(&cuts(model))?)
|
||||
let defeatures: Vec<fieldcraft_geometry::defeature::Defeature> =
|
||||
model.defeatures().iter().map(|d| fieldcraft_geometry::defeature::Defeature { name: d.name.clone(), features: d.features.clone() }).collect();
|
||||
let (design, mut merges) = design.defeatured(&defeatures)?;
|
||||
merges.extend(model.face_merges().iter().map(|m| Merge {
|
||||
name: m.name.clone(),
|
||||
body: m.body.clone(),
|
||||
faces: m.faces.clone(),
|
||||
into: m.into.clone(),
|
||||
}));
|
||||
let splits: Vec<fieldcraft_geometry::face_split::FaceSplit> = model
|
||||
.face_splits()
|
||||
.iter()
|
||||
.filter(|s| !model.datum_suppressed(&s.datum))
|
||||
.filter_map(|s| {
|
||||
let (point, normal) = model.datum(&s.datum)?.plane()?;
|
||||
Some(fieldcraft_geometry::face_split::FaceSplit { name: s.name.clone(), body: s.body.clone(), face: s.face.clone(), point, normal })
|
||||
})
|
||||
.collect();
|
||||
Ok(design.partitioned(&cuts(model))?.merged(&merges)?.split(&splits)?)
|
||||
}
|
||||
|
||||
/// Cut the session's design by its model's partitions again.
|
||||
fn recut(s: &mut Session) -> Result<()> {
|
||||
if let Some(d) = &s.design {
|
||||
let cut = partitioned(d, &s.model)?;
|
||||
s.design = Some(Arc::new(cut));
|
||||
}
|
||||
Ok(())
|
||||
/// Cut the session's design by its model's partitions again (and merge its faces 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.
|
||||
pub(super) 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();
|
||||
s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies })?;
|
||||
recut(s)?;
|
||||
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();
|
||||
@@ -459,26 +526,71 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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();
|
||||
fieldcraft_model::progress::stage("Partition: checking the cells", Some(cells.len() as f64), "cells");
|
||||
for (k, cell) in cells.iter().enumerate() {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).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 });
|
||||
}
|
||||
Ok(json!({
|
||||
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, or at `tolerance`), bounds a volume mesh:
|
||||
/// the defects (open edges, edges on three triangles) when it does not.
|
||||
pub(super) fn unmeshable(shape: &fieldcraft_geometry::Shape, tolerance: Option<f64>) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, tolerance)?;
|
||||
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)?;
|
||||
recut(s)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(json!({"deleted": name, "bodies": bodies}))
|
||||
Ok(cleared.report_if_any(s, json!({"deleted": name, "bodies": bodies})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -549,6 +661,31 @@ pub(crate) mod tests {
|
||||
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();
|
||||
@@ -727,4 +864,146 @@ pub(crate) mod tests {
|
||||
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,
|
||||
/// or refused where the kernel could only cut it off the plane.
|
||||
#[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: the kernel only cuts with its box nudged
|
||||
// off the plane, so the cut is refused, saying why (#427).
|
||||
s.execute("geometry.datum", &json!({"name": "Side", "by": {"type": "offset", "plane": "yz", "offset": 34.0}})).unwrap();
|
||||
let e = s.execute("geometry.partition", &json!({"name": "PSide", "datum": "Side", "bodies": ["Air"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("could not cut `Air`") && e.contains("only made off the plane"), "{e}");
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Defeaturing for meshing: `geometry.defeature` takes holes, fillets, chamfers and small bosses
|
||||
//! out of the open design by suppressing their SolidCraft features (virtually, as a face merge,
|
||||
//! when a feature cannot be suppressed on its own), and `geometry.defeature.propose` lists the
|
||||
//! candidates of a body against size thresholds and the last geometry.check.
|
||||
//!
|
||||
//! A defeature is part of the model (`fieldcraft_model::Defeature`, by feature id); the geometry
|
||||
//! layer applies it to the design as opened (`fieldcraft_geometry::defeature`), before the
|
||||
//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the
|
||||
//! meshers, the viewport and the checks all see the part without them.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_geometry::defeature::{Candidate, candidate};
|
||||
use fieldcraft_model::Defeature;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const DEFEATURE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature",
|
||||
label: "Defeature",
|
||||
params: r#"{"name": "Small", "features": [3, "F7"] (feature ids, as geometry.features and geometry.defeature.propose list them; or the text "3, F7")} — takes features of the open design out for meshing: the design is rebuilt without them, as SolidCraft builds it with them suppressed (a hole filled, a fillet or chamfer back to its sharp edge, a boss gone), and the other faces keep their persistent names. A feature that cannot be suppressed alone (a later feature needs it, or a body would go) is defeatured virtually instead: its faces are merged into the neighbour they share the longest edges with, for meshing only (virtual topology; the B-rep keeps it). A feature that builds a body or a sketch, or one the design lacks, is an error. Kept in the model by feature id and applied again whenever the design is opened (before the partitions); the same name again replaces its features. A suppressed feature affects every body it shaped. The bodies lose their meshes (`replaced`; remesh them); sets naming their faces are `stale_sets`. Replies `suppressed` (feature ids), `virtual` ([{feature, reason}]) and `bodies`. One undo step; geometry.defeature.delete turns them back on"#,
|
||||
form: Some(super::forms::DEFEATURE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: defeature,
|
||||
};
|
||||
|
||||
pub const DEFEATURE_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature.delete",
|
||||
label: "Delete Defeature",
|
||||
params: r#"{"name": "Small"} — turns a defeature's features back on: the design is rebuilt with them; the bodies they shaped lose their meshes (`replaced`)"#,
|
||||
form: Some(super::forms::DEFEATURE_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Defeature],
|
||||
run: delete,
|
||||
};
|
||||
|
||||
pub const DEFEATURE_PROPOSE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature.propose",
|
||||
label: "Propose Defeaturing",
|
||||
params: r#"{"body": "Base", "below"?: {"hole_diameter"?: 6, "fillet_radius"?: 2, "chamfer"?: 1, "boss_height"?: 2} (mm), "from_check"?: false, "name"?: "Defeature"} — lists the features of a body (as opened) that defeaturing may take out (read-only): holes (a hole, or a cut of a circle or cylinder) by diameter, fillets by radius, chamfers by distance, bosses (a join of an extrude, cylinder or box) by height. A candidate is `proposed` when it is smaller than its `below` threshold, or with `from_check` when one of its faces is a finding of the last geometry.check (still marked). Replies `candidates` ([{id, feature, name, type, category, size, faces, proposed, suppressed, by, method}]: `suppressed` when a defeature takes it out, `by` that defeature, `method` suppressed or virtual), `proposal` (the proposed ids) and `name`, the defeature the panel's on/off switches edit (geometry.defeature with that name)"#,
|
||||
form: Some(super::forms::DEFEATURE_PROPOSE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: propose,
|
||||
};
|
||||
|
||||
/// A feature id: `3` or `"F3"`.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum FeatureId {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// The feature ids of `features`: a list, or the form's text (`3, F7`).
|
||||
fn ids(p: &Value) -> Result<Vec<u64>> {
|
||||
let given: Vec<FeatureId> = match p.get("features") {
|
||||
Some(Value::String(t)) if t.len() <= 64 * 1024 => {
|
||||
t.split([',', ' ', ';']).filter(|w| !w.is_empty()).map(|w| FeatureId::Name(w.to_string())).collect()
|
||||
}
|
||||
_ => super::req(p, "features")?,
|
||||
};
|
||||
if given.is_empty() || given.len() > fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES {
|
||||
return Err(EngineError::invalid("features", format!("1 to {} feature ids", fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES)));
|
||||
}
|
||||
let mut out = Vec::with_capacity(given.len());
|
||||
for f in given {
|
||||
let id = match f {
|
||||
FeatureId::Id(id) => Some(id),
|
||||
FeatureId::Name(n) => n.strip_prefix('F').unwrap_or(&n).parse().ok(),
|
||||
};
|
||||
match id {
|
||||
Some(id) if !out.contains(&id) => out.push(id),
|
||||
Some(_) => {}
|
||||
None => return Err(EngineError::invalid("features", "feature ids: 3 or \"F3\"")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The bodies of the open design (as opened) that features `ids` shaped.
|
||||
fn shaped(s: &Session, ids: &BTreeSet<u64>) -> Vec<String> {
|
||||
let Some(design) = s.design.as_deref() else { return Vec::new() };
|
||||
let graph = design.undefeatured().features(None).unwrap_or_default();
|
||||
let sources: Vec<String> = graph.into_iter().filter(|b| b.features.iter().any(|f| ids.contains(&f.id))).map(|b| b.body).collect();
|
||||
// A body the partitions cut: each of its cells.
|
||||
design.bodies.iter().map(|b| b.name.clone()).filter(|n| design.source_of(n).is_some_and(|src| sources.iter().any(|x| x == src))).collect()
|
||||
}
|
||||
|
||||
/// What the design's defeatures did with the features of `name`, as the reply lists them.
|
||||
fn outcome(s: &Session, name: &str) -> (Vec<u64>, Vec<Value>) {
|
||||
let report = s.design.as_deref().map(|d| d.defeat_report().to_vec()).unwrap_or_default();
|
||||
let mine = report.into_iter().filter(|r| r.defeature == name);
|
||||
let (mut suppressed, mut virt) = (Vec::new(), Vec::new());
|
||||
for r in mine {
|
||||
if r.method == "suppressed" {
|
||||
suppressed.push(r.feature);
|
||||
} else {
|
||||
virt.push(json!({"feature": r.feature, "reason": r.reason}));
|
||||
}
|
||||
}
|
||||
(suppressed, virt)
|
||||
}
|
||||
|
||||
fn defeature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let features = ids(p)?;
|
||||
let before: BTreeSet<u64> = s.model.defeatures().iter().filter(|d| d.name == name).flat_map(|d| d.features.clone()).collect();
|
||||
s.model.set_defeature(Defeature { name: name.clone(), features: features.clone() })?;
|
||||
let mut touched = before;
|
||||
touched.extend(&features);
|
||||
let bodies = shaped(s, &touched);
|
||||
let merges: Vec<String> = features.iter().map(|id| format!("merge {name}-F{id}")).collect();
|
||||
let mut errors = vec![format!("defeature {name}")];
|
||||
errors.extend(merges);
|
||||
let reply = super::virtual_faces::reapply(s, &errors, &bodies, json!({"name": name}))?;
|
||||
let (suppressed, virt) = outcome(s, &name);
|
||||
let bodies = shaped(s, &touched);
|
||||
let mut reply = reply;
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("suppressed".into(), json!(suppressed));
|
||||
o.insert("virtual".into(), json!(virt));
|
||||
o.insert("bodies".into(), json!(bodies));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let removed = s.model.remove_defeature(name)?;
|
||||
let bodies = shaped(s, &removed.features.iter().copied().collect());
|
||||
let mut cleared = super::datum::recut(s)?;
|
||||
cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str)));
|
||||
Ok(cleared.report_if_any(s, json!({"deleted": name, "features": removed.features, "bodies": bodies})))
|
||||
}
|
||||
|
||||
/// Size thresholds of a proposal (mm).
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Below {
|
||||
hole_diameter: Option<f64>,
|
||||
fillet_radius: Option<f64>,
|
||||
chamfer: Option<f64>,
|
||||
boss_height: Option<f64>,
|
||||
}
|
||||
|
||||
impl Below {
|
||||
fn of(&self, category: &str) -> Option<f64> {
|
||||
match category {
|
||||
"hole" => self.hole_diameter,
|
||||
"fillet" => self.fillet_radius,
|
||||
"chamfer" => self.chamfer,
|
||||
"boss" => self.boss_height,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn propose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let body: String = super::req(p, "body")?;
|
||||
let below: Below = super::opt(p, "below")?.unwrap_or_default();
|
||||
for (k, v) in [
|
||||
("hole_diameter", below.hole_diameter),
|
||||
("fillet_radius", below.fillet_radius),
|
||||
("chamfer", below.chamfer),
|
||||
("boss_height", below.boss_height),
|
||||
] {
|
||||
if v.is_some_and(|v| !(0.0..=fieldcraft_model::MAX_COORD).contains(&v)) {
|
||||
return Err(EngineError::invalid("below", format!("`{k}` is a length of 0 or more")));
|
||||
}
|
||||
}
|
||||
let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false);
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => "Defeature".to_string(),
|
||||
Some(_) => super::name(p)?.to_string(),
|
||||
};
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let source = design
|
||||
.source_of(&body)
|
||||
.ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())))?;
|
||||
let graph = design.undefeatured().features(Some(source))?;
|
||||
// The faces the last check marked on the body (merged faces by their B-rep faces).
|
||||
let mut flagged: BTreeSet<String> = BTreeSet::new();
|
||||
if from_check {
|
||||
let marks = crate::view::geometry_marks(s)
|
||||
.ok_or_else(|| EngineError::Params("`from_check`: run geometry.check (with marks) on the open design first".into()))?;
|
||||
for (b, i) in marks.marks.iter().flat_map(|m| m.faces.iter()) {
|
||||
if design.source_of(b) != Some(source) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = design.body(b).and_then(|shape| shape.faces().get(*i as usize)) {
|
||||
flagged.insert(f.name.split('#').next().unwrap_or(&f.name).to_string());
|
||||
flagged.extend(f.members.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Which defeature takes each feature out, and how.
|
||||
let by: BTreeMap<u64, (String, &'static str)> = design.defeat_report().iter().map(|r| (r.feature, (r.defeature.clone(), r.method))).collect();
|
||||
let mut candidates = Vec::new();
|
||||
let mut proposal = Vec::new();
|
||||
for c in graph.iter().flat_map(|b| b.features.iter()).filter_map(candidate) {
|
||||
let Candidate { id, name: fname, kind, category, size, faces } = c;
|
||||
let small = below.of(category).is_some_and(|t| size < t);
|
||||
let hit = faces.iter().any(|f| flagged.contains(f));
|
||||
let proposed = small || hit;
|
||||
if proposed {
|
||||
proposal.push(id);
|
||||
}
|
||||
let taken = by.get(&id);
|
||||
candidates.push(json!({
|
||||
"id": id,
|
||||
"feature": format!("F{id}"),
|
||||
"name": fname,
|
||||
"type": kind,
|
||||
"category": category,
|
||||
"size": size,
|
||||
"faces": faces,
|
||||
"proposed": proposed,
|
||||
"suppressed": taken.is_some(),
|
||||
"by": taken.map(|t| t.0.clone()),
|
||||
"method": taken.map(|t| t.1),
|
||||
}));
|
||||
}
|
||||
let off = candidates.iter().filter(|c| c["suppressed"] == true).count();
|
||||
let summary = format!(
|
||||
"{} on `{source}`: {} proposed, {} defeatured",
|
||||
match candidates.len() {
|
||||
1 => "1 candidate".to_string(),
|
||||
n => format!("{n} candidates"),
|
||||
},
|
||||
proposal.len(),
|
||||
off
|
||||
);
|
||||
Ok(json!({"body": source, "name": name, "candidates": candidates, "proposal": proposal, "summary": summary}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn faces(s: &Session) -> usize {
|
||||
s.design.as_ref().unwrap().bodies[0].faces().len()
|
||||
}
|
||||
|
||||
fn hole(s: &mut Session) -> u64 {
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
let r = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 12}})).unwrap();
|
||||
assert_eq!(r["proposal"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
r["proposal"][0].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// The sample plate: its hole is proposed below 12 mm, suppressing it gives a block meshed
|
||||
/// without it (tet and hex), undo and redo take it out and put it back, it is saved and
|
||||
/// replayed on open, and deleting the defeature brings the hole back.
|
||||
#[test]
|
||||
fn the_plate_hole_is_suppressed_meshed_undone_saved_and_restored() {
|
||||
let (mut s, dir) = super::super::datum::tests::plate();
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
assert_eq!(faces(&s), 8);
|
||||
let none = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 5}})).unwrap();
|
||||
assert_eq!((none["candidates"].as_array().map(Vec::len), none["proposal"].as_array().map(Vec::len)), (Some(1), Some(0)), "{none}");
|
||||
let id = hole(&mut s);
|
||||
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
|
||||
let with_hole = s.model.elements().len();
|
||||
let r = s.execute("geometry.defeature", &json!({"name": "Holes", "features": [id]})).unwrap();
|
||||
assert_eq!(r["suppressed"], json!([id]), "{r}");
|
||||
assert_eq!(r["replaced"][0]["body"].as_str(), Some(body.as_str()), "{r}");
|
||||
assert_eq!(faces(&s), 6);
|
||||
// The proposal shows it off, by the defeature.
|
||||
let p = s.execute("geometry.defeature.propose", &json!({"body": body})).unwrap();
|
||||
assert_eq!((p["candidates"][0]["suppressed"].as_bool(), p["candidates"][0]["by"].as_str()), (Some(true), Some("Holes")), "{p}");
|
||||
// Meshed without it: a tet mesh fills the whole block, and the block sweeps into hexes.
|
||||
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
|
||||
let volume = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume;
|
||||
assert!((volume(&s) - 40.0 * 30.0 * 10.0).abs() < 1e-6, "{}", volume(&s));
|
||||
assert_ne!(s.model.elements().len(), with_hole);
|
||||
s.execute("mesh.hex.sweep", &json!({"body": body, "size": 5})).unwrap();
|
||||
assert!(s.model.elements().values().all(|e| e.nodes.len() == 8), "a block sweeps into hexes only");
|
||||
// Undo the meshes and the defeature: the hole is back; redo takes it out again.
|
||||
while !s.model.defeatures().is_empty() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(faces(&s), 8);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(faces(&s), 6);
|
||||
// Saved, and replayed on open of the model and on a re-open of the design.
|
||||
let file = dir.join("defeatured.fieldcraft");
|
||||
s.execute("file.save", &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!((t.model.defeatures().len(), faces(&t)), (1, 6));
|
||||
let design = s.sources.design.as_ref().unwrap().path().to_string();
|
||||
s.execute("geometry.open", &json!({"path": design})).unwrap();
|
||||
assert_eq!(faces(&s), 6);
|
||||
// Switched back on.
|
||||
let r = s.execute("geometry.defeature.delete", &json!({"name": "Holes"})).unwrap();
|
||||
assert_eq!(r["features"], json!([id]), "{r}");
|
||||
assert_eq!(faces(&s), 8);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// Unknown features, body-building ones and bad ids are refused and leave the model as it was.
|
||||
#[test]
|
||||
fn bad_features_are_refused() {
|
||||
let (mut s, dir) = super::super::datum::tests::plate();
|
||||
for features in [json!([999]), json!(["Fx"]), json!([]), json!("F1, x"), json!(7)] {
|
||||
assert!(s.execute("geometry.defeature", &json!({"name": "Bad", "features": features})).is_err(), "{features}");
|
||||
}
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
let base = s.execute("geometry.features", &json!({"body": body})).unwrap()["bodies"][0]["features"][0]["id"].as_u64().unwrap();
|
||||
let e = s.execute("geometry.defeature", &json!({"name": "Bad", "features": [base]})).unwrap_err();
|
||||
assert!(e.to_string().contains("builds a body"), "{e}");
|
||||
assert!(s.model.defeatures().is_empty() && faces(&s) == 8);
|
||||
for below in [json!({"hole_diameter": -1}), json!({"hole_diameter": "x"}), json!({"nope": 1})] {
|
||||
assert!(s.execute("geometry.defeature.propose", &json!({"body": body, "below": below})).is_err(), "{below}");
|
||||
}
|
||||
assert!(s.execute("geometry.defeature.propose", &json!({"body": "Nope"})).is_err());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// The bracket's 6 mm fillet is proposed below 8 mm, defeatured back to a sharp corner and
|
||||
/// meshed; its holes stay.
|
||||
#[test]
|
||||
fn a_bracket_is_meshed_with_its_small_fillet_defeatured() {
|
||||
let (_dir, mut s) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
|
||||
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [8, 32, 20], "diameter": 8}}
|
||||
]));
|
||||
let p = s.execute("geometry.defeature.propose", &json!({"body": "Bracket", "below": {"fillet_radius": 8}})).unwrap();
|
||||
let cats: Vec<&str> = p["candidates"].as_array().unwrap().iter().filter_map(|c| c["category"].as_str()).collect();
|
||||
assert_eq!(cats, ["fillet", "hole", "hole"], "{p}");
|
||||
let fillet = p["proposal"].as_array().unwrap().clone();
|
||||
assert_eq!(fillet.len(), 1);
|
||||
let before = faces(&s);
|
||||
let r = s.execute("geometry.defeature", &json!({"name": "Fillets", "features": fillet})).unwrap();
|
||||
assert_eq!(r["suppressed"], json!(fillet), "{r}");
|
||||
assert_eq!(faces(&s), before - 1);
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Bracket", "size": 4})).unwrap();
|
||||
assert!(r["elements"].as_u64().unwrap_or(0) > 0, "{r}");
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@
|
||||
|
||||
use fieldcraft_model::{
|
||||
BoundaryCondition, Coupling, FaceCondition, FluidBoundary, FluidDomain, Load, Material, Model, PointMass, ReferencePoint, Section, Set, Spring,
|
||||
Step,
|
||||
Step, ThermalCondition,
|
||||
};
|
||||
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};
|
||||
@@ -92,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,
|
||||
@@ -249,7 +252,7 @@ definition_commands!(
|
||||
BoundaryCondition,
|
||||
"boundary_condition",
|
||||
"Boundary Condition",
|
||||
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" (the plane's normal)} — active in the steps that list it (model.step.activate)"#,
|
||||
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" | {"normal": [nx, ny, nz]} (the plane's normal; an inclined plane holds n · u = 0 and the rotations about its in-plane axes)} — active in the steps that list it (model.step.activate)"#,
|
||||
super::forms::BOUNDARY_CONDITION,
|
||||
super::forms::BOUNDARY_CONDITION_DELETE,
|
||||
add_boundary_condition,
|
||||
@@ -267,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,
|
||||
@@ -285,7 +288,7 @@ definition_commands!(
|
||||
FluidDomain,
|
||||
"fluid_domain",
|
||||
"Fluid Domain",
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing), "thermal": {"initial_temperature": 20, "gravity": [0, -9810, 0], "expansion": 2.1e-4 (β per temperature; buoyancy needs both, Boussinesq), "faces": {"y_min": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}, …} (default adiabatic; a velocity inlet carries its held temperature in, a pressure outlet lets heat leave), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "temperature", "value": 80}}] (obstacle faces are adiabatic unless listed)} (heat in the fluid, carried by the flow and diffused with the material's conductivity and specific heat; left out, the flow is isothermal)}"#,
|
||||
super::forms::FLUID_DOMAIN,
|
||||
super::forms::FLUID_DOMAIN_DELETE,
|
||||
add_fluid_domain,
|
||||
@@ -316,10 +319,28 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"domain": domain}))
|
||||
}
|
||||
|
||||
pub const FLUID_THERMAL_BOUNDARY: CommandSpec = CommandSpec {
|
||||
id: "model.fluid_domain.thermal_boundary",
|
||||
label: "Temperature or Heat Flux",
|
||||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}} — sets one boundary's thermal condition; the domain needs `thermal` settings (model.fluid_domain.edit)"#,
|
||||
form: Some(super::forms::FLUID_THERMAL_BOUNDARY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain],
|
||||
run: fluid_thermal_boundary,
|
||||
};
|
||||
|
||||
fn fluid_thermal_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let domain: &str = req(p, "domain")?;
|
||||
let at: FluidBoundary = req(p, "at")?;
|
||||
let condition: ThermalCondition = req(p, "condition")?;
|
||||
s.model.set_fluid_thermal_boundary(domain, at, condition)?;
|
||||
Ok(json!({"domain": domain}))
|
||||
}
|
||||
|
||||
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: &[],
|
||||
@@ -356,6 +377,60 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
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",
|
||||
@@ -473,6 +548,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();
|
||||
|
||||
@@ -30,6 +30,16 @@ pub const CRASH_REPORT: CommandSpec = CommandSpec {
|
||||
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",
|
||||
@@ -51,8 +61,12 @@ pub const CRASH_TEST: CommandSpec = CommandSpec {
|
||||
};
|
||||
|
||||
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::Read)?));
|
||||
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()))
|
||||
}
|
||||
@@ -71,6 +85,21 @@ fn crash_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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> {
|
||||
@@ -131,3 +160,39 @@ fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! `mesh.hex.extrude`: hexahedra from a quad section up to a target surface
|
||||
//! ([`fieldcraft_mesh::hex::extrude_to_surface`]).
|
||||
//!
|
||||
//! The section is, in this order: the quadrilaterals of a set (`source_set`: shell elements or
|
||||
//! element faces); the mesh already on the source faces (element faces tagged with them, or the
|
||||
//! boundary faces of a neighbouring body's mesh lying on them), whose nodes the extrusion shares
|
||||
//! so it conforms with that mesh; or a fresh quad mesh of a planar source face. Target and side
|
||||
//! faces are projected onto through the trimmed B-rep projection that projection blocking uses
|
||||
//! ([`fieldcraft_mesh::tet::geometry::Tessellation::project_trimmed`]): exact on analytic faces,
|
||||
//! the tessellation's accuracy on free-form ones.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_geometry::{FaceSurface, Shape};
|
||||
use fieldcraft_mesh::hex::{ExtrudeLayers, ExtrudePath, ExtrudeRole, ExtrudeSpec, MAX_BIAS, MAX_LAYERS, extrude_to_surface};
|
||||
use fieldcraft_mesh::planar::{face_boundary, mesh_region};
|
||||
use fieldcraft_mesh::project::Geometry;
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
use fieldcraft_mesh::tet::geometry::Tessellation;
|
||||
use fieldcraft_model::{ElementKind, Entity, GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_EXTRUDE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.extrude",
|
||||
label: "Extrude to Surface",
|
||||
params: r#"{"source": [{"body": "Post", "face": "F1"}] (the section: faces already meshed, by this body or a neighbour, or one planar face meshed here) or "source_set": "Section" (a set of quad shell elements or element faces, such as a 2D mesh), "to_surface": [{"body": "Wall", "face": "F7"}] (the target faces the last layer lies on), "sides": [{"body": "Post", "face": "F3"}, …] (optional: the faces the section's sides follow), "direction": [0, 0, 1] or "normal": true (each node along the section's normal toward the target; the default), "layers": 8 or "size": 2.0 (layer thickness, mm; also the size of a section meshed here; default 1/25 of the paths' extent), "bias": 1.0 (last layer's thickness over the first's, 0.001 to 1000), "body": "Post" (the body the elements fill; default: the first source face's body), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of each body's diagonal)} — replaces `body`'s mesh with hex8 layers stacked on the section, each node along its own straight path, the last layer on the target surface; with `sides`, every layer's boundary nodes lie on the side faces (which need not be straight) and the interior follows them. A section taken from an existing mesh keeps its nodes, so the extrusion conforms with the neighbour across it. Boundary element faces are tagged with `body`'s source, target and side faces. Reports the layers, counts, quality, the target gap and where the section came from; a path that misses the target, or elements that would invert, is an error that says where"#,
|
||||
form: Some(super::forms::HEX_EXTRUDE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: hex_extrude,
|
||||
};
|
||||
|
||||
/// Most faces in `source`, `to_surface` or `sides`.
|
||||
const MAX_FACES: usize = 1000;
|
||||
/// Default layer size and chordal tolerance, relative to the extent.
|
||||
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
|
||||
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
|
||||
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
|
||||
/// Most quadrilaterals of a section meshed here.
|
||||
const MAX_SECTION_QUADS: usize = 1_000_000;
|
||||
|
||||
/// The bodies the extrusion's faces lie on, and every face it names, with one projection.
|
||||
struct Faces {
|
||||
bodies: Vec<Tessellation>,
|
||||
/// Per body: its name, and its faces' names.
|
||||
names: Vec<(String, Vec<String>)>,
|
||||
/// Face id → (body, B-rep face).
|
||||
faces: Vec<(usize, u32)>,
|
||||
/// The largest chordal tolerance used.
|
||||
tolerance: f64,
|
||||
}
|
||||
|
||||
impl Geometry for Faces {
|
||||
fn project(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.faces.get(face as usize)?;
|
||||
self.bodies.get(b)?.project_trimmed(f, p)
|
||||
}
|
||||
fn normal(&self, face: u32, p: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.faces.get(face as usize)?;
|
||||
self.bodies.get(b)?.face_normal(f, p)
|
||||
}
|
||||
}
|
||||
|
||||
impl Faces {
|
||||
/// The ids of the faces `refs` names (a name selects all its pieces), loading their bodies.
|
||||
fn add(&mut self, s: &Session, refs: &[GeometryRef], tolerance: Option<f64>, key: &str) -> Result<Vec<u32>> {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let mut out = Vec::new();
|
||||
for r in refs {
|
||||
if !matches!(r.entity, Entity::Face(_)) {
|
||||
return Err(EngineError::invalid(key, format!("{r} is not a face")));
|
||||
}
|
||||
let shape = design.body(&r.body).ok_or_else(|| EngineError::invalid(key, format!("no body `{}`", r.body)))?;
|
||||
let b = match self.names.iter().position(|(n, _)| *n == shape.name) {
|
||||
Some(b) => b,
|
||||
None => self.load(shape, tolerance)?,
|
||||
};
|
||||
let names = self.names.get(b).map(|(_, f)| f.clone()).unwrap_or_default();
|
||||
let before = out.len();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
if r.selects(&GeometryRef::face(shape.name.as_str(), name.as_str())?) {
|
||||
let id = self.id(b, i as u32);
|
||||
if !out.contains(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len() == before {
|
||||
return Err(EngineError::invalid(key, format!("body `{}` has no face `{}`", r.body, r.entity.name().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn load(&mut self, shape: &Shape, tolerance: Option<f64>) -> Result<usize> {
|
||||
let diag = shape.size()?;
|
||||
let tol = tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
|
||||
if tol < MIN_RELATIVE_TOLERANCE * diag {
|
||||
return Err(EngineError::Params(format!("`tolerance` must be at least {} mm for body `{}`", MIN_RELATIVE_TOLERANCE * diag, shape.name)));
|
||||
}
|
||||
let mesh = shape.tessellate(tol)?;
|
||||
let surface = BoundarySurface::new(
|
||||
&mesh.positions,
|
||||
&mesh.triangles,
|
||||
&mesh.tri_face,
|
||||
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
|
||||
)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", shape.name)))?;
|
||||
self.bodies.push(Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)));
|
||||
self.names.push((shape.name.clone(), shape.faces().iter().map(|f| f.name.clone()).collect()));
|
||||
self.tolerance = self.tolerance.max(tol);
|
||||
Ok(self.bodies.len() - 1)
|
||||
}
|
||||
|
||||
fn id(&mut self, b: usize, f: u32) -> u32 {
|
||||
match self.faces.iter().position(|x| *x == (b, f)) {
|
||||
Some(i) => i as u32,
|
||||
None => {
|
||||
self.faces.push((b, f));
|
||||
(self.faces.len() - 1) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body and face name of face `id`.
|
||||
fn name(&self, id: u32) -> Option<(&str, &str)> {
|
||||
let &(b, f) = self.faces.get(id as usize)?;
|
||||
let (body, faces) = self.names.get(b)?;
|
||||
Some((body.as_str(), faces.get(f as usize)?.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The section: node coordinates, the model label of each node it shares (none for a fresh
|
||||
/// section), and its quadrilaterals.
|
||||
struct Section {
|
||||
nodes: Vec<[f64; 3]>,
|
||||
labels: Vec<Option<u32>>,
|
||||
quads: Vec<[u32; 4]>,
|
||||
from: &'static str,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
/// A section of model nodes: each label once, in order of first use.
|
||||
fn of_labels(model: &Model, quads: &[[u32; 4]], from: &'static str) -> Result<Section> {
|
||||
let mut index: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
let mut s = Section { nodes: Vec::new(), labels: Vec::new(), quads: Vec::with_capacity(quads.len()), from };
|
||||
for q in quads {
|
||||
let mut local = [0u32; 4];
|
||||
for (slot, &l) in local.iter_mut().zip(q) {
|
||||
*slot = match index.get(&l) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let xyz = model.nodes().get(&l).map(|n| n.xyz).ok_or_else(|| EngineError::Params(format!("node {l} is not in the model")))?;
|
||||
let i = s.nodes.len() as u32;
|
||||
s.nodes.push(xyz);
|
||||
s.labels.push(Some(l));
|
||||
index.insert(l, i);
|
||||
i
|
||||
}
|
||||
};
|
||||
}
|
||||
s.quads.push(local);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The four corners of element `e`'s face `face` (1-based), when it is a quadrilateral.
|
||||
fn quad_face(model: &Model, e: u32, face: u8) -> Option<[u32; 4]> {
|
||||
let el = model.elements().get(&e)?;
|
||||
let local = el.kind.faces().get(usize::from(face).checked_sub(1)?)?;
|
||||
if local.len() < 4 || (local.len() != 4 && local.len() != 8) {
|
||||
return None;
|
||||
}
|
||||
let corner = |k: usize| local.get(k).and_then(|&i| el.nodes.get(i)).copied();
|
||||
Some([corner(0)?, corner(1)?, corner(2)?, corner(3)?])
|
||||
}
|
||||
|
||||
/// The corners of a quad shell element.
|
||||
fn quad_shell(model: &Model, e: u32) -> Option<[u32; 4]> {
|
||||
let el = model.elements().get(&e)?;
|
||||
if !matches!(el.kind, ElementKind::Quad4 | ElementKind::Quad8) {
|
||||
return None;
|
||||
}
|
||||
Some([*el.nodes.first()?, *el.nodes.get(1)?, *el.nodes.get(2)?, *el.nodes.get(3)?])
|
||||
}
|
||||
|
||||
/// The elements of `body`'s mesh.
|
||||
fn body_elements(model: &Model, body: &str) -> std::collections::BTreeSet<u32> {
|
||||
model.body_elements(body).into_iter().collect()
|
||||
}
|
||||
|
||||
/// The quadrilaterals tagged with the faces `refs` names (shell elements filling them, element
|
||||
/// faces on them), leaving out `exclude`'s elements.
|
||||
fn tagged_quads(model: &Model, refs: &[GeometryRef], exclude: &std::collections::BTreeSet<u32>) -> Result<Vec<[u32; 4]>> {
|
||||
let c = model.classification();
|
||||
let mut tags: Vec<u32> = refs.iter().flat_map(|r| c.tags_selected_by(r)).collect();
|
||||
tags.sort_unstable();
|
||||
tags.dedup();
|
||||
let mut out = Vec::new();
|
||||
for (&e, t) in c.elements() {
|
||||
if exclude.contains(&e) {
|
||||
continue;
|
||||
}
|
||||
if t.region.is_some_and(|r| tags.binary_search(&r).is_ok()) {
|
||||
out.push(quad_shell(model, e).ok_or_else(|| EngineError::Params(format!("element {e} on the source face is not a quadrilateral")))?);
|
||||
}
|
||||
for (&f, tag) in &t.faces {
|
||||
if tags.binary_search(tag).is_ok() {
|
||||
out.push(
|
||||
quad_face(model, e, f)
|
||||
.ok_or_else(|| EngineError::Params(format!("element {e}'s face {f} on the source face is not a quadrilateral")))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The boundary faces of other bodies' meshes (tagged faces) that lie on the source faces.
|
||||
fn neighbour_quads(model: &Model, geometry: &Faces, source: &[u32], exclude: &std::collections::BTreeSet<u32>) -> Vec<[u32; 4]> {
|
||||
let tol = 10.0 * geometry.tolerance.max(1e-9);
|
||||
let on = |p: [f64; 3]| {
|
||||
source.iter().any(|&f| geometry.project(f, p).is_some_and(|q| (0..3).map(|k| (q[k] - p[k]).powi(2)).sum::<f64>().sqrt() <= tol))
|
||||
};
|
||||
let xyz = |l: u32| model.nodes().get(&l).map(|n| n.xyz);
|
||||
let mut out = Vec::new();
|
||||
for (&e, t) in model.classification().elements() {
|
||||
if exclude.contains(&e) {
|
||||
continue;
|
||||
}
|
||||
for &f in t.faces.keys() {
|
||||
let Some(q) = quad_face(model, e, f) else { continue };
|
||||
let Some(pts) = q.iter().map(|&l| xyz(l)).collect::<Option<Vec<_>>>() else { continue };
|
||||
let centre = pts.iter().fold([0.0; 3], |a, p| [a[0] + p[0] / 4.0, a[1] + p[1] / 4.0, a[2] + p[2] / 4.0]);
|
||||
if pts.iter().all(|p| on(*p)) && on(centre) {
|
||||
out.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A quad mesh of one planar face of `shape` of about `size`.
|
||||
fn mesh_planar_face(shape: &Shape, face: u32, size: f64, tolerance: f64) -> Result<Section> {
|
||||
let surfaces = shape.face_surfaces()?;
|
||||
let Some(Some(FaceSurface::Plane { normal, point })) = surfaces.get(face as usize).copied() else {
|
||||
return Err(EngineError::Params(format!(
|
||||
"the source face of body `{}` is not meshed and not planar: mesh it first (a 2D mesh, or a neighbouring body's mesh)",
|
||||
shape.name
|
||||
)));
|
||||
};
|
||||
let n = unit(normal).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?;
|
||||
let t = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
||||
let u = unit(cross(n, t)).ok_or_else(|| EngineError::Params("the source face has no normal".into()))?;
|
||||
let v = cross(n, u);
|
||||
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
let mesh = shape.tessellate(tolerance)?;
|
||||
// The tessellation in the face's frame, the face at z = 0.
|
||||
let local: Vec<[f64; 3]> = mesh
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let d = [p[0] - point[0], p[1] - point[1], p[2] - point[2]];
|
||||
[dot(d, u), dot(d, v), dot(d, n)]
|
||||
})
|
||||
.collect();
|
||||
let boundary = face_boundary(&local, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance.max(tolerance))?;
|
||||
let opts = fieldcraft_mesh::quad::QuadOptions { max_elements: MAX_SECTION_QUADS, ..Default::default() };
|
||||
let m = mesh_region(&boundary.loops, ElementKind::Quad4, size, &opts)?;
|
||||
let at = |xy: [f64; 2]| [0, 1, 2].map(|k| point[k] + xy[0] * u[k] + xy[1] * v[k]);
|
||||
let nodes: Vec<[f64; 3]> = m.nodes.iter().map(|xy| at(*xy)).collect();
|
||||
let quads = m
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| match e.as_slice() {
|
||||
[a, b, c, d, ..] => Ok([*a, *b, *c, *d]),
|
||||
_ => Err(EngineError::Params("the section mesher made an element that is not a quadrilateral".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(Section { labels: vec![None; nodes.len()], nodes, quads, from: "meshed" })
|
||||
}
|
||||
|
||||
fn unit(a: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
|
||||
(l > 0.0 && l.is_finite()).then(|| a.map(|x| x / l))
|
||||
}
|
||||
|
||||
fn cross(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]]
|
||||
}
|
||||
|
||||
/// A list of face references.
|
||||
fn face_refs(p: &Value, key: &str, required: bool) -> Result<Vec<GeometryRef>> {
|
||||
let refs: Vec<GeometryRef> = match p.get(key) {
|
||||
None if !required => return Ok(Vec::new()),
|
||||
None => return Err(EngineError::param(key, ProblemKind::Required)),
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| EngineError::invalid(key, format!("a list of {{\"body\", \"face\"}}: {e}")))?,
|
||||
};
|
||||
if refs.len() > MAX_FACES || (required && refs.is_empty()) {
|
||||
return Err(EngineError::invalid(key, format!("1 to {MAX_FACES} faces")));
|
||||
}
|
||||
Ok(refs)
|
||||
}
|
||||
|
||||
/// The body the elements fill: `body`, or the first source face's.
|
||||
pub(super) fn extrude_body(_: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
if let Some(b) = p.get("body") {
|
||||
let name = b.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
GeometryRef::body(name)?;
|
||||
return Ok(vec![name.to_string()]);
|
||||
}
|
||||
let source = face_refs(p, "source", false)?;
|
||||
let first = source.first().ok_or_else(|| EngineError::Params("give `body` (the body the elements fill) with `source_set`".into()))?;
|
||||
Ok(vec![first.body.clone()])
|
||||
}
|
||||
|
||||
fn hex_extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tolerance = super::hex::number(p, "tolerance")?;
|
||||
let size = super::hex::number(p, "size")?;
|
||||
let layers = match p.get("layers") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.filter(|n| (1..=MAX_LAYERS as u64).contains(n))
|
||||
.ok_or_else(|| EngineError::invalid("layers", format!("a whole number from 1 to {MAX_LAYERS}")))? as usize,
|
||||
),
|
||||
};
|
||||
let bias = match p.get("bias") {
|
||||
None => 1.0,
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|b| (1.0 / MAX_BIAS..=MAX_BIAS).contains(b))
|
||||
.ok_or_else(|| EngineError::invalid("bias", format!("a number from {} to {MAX_BIAS}", 1.0 / MAX_BIAS)))?,
|
||||
};
|
||||
let path = match (p.get("direction"), p.get("normal")) {
|
||||
(Some(d), None) => {
|
||||
let d: [f64; 3] = serde_json::from_value(d.clone()).map_err(|_| EngineError::param("direction", ProblemKind::NotAVector { len: 3 }))?;
|
||||
if !d.iter().all(|x| x.is_finite()) || unit(d).is_none() {
|
||||
return Err(EngineError::invalid("direction", "a vector with a length"));
|
||||
}
|
||||
ExtrudePath::Direction(d)
|
||||
}
|
||||
(None, None) => ExtrudePath::Normal,
|
||||
(None, Some(n)) => match n.as_bool() {
|
||||
Some(true) => ExtrudePath::Normal,
|
||||
_ => return Err(EngineError::invalid("normal", "true, or give `direction`")),
|
||||
},
|
||||
(Some(_), Some(_)) => return Err(EngineError::Params("give `direction` or `normal`, not both".into())),
|
||||
};
|
||||
let source = face_refs(p, "source", false)?;
|
||||
let source_set = match p.get("source_set") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| EngineError::param("source_set", ProblemKind::NotAName))?.to_string()),
|
||||
};
|
||||
if source.is_empty() == source_set.is_none() {
|
||||
return Err(EngineError::Params("give the section as `source` (faces) or `source_set` (a set), one of them".into()));
|
||||
}
|
||||
let targets = face_refs(p, "to_surface", true)?;
|
||||
let sides = face_refs(p, "sides", false)?;
|
||||
let body = extrude_body(s, p)?.into_iter().next().unwrap_or_default();
|
||||
let body_ref = GeometryRef::body(body.as_str())?;
|
||||
let mut faces = Faces { bodies: Vec::new(), names: Vec::new(), faces: Vec::new(), tolerance: 0.0 };
|
||||
let source_ids = faces.add(s, &source, tolerance, "source")?;
|
||||
let target_ids = faces.add(s, &targets, tolerance, "to_surface")?;
|
||||
let side_ids = faces.add(s, &sides, tolerance, "sides")?;
|
||||
if let Some(f) = target_ids.iter().find(|f| source_ids.contains(f) || side_ids.contains(f)) {
|
||||
let (b, n) = faces.name(*f).unwrap_or_default();
|
||||
return Err(EngineError::Params(format!("face `{n}` of body `{b}` is both a target and a source or side face")));
|
||||
}
|
||||
|
||||
// The section: a set, the mesh already on the source faces, or a fresh mesh of one face.
|
||||
let own = body_elements(&s.model, &body);
|
||||
let section = match &source_set {
|
||||
Some(name) => {
|
||||
let r = s.model.resolve_set(name)?;
|
||||
let mut quads = Vec::new();
|
||||
for &e in &r.elements {
|
||||
if let Some(q) = quad_shell(&s.model, e) {
|
||||
quads.push(q);
|
||||
}
|
||||
}
|
||||
for f in &r.faces {
|
||||
quads.push(
|
||||
quad_face(&s.model, f.element, f.face).ok_or_else(|| {
|
||||
EngineError::Params(format!("set `{name}`: element {}'s face {} is not a quadrilateral", f.element, f.face))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
if quads.is_empty() {
|
||||
return Err(EngineError::Params(format!("set `{name}` has no quadrilaterals (quad shell elements or hex element faces)")));
|
||||
}
|
||||
Section::of_labels(&s.model, &quads, "set")?
|
||||
}
|
||||
None => {
|
||||
let tagged = tagged_quads(&s.model, &source, &own)?;
|
||||
if !tagged.is_empty() {
|
||||
Section::of_labels(&s.model, &tagged, "mesh")?
|
||||
} else {
|
||||
let near = neighbour_quads(&s.model, &faces, &source_ids, &own);
|
||||
if !near.is_empty() {
|
||||
Section::of_labels(&s.model, &near, "neighbour")?
|
||||
} else {
|
||||
let [id] = source_ids.as_slice() else {
|
||||
return Err(EngineError::Params("the source faces are not meshed: give one face to mesh here, or mesh them first".into()));
|
||||
};
|
||||
let (b, f) = faces.faces.get(*id as usize).copied().ok_or_else(|| EngineError::Params("no source face".into()))?;
|
||||
let name = faces.names.get(b).map(|n| n.0.clone()).unwrap_or_default();
|
||||
let shape = s.design.as_deref().and_then(|d| d.body(&name)).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
|
||||
let extent = shape.size()?;
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * extent);
|
||||
mesh_planar_face(shape, f, h, tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * extent))?
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let layers = match (layers, size) {
|
||||
(Some(n), _) => ExtrudeLayers::Count(n),
|
||||
(None, Some(h)) => ExtrudeLayers::Size(h),
|
||||
(None, None) => {
|
||||
let extent = s.design.as_deref().and_then(|d| d.body(&body)).map(Shape::size).transpose()?.unwrap_or(1.0);
|
||||
ExtrudeLayers::Size(DEFAULT_RELATIVE_SIZE * extent)
|
||||
}
|
||||
};
|
||||
let spec = ExtrudeSpec {
|
||||
nodes: §ion.nodes,
|
||||
quads: §ion.quads,
|
||||
path,
|
||||
targets: &target_ids,
|
||||
sides: &side_ids,
|
||||
layers,
|
||||
bias,
|
||||
tolerance: faces.tolerance.max(1e-9),
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
};
|
||||
let m = extrude_to_surface(&spec, &faces).map_err(|e| EngineError::of_body(&body, e))?;
|
||||
|
||||
// Replace the body's mesh; the section's shared nodes stay.
|
||||
let mut model = s.model.clone();
|
||||
let cleared = super::replace::clear(&mut model, [body.as_str()]);
|
||||
if let Some(l) = section.labels.iter().flatten().find(|l| !model.nodes().contains_key(l)) {
|
||||
return Err(EngineError::Params(format!("the section's node {l} belongs to body `{body}`'s mesh, which the extrusion replaces")));
|
||||
}
|
||||
let n_src = section.nodes.len();
|
||||
let fresh = m.nodes.len() - section.labels.iter().flatten().count();
|
||||
let (first_node, first_element) = super::fresh_labels(&model, fresh, m.hexes.len())?;
|
||||
let mut labels = Vec::with_capacity(m.nodes.len());
|
||||
let mut next = first_node;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
let shared = if i < n_src { section.labels.get(i).copied().flatten() } else { None };
|
||||
labels.push(match shared {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
model.set_node(next, *xyz)?;
|
||||
next += 1;
|
||||
next - 1
|
||||
}
|
||||
});
|
||||
}
|
||||
let body_tag = model.geometry_tag(&body_ref)?;
|
||||
for (e, h) in m.hexes.iter().enumerate() {
|
||||
let nodes: Vec<u32> = h.iter().map(|&i| labels.get(i as usize).copied().unwrap_or(0)).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, ElementKind::Hex8, nodes)?;
|
||||
model.tag_element(l, body_tag)?;
|
||||
}
|
||||
// Boundary faces on the body's own B-rep faces get their tags.
|
||||
let source_own: Vec<u32> = source_ids.iter().copied().filter(|&f| faces.name(f).is_some_and(|(b, _)| b == body)).collect();
|
||||
let mut tags: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for b in &m.boundary {
|
||||
let face = match b.role {
|
||||
ExtrudeRole::Source => source_own.first().copied(),
|
||||
ExtrudeRole::Target(f) | ExtrudeRole::Side(Some(f)) => Some(f),
|
||||
ExtrudeRole::Side(None) => None,
|
||||
};
|
||||
let Some((owner, name)) = face.and_then(|f| faces.name(f)) else { continue };
|
||||
if owner != body {
|
||||
continue;
|
||||
}
|
||||
let key = face.unwrap_or_default();
|
||||
let tag = match tags.get(&key) {
|
||||
Some(&t) => t,
|
||||
None => {
|
||||
let t = model.geometry_tag(&GeometryRef::face(body.as_str(), name)?)?;
|
||||
tags.insert(key, t);
|
||||
t
|
||||
}
|
||||
};
|
||||
model.tag_element_face(first_element + b.element, b.face, tag)?;
|
||||
}
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
s.model = model;
|
||||
let reply = json!({
|
||||
"body": body,
|
||||
"section": {"from": section.from, "quadrilaterals": section.quads.len(), "nodes": n_src, "shared_nodes": section.labels.iter().flatten().count()},
|
||||
"layers": m.layers,
|
||||
"kind": "hex8",
|
||||
"elements": m.hexes.len(),
|
||||
"new_nodes": fresh,
|
||||
"quality": {"min_scaled_jacobian": m.min_scaled_jacobian, "mean_scaled_jacobian": m.mean_scaled_jacobian},
|
||||
"volume": m.gate.volume,
|
||||
"max_target_gap": m.max_target_gap,
|
||||
"nodes": nodes,
|
||||
"model_elements": elements,
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||