forked from 1b-agent/fieldcraft-upstream
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c012a9e442 | ||
|
|
5a0605741b | ||
|
|
ef77deff5f | ||
|
|
4ffce253f2 | ||
|
|
8e28283dad | ||
|
|
a6f1a6dbeb | ||
|
|
87af68f550 | ||
|
|
94fdbf0fca | ||
|
|
75f9e5ae6e | ||
|
|
2030a81327 | ||
|
|
4c42a708f7 | ||
|
|
a530a0d914 | ||
|
|
c7d0a82079 | ||
|
|
09323ddb45 | ||
|
|
c6eeac35c1 | ||
|
|
755576de8a | ||
|
|
c3a1712069 | ||
|
|
92c3588099 | ||
|
|
df7f7f0769 | ||
|
|
1e88839575 | ||
|
|
b90554561f | ||
|
|
b3098f8739 | ||
|
|
21072a23de | ||
|
|
840b4b7dc5 | ||
|
|
e85300234f | ||
|
|
e3d42d8be1 | ||
|
|
f220c7108b | ||
|
|
3e997dab43 | ||
|
|
29667e63e8 | ||
|
|
7a9cb2cb22 | ||
|
|
1dc4c27dc1 | ||
|
|
0a0bac2bf2 | ||
|
|
fe1fc30be5 | ||
|
|
cc83e01c57 | ||
|
|
4a5d897782 | ||
|
|
c983987c64 | ||
|
|
05dc20a70b | ||
|
|
198ead4015 | ||
|
|
57f7bd3d7e | ||
|
|
1a91bb324d | ||
|
|
b5e54cb802 | ||
|
|
3385209c6f | ||
|
|
d23c2efaec | ||
|
|
22f2e0c9cc | ||
|
|
1f85a8caa3 | ||
|
|
c0cbcadf3d | ||
|
|
d1557a87fe | ||
|
|
5112855b83 | ||
|
|
441f03d093 | ||
|
|
4711f9474a | ||
|
|
0625125ed7 | ||
|
|
92b6225a72 | ||
|
|
5aace9b5b3 | ||
|
|
5fcbee028e | ||
|
|
a2d646a75e | ||
|
|
3363181a01 | ||
|
|
84f42f6f8a | ||
|
|
5189554a6a | ||
|
|
dcf03e3378 | ||
|
|
a0ae8b2111 | ||
|
|
587cbc3b01 | ||
|
|
980a50c1d9 | ||
|
|
3a023f3e25 | ||
|
|
f9c1c3cb24 | ||
|
|
2908a77850 | ||
|
|
bbf2dba092 | ||
|
|
89dfc62c2f | ||
|
|
1f36693c8f | ||
|
|
b377c882d6 | ||
|
|
3458117357 | ||
|
|
750f5f5a61 | ||
|
|
7a078b1c04 | ||
|
|
91e8467e4f | ||
|
|
0966dfa2a6 | ||
|
|
a09e3c856b | ||
|
|
1372094f8a | ||
|
|
e8e0b92d87 | ||
|
|
25dfbcce42 | ||
|
|
d78a4c2e52 | ||
|
|
8ba59701b4 | ||
|
|
358ed2f54f | ||
|
|
ecd78b9ee6 | ||
|
|
4ee855bb27 | ||
|
|
3eaca26246 | ||
|
|
464a4d50c1 | ||
|
|
0e0d8bd114 | ||
|
|
a7bb67c452 | ||
|
|
c2ad804fed | ||
|
|
7dd4043afe | ||
|
|
35ee88aa18 | ||
|
|
47bdbf981d | ||
|
|
b89efb55c1 | ||
|
|
27af5e19b7 | ||
|
|
de8cdd0b03 | ||
|
|
a53c79cb55 | ||
|
|
b65f1696b2 | ||
|
|
8819ff787c | ||
|
|
febcffefd6 | ||
|
|
836513ddfe | ||
|
|
0ec1882697 |
No files matched your search
@@ -1,2 +1,13 @@
|
||||
# Every change to FieldCraft needs the maintainer's review before it merges.
|
||||
* @jyviko
|
||||
|
||||
# Release and supply-chain paths, named explicitly so they stay owner-reviewed if the rule above
|
||||
# is ever relaxed: workflows and secrets use, packaging and signing, the updater, dependencies,
|
||||
# licences.
|
||||
/.github/ @jyviko
|
||||
/packaging/ @jyviko
|
||||
/crates/update/ @jyviko
|
||||
/Cargo.toml @jyviko
|
||||
/Cargo.lock @jyviko
|
||||
/ATTRIBUTION.md @jyviko
|
||||
/deny.toml @jyviko
|
||||
@@ -41,6 +41,6 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version and platform
|
||||
placeholder: "v0.5.9 (or commit), macOS 15 arm64"
|
||||
placeholder: "v0.6.0 (or commit), macOS 15 arm64"
|
||||
validations:
|
||||
required: true
|
||||
+88
-16
@@ -6,6 +6,13 @@ name: CI
|
||||
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
|
||||
# the run, so the review sees it; so do Dependabot's (.github/dependabot.yml), which nobody tests
|
||||
# locally. The heavy job stays off pull requests.
|
||||
#
|
||||
# What runs when (docs/releasing.md, "What CI runs when"): the `plan` job picks a base, the last
|
||||
# `develop` commit whose CI run passed (a pull request: its merge base; nightly and manual runs:
|
||||
# none), and `cargo xtask affected` maps the change since it to crates. `cargo xtask ci --affected`
|
||||
# then lints and tests those crates and their dependents only; the heavy and oracle jobs run when
|
||||
# the change reaches the solvers, the oracle or everything (Cargo.toml/Cargo.lock/xtask/workflows).
|
||||
# Nightly and manual runs, and every release (release.yml), run all of it.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -26,7 +33,7 @@ on:
|
||||
- 'docs/features.md'
|
||||
- 'docs/oracle.md'
|
||||
schedule:
|
||||
# Nightly, on the default branch (develop): the heavy tests.
|
||||
# Nightly, on the default branch (develop): the whole gate, the heavy tests and the oracle.
|
||||
- cron: '23 3 * * *'
|
||||
# Weekly: the oracle on macOS too, so results that differ by platform show up in CI (the
|
||||
# fixtures are often blessed on a Mac; #115).
|
||||
@@ -37,7 +44,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# A newer merge cancels the run still going for the one before it, so a burst of merges is tested
|
||||
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
|
||||
# once, at the tip; nothing is missed, since the tip's run tests everything since the last green
|
||||
# run. Nightly and manual runs are grouped apart and never cancelled by a merge.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
@@ -46,13 +54,75 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan (affected crates)
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
# To find the last green run on develop.
|
||||
actions: read
|
||||
outputs:
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
everything: ${{ steps.affected.outputs.everything }}
|
||||
solver: ${{ steps.affected.outputs.solver }}
|
||||
wasm: ${{ steps.affected.outputs.wasm }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: plan
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- name: Pick the base
|
||||
id: base
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
PR_BASE: ${{ github.base_ref }}
|
||||
run: |
|
||||
base=""
|
||||
case "$EVENT" in
|
||||
push)
|
||||
# The newest develop commit whose push run passed and that this commit contains.
|
||||
# Runs that were cancelled or failed are skipped, so their changes are tested here.
|
||||
for sha in $(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?branch=develop&event=push&status=success&per_page=100" --jq '.workflow_runs[].head_sha'); do
|
||||
if [ "$sha" != "$GITHUB_SHA" ] && git merge-base --is-ancestor "$sha" HEAD; then
|
||||
base="$sha"
|
||||
break
|
||||
fi
|
||||
done
|
||||
;;
|
||||
pull_request)
|
||||
base="$(git merge-base "origin/$PR_BASE" HEAD || true)"
|
||||
;;
|
||||
esac
|
||||
# Nightly and manual runs, or no green run to start from: everything.
|
||||
echo "base=$base" >>"$GITHUB_OUTPUT"
|
||||
echo "### Base: \`${base:-none (everything)}\`" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: cargo xtask affected
|
||||
id: affected
|
||||
env:
|
||||
BASE: ${{ steps.base.outputs.base }}
|
||||
run: |
|
||||
if [ -n "$BASE" ]; then
|
||||
cargo xtask affected --base "$BASE" --github-output | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ci:
|
||||
name: cargo xtask ci
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
needs: plan
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
@@ -60,18 +130,18 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
# A dependency update changes THIRD-PARTY-LICENSES.txt, which Dependabot cannot regenerate:
|
||||
# regenerate it here so the gate checks everything else, and dependabot-licences.yml commits
|
||||
# it to the pull request.
|
||||
- name: Regenerate THIRD-PARTY-LICENSES.txt (Dependabot)
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
run: cargo xtask licences
|
||||
- run: cargo xtask ci
|
||||
- name: cargo xtask ci (the affected crates, or everything)
|
||||
env:
|
||||
BASE: ${{ needs.plan.outputs.base }}
|
||||
run: |
|
||||
if [ -n "$BASE" ]; then cargo xtask ci --affected "$BASE"; else cargo xtask ci; fi
|
||||
|
||||
heavy:
|
||||
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
|
||||
# The heavy verification and corpus tests (#[ignore]d) and the doc tests, when the change
|
||||
# reaches them.
|
||||
name: cargo xtask test --heavy
|
||||
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
|
||||
needs: plan
|
||||
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -85,9 +155,11 @@ jobs:
|
||||
oracle:
|
||||
# Verification and validation (docs/verification.md): every case against its reference and
|
||||
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
|
||||
# run here too while the whole set stays within a few minutes; `--quick` skips them.
|
||||
# run here too while the whole set stays within a few minutes; `--quick` skips them. It runs
|
||||
# when the change reaches the solvers or the oracle's package.
|
||||
name: cargo xtask oracle
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
needs: plan
|
||||
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -120,7 +192,7 @@ jobs:
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
advisories:
|
||||
name: Dependency advisories and sources
|
||||
name: Dependency licences, advisories and sources
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
@@ -129,4 +201,4 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
- run: cargo deny --locked check --warn unmaintained advisories bans licenses sources
|
||||
@@ -1,40 +0,0 @@
|
||||
# Dependabot's Cargo updates change THIRD-PARTY-LICENSES.txt (`cargo xtask licences`), which
|
||||
# Dependabot cannot regenerate: this commits the regenerated file to its pull request. CI's own
|
||||
# run on the pull request regenerates it too, so its result covers everything else.
|
||||
name: Dependabot licences
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
paths: [Cargo.lock]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dependabot-licences-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
licences:
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo xtask licences
|
||||
- name: Commit the regenerated file
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt; then
|
||||
echo "THIRD-PARTY-LICENSES.txt is up to date"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
# [skip ci]: the pull request's CI run already regenerated this file and tested the
|
||||
# tree with it; a bot-authored commit would otherwise wait for a maintainer's approval.
|
||||
git commit -m "deps: regenerate THIRD-PARTY-LICENSES.txt [skip ci]" -- THIRD-PARTY-LICENSES.txt
|
||||
git push origin "HEAD:${GITHUB_HEAD_REF}"
|
||||
@@ -33,6 +33,10 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# POSTHOG_KEY lives in the `release` environment (main only, docs/releasing.md). The deploy job
|
||||
# already uses github-pages and a job has one environment, so the build job reads the key: on
|
||||
# main it runs in `release`, elsewhere in no environment and with an empty key.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
|
||||
@@ -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,11 @@
|
||||
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
|
||||
# otherwise (the release notes say which). Linux and Windows builds are not signed.
|
||||
#
|
||||
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
|
||||
# Before anything is packaged, the verify jobs run everything CI can: the full `cargo xtask ci`,
|
||||
# `cargo xtask test --heavy` and `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's
|
||||
# CI tests only what each change affects (ci.yml), so this is where a release proves all of it.
|
||||
#
|
||||
# Dry run: `gh workflow run release.yml --ref <branch>` verifies and builds everything and uploads the
|
||||
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
|
||||
# the draft.
|
||||
name: Release
|
||||
@@ -70,9 +75,65 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The full gate, the heavy tests and the oracle on Linux and macOS: every packaging job needs
|
||||
# them, so a release (or a dry run) never builds from a commit that fails any of them.
|
||||
verify-ci:
|
||||
name: Verify (cargo xtask ci)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask ci
|
||||
|
||||
verify-heavy:
|
||||
name: Verify (cargo xtask test --heavy)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask test --heavy
|
||||
|
||||
verify-oracle:
|
||||
name: Verify (cargo xtask oracle, ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
cache: release-oracle-linux
|
||||
- os: macos-14
|
||||
cache: release-oracle-macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ${{ matrix.cache }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
@@ -89,6 +150,8 @@ jobs:
|
||||
key: release-macos-universal
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
|
||||
run: cargo xtask licences
|
||||
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -116,8 +179,11 @@ jobs:
|
||||
|
||||
linux:
|
||||
name: Linux x86_64
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -137,6 +203,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
|
||||
@@ -200,7 +268,10 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows x64
|
||||
needs: version
|
||||
needs: [version, verify-ci, verify-heavy, verify-oracle]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
@@ -215,6 +286,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:
|
||||
@@ -230,6 +303,9 @@ jobs:
|
||||
release:
|
||||
name: Draft release (or dry-run artifacts)
|
||||
needs: [version, macos, linux, windows]
|
||||
# Signing, notarization and analytics secrets live in the `release` environment, which only
|
||||
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -246,12 +322,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
|
||||
@@ -119,17 +119,22 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
|
||||
Then remove your worktree and delete your local branch.
|
||||
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
|
||||
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
|
||||
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
|
||||
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
|
||||
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs,
|
||||
per merge on `develop`, `cargo xtask ci --affected <last green commit>` (fmt, assets,
|
||||
layers on the whole tree; clippy and tests on the crates the change reaches; wasm when one
|
||||
of them builds for it), the oracle and heavy tests when the change reaches the solvers or
|
||||
the oracle, and the dependency licences and advisories; a change to Cargo.toml, Cargo.lock,
|
||||
xtask or the workflows, the nightly run and every release run all of it (docs/releasing.md,
|
||||
"What CI runs when"). `cargo xtask affected --base origin/develop` shows what your branch
|
||||
reaches. The coordinator
|
||||
watches it and sends a failure back to the change that caused it, which is fixed forward
|
||||
at once.
|
||||
- **Keep the trunk gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
|
||||
(convergence studies, the whole corpus, cross-checks between solvers) is
|
||||
`#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative
|
||||
test left in the trunk gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
|
||||
test, benchmarks included, and the doc tests) on every merge to `develop`, nightly and on
|
||||
demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
|
||||
test, benchmarks included, and the doc tests) on merges to `develop` that reach a crate with
|
||||
heavy tests, nightly, before every release and on demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
|
||||
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
|
||||
test.
|
||||
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
|
||||
|
||||
+21
-6
@@ -24,13 +24,25 @@ 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/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/impact.png | FieldCraft contributors | Original: the impact sample as docs/media/examples/impact.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the impact sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/parts.png | FieldCraft contributors | Original: the parts sample as docs/media/examples/parts.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the parts sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/bracket-hex.png | FieldCraft contributors | Original: the bracket-hex sample as docs/media/examples/bracket-hex.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the bracket-hex sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/crank-arm.png | FieldCraft contributors | Original: the crank-arm sample as docs/media/examples/crank-arm.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-arm sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/crank-mirror.png | FieldCraft contributors | Original: the crank-mirror sample as docs/media/examples/crank-mirror.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-mirror sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/drop-test.png | FieldCraft contributors | Original: the drop-test sample as docs/media/examples/drop-test.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the drop-test sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/heated-bracket.png | FieldCraft contributors | Original: the heated-bracket sample as docs/media/examples/heated-bracket.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the heated-bracket sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/rubber-seal.png | FieldCraft contributors | Original: the rubber-seal sample as docs/media/examples/rubber-seal.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the rubber-seal sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
|
||||
| crates/engine/tests/designs/pi5-enclosure.json | FieldCraft contributors | Original SolidCraft recipe (solid.* commands) of an enclosure for a single-board computer, for thermal analysis: base, lid, heat sink, board, chips, connectors and the air inside | MIT OR Apache-2.0 |
|
||||
| crates/geometry/tests/designs/hollow-box.solidcraft, crates/geometry/tests/designs/hollow-box-post.solidcraft, crates/geometry/tests/designs/open-tray-post.solidcraft | FieldCraft contributors | Built with `solidcraft-cli run` from the recipes beside them (crates/geometry/tests/designs/*.json) | MIT OR Apache-2.0 |
|
||||
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.write.abaqus.svg, assets/icons/deck.write.abaqus.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.abaqus.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -58,6 +70,8 @@ is committed and listed here with its exports.
|
||||
| assets/icons/interaction.contact.general.svg, assets/icons/interaction.contact.general.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.general.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.contact.pair.svg, assets/icons/interaction.contact.pair.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.pair.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.fsi.svg, assets/icons/interaction.fsi.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.block.svg, assets/icons/mesh.block.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.block.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.mirror.svg, assets/icons/mesh.mirror.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.mirror.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.mass.create.svg, assets/icons/model.mass.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.mass.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.rigid_wall.svg, assets/icons/interaction.rigid_wall.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.rigid_wall.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.spring.create.svg, assets/icons/model.spring.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.spring.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -74,6 +88,7 @@ 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.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 |
|
||||
|
||||
Generated
+83
-40
@@ -1052,7 +1052,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,7 +1270,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1289,7 +1289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1300,7 +1300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1309,7 +1309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1328,12 +1328,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1344,7 +1344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1352,10 +1352,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
"quick-xml 0.42.0",
|
||||
"serde_json",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-engine",
|
||||
@@ -1363,12 +1363,12 @@ dependencies = [
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
@@ -1376,12 +1376,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1391,7 +1391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1402,7 +1402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"num-bigint",
|
||||
@@ -1414,7 +1414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1423,14 +1423,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1452,7 +1452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1463,7 +1463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1484,7 +1484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-update"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -1496,12 +1496,12 @@ dependencies = [
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"zip",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -2208,7 +2208,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",
|
||||
@@ -2355,6 +2355,18 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -3087,6 +3099,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3792,7 +3813,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 +3830,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 +3847,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 +3856,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 +3866,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 +3891,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 +3902,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 +4250,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 +4300,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 +4315,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 +4355,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -4697,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -5356,7 +5384,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5507,6 +5535,21 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
+14
-14
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.9"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -35,9 +35,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
|
||||
# 3MF packages (docs/formats/tagged-mesh.md): the same versions SolidCraft uses.
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# 3MF packages (docs/formats/tagged-mesh.md). zip is ahead of SolidCraft (still on 3), so both are built.
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.42"
|
||||
|
||||
# content hashes and part checksums of .fieldcraft files (the version SolidCraft uses)
|
||||
sha2 = "0.11"
|
||||
@@ -59,13 +59,13 @@ rfd = "0.17"
|
||||
|
||||
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
|
||||
# (plan/adr/0001-libraries.md).
|
||||
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "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 +129,10 @@ debug = "line-tables-only"
|
||||
# reach a dependent workspace, so FieldCraft applies the same ones from the same pinned commit;
|
||||
# keep this list and the rev in step with SolidCraft's [patch.crates-io].
|
||||
[patch.crates-io]
|
||||
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "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.
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ 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
|
||||
@@ -148,7 +148,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
|
||||
|
||||
+38
-1
@@ -32,7 +32,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| 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 |
|
||||
@@ -54,6 +54,43 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| 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:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
Binary file not shown.
@@ -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 |
Binary file not shown.
@@ -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 |
Binary file not shown.
@@ -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": "M12 2 L12 22", "stroke": "accent", "width": 1.25, "dash": [2, 1.5]}, {"d": "M10 5 L3 7 L3 17 L10 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 12 L10 12 M6.5 6 L6.5 18", "stroke": "line", "width": 1}, {"d": "M14 5 L21 7 L21 17 L14 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M14 12 L21 12 M17.5 6 L17.5 18", "stroke": "line", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"circle": [12, 12, 9], "stroke": "line", "width": 1.25, "dash": [2, 2]}, {"d": "M5 12 L19 12 M12 5 L12 19", "stroke": "line", "width": 1.0}, {"d": "M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5", "stroke": "line", "width": 1.0}, {"circle": [12, 12, 2.4], "fill": "accent", "stroke": "accent"}]}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A cancelled job ([`fieldcraft_model::progress::Progress::cancel`]) stops every solver and
|
||||
//! mesher promptly with `cancelled` and leaves the model as it was.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fieldcraft_geometry::sample::Plate;
|
||||
use fieldcraft_model::progress::{self, Progress};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::sample::impact;
|
||||
|
||||
/// How long a cancelled command may keep running: one increment, time step or pass, far
|
||||
/// below the minutes the runs below would take uncancelled.
|
||||
const PROMPT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Run `command` on `s` the way a job does and cancel it once it reports the stage `stage`
|
||||
/// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving
|
||||
/// the model and the journal as they were.
|
||||
fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
|
||||
let (model, journal) = (s.model.clone(), s.journal.len());
|
||||
let p = Progress::new();
|
||||
if stage.is_none() {
|
||||
p.cancel();
|
||||
}
|
||||
let (r, waited) = std::thread::scope(|scope| {
|
||||
let worker = {
|
||||
let p = p.clone();
|
||||
let s = &mut *s;
|
||||
let params = params.clone();
|
||||
scope.spawn(move || progress::scoped(Some(p), || s.execute(command, ¶ms)))
|
||||
};
|
||||
let mut at = Instant::now();
|
||||
if let Some(stage) = stage {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
let now = p.snapshot();
|
||||
if now.stage.starts_with(stage) && now.done > 0.0 {
|
||||
at = Instant::now();
|
||||
p.cancel();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot());
|
||||
}
|
||||
let r = worker.join().unwrap();
|
||||
(r, at.elapsed())
|
||||
});
|
||||
let e = r.expect_err(&format!("{command}: ran to the end although cancelled"));
|
||||
assert!(e.is_cancelled(), "{command}: {e}");
|
||||
assert_eq!(e.to_string(), "cancelled", "{command}");
|
||||
assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel");
|
||||
assert!(s.model == model, "{command}: the model changed");
|
||||
assert_eq!(s.journal.len(), journal, "{command}: journalled");
|
||||
}
|
||||
|
||||
/// A sample's script run on a fresh session.
|
||||
fn session(script: &Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.run_script(script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn plate() -> (crate::tests::TempDir, Session) {
|
||||
let (dir, path) = crate::tests::sample_design_file(&Plate::default());
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_stops_between_time_steps() {
|
||||
// A hundred times as long as the sample: hundreds of thousands of increments.
|
||||
let mut script = impact();
|
||||
for c in script.as_array_mut().unwrap() {
|
||||
if c["command"] == "model.step.create" {
|
||||
let p = &mut c["params"]["procedure"];
|
||||
(p["end_time"], p["output_interval"], p["history_interval"]) = (json!(4e-3), json!(4e-4), json!(2e-5));
|
||||
}
|
||||
}
|
||||
let mut s = session(&script);
|
||||
cancel(&mut s, "solve.explicit", json!({}), Some("Time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_stops_between_lattice_steps() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "flow-past"})).unwrap();
|
||||
cancel(&mut s, "solve.fluid", json!({}), Some("Fluid steps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonlinear_stops_between_increments() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "rubber-seal"})).unwrap();
|
||||
cancel(&mut s, "solve.nonlinear", json!({}), Some("Load factor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_heat_stops_between_increments() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "heated-bracket"})).unwrap();
|
||||
s.execute(
|
||||
"model.step.create",
|
||||
&json!({"name": "Warm", "procedure": {"type": "heat_transfer", "transient": {"end_time": 1e5, "increment": 1}},
|
||||
"boundary_conditions": ["Heater"], "loads": ["Air"]}),
|
||||
)
|
||||
.unwrap();
|
||||
cancel(&mut s, "solve.heat", json!({"step": "Warm"}), Some("Time increment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_frequency_and_solve_all_stop_at_the_factorization() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
cancel(&mut s, "solve.static", json!({}), None);
|
||||
cancel(&mut s, "solve.frequency", json!({}), None);
|
||||
cancel(&mut s, "solve.run", json!({}), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshers_stop_and_leave_no_mesh() {
|
||||
let (_dir, mut s) = plate();
|
||||
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 0.4}), Some("Refinement round"));
|
||||
cancel(&mut s, "mesh.tet.generate", json!({"body": "Body1", "size": 2.5}), None);
|
||||
cancel(&mut s, "mesh.hex.generate", json!({"body": "Body1", "size": 2.5}), None);
|
||||
cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None);
|
||||
assert!(s.model.nodes().is_empty());
|
||||
}
|
||||
@@ -238,7 +238,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
// ---- Geometry ----
|
||||
ready("geometry.open", "Open Design", Geometry, "Import", "Open a SolidCraft design: its bodies with persistent face names"),
|
||||
ready("geometry.import.mesh", "Tagged Mesh", Geometry, "Import", "Read a face-tagged surface mesh (3MF)"),
|
||||
ready("geometry.bodies", "Bodies", Geometry, "Inspect", "List the open design's bodies, volumes and named faces"),
|
||||
ready_more(
|
||||
"geometry.bodies",
|
||||
"Bodies",
|
||||
Geometry,
|
||||
"Inspect",
|
||||
"List the open design's bodies, volumes and named faces, edges and vertices",
|
||||
&["geometry.centre"],
|
||||
),
|
||||
ready("geometry.tessellate", "Tessellate", Geometry, "Inspect", "Tessellate a body into a face-tagged boundary surface"),
|
||||
ready(
|
||||
"mesh.surface.check",
|
||||
@@ -254,7 +261,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 +285,7 @@ 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"],
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
@@ -325,6 +332,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
|
||||
&["mesh.hex.blocks"],
|
||||
),
|
||||
ready("mesh.block", "Mesh Block", Mesh, "Structured Hex", "A mapped block of hex, wedge or quad elements between corner points"),
|
||||
soon(
|
||||
"mesh.hex.decompose",
|
||||
"Decompose",
|
||||
@@ -346,16 +354,25 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
|
||||
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
|
||||
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more(
|
||||
"mesh.element.add",
|
||||
"Element",
|
||||
"mesh.node.relax",
|
||||
"Relax Nodes",
|
||||
Mesh,
|
||||
"Improve",
|
||||
"Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
|
||||
&["mesh.node.relax.apply"],
|
||||
),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]),
|
||||
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
|
||||
ready_more(
|
||||
"mesh.node.merge",
|
||||
"Merge Nodes",
|
||||
Mesh,
|
||||
"Edit",
|
||||
"Add an element on existing nodes, or a mapped block of them",
|
||||
&["mesh.element.remove", "mesh.block", "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"),
|
||||
@@ -498,7 +515,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Contact",
|
||||
"Find the parts of the mesh that touch, and make contact pairs of them",
|
||||
),
|
||||
soon("interaction.rigid_wall", "Rigid Wall", Interactions, "Contact", "M10", "Rigid walls for explicit dynamics"),
|
||||
ready_more(
|
||||
"interaction.rigid_wall",
|
||||
"Rigid Wall",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"A fixed rigid plane a set's nodes cannot pass, with friction, in an explicit dynamics step",
|
||||
&["interaction.rigid_wall.delete"],
|
||||
),
|
||||
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
|
||||
ready_more(
|
||||
"model.spring.create",
|
||||
@@ -564,7 +588,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Displacement, rotation, reaction and stress at nodes, element section forces, a mode shape, or an explicit field frame (or click the model)",
|
||||
&["solve.history"],
|
||||
),
|
||||
ready("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set"),
|
||||
ready_more("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set", &["results.export.csv"]),
|
||||
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
|
||||
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
|
||||
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
|
||||
@@ -574,8 +598,10 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
||||
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
||||
// ---- Tools ----
|
||||
ready("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary"),
|
||||
ready("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model"),
|
||||
// Renaming starts from a definition's item menu (Rename…); the search finds it here too.
|
||||
ready_more("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary", &["model.rename"]),
|
||||
// New starts from the File menu; the search finds it here too.
|
||||
ready_more("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model", &["file.new"]),
|
||||
ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"),
|
||||
ready("file.save", "Save", Tools, "File", "Save the model to its .fieldcraft file"),
|
||||
ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .fieldcraft file"),
|
||||
@@ -604,7 +630,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"],
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
+402
-47
@@ -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,12 +27,14 @@
|
||||
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::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, add_hex, hex_body};
|
||||
use super::hex::{Input, add_cells, add_hex, hex_body};
|
||||
use super::mesh::{BodyTets, add_tets, mesh_body, quality_options};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
@@ -30,7 +42,7 @@ 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)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, they are tied: each cell's tied cut faces make one tie, `tie_<cell>` (`/` as `_`; sets `<tie>_main`, the facing cells' cut faces, and `<tie>_secondary`, the cell's own), hex cells main to tet cells and otherwise by name, so a node is a secondary node of one such tie only; the ties an earlier run made on these cells are replaced. The cells of one design body that may sweep (auto, hex, swept, extrude, revolve) are swept together first: one cross-section and one set of layers for all of them, so they share their nodes across every cut, T-junctions included, with no edge seeds (`swept_together` names the body; `not_swept_together` says why not, and those cells are meshed one by one). Reports each cell's method (and why auto fell back to tet), its `quality` (volume, smallest and mean scaled Jacobian, largest edge ratio), and each interface: `merged` with the nodes merged or shared, or `tied`"#,
|
||||
form: Some(super::forms::CELLS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
@@ -148,9 +160,55 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
|
||||
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
let method_of = |n: &str| methods.get(n).copied().unwrap_or(default);
|
||||
// The cells of one body that may sweep are swept together first (#288): one cross-section
|
||||
// and one set of layers for all of them, so they meet node for node across their cuts. Cells
|
||||
// that do not sweep together are meshed one by one, as below.
|
||||
let mut groups: BTreeMap<(String, HexMethod), Vec<usize>> = BTreeMap::new();
|
||||
for (i, shape) in shapes.iter().enumerate() {
|
||||
let sweep = match method_of(&shape.name) {
|
||||
Method::Auto | Method::Hex(HexMethod::Auto | HexMethod::Swept) => Some(HexMethod::Swept),
|
||||
Method::Hex(m @ (HexMethod::Extrude | HexMethod::Revolve)) => Some(m),
|
||||
Method::Hex(_) | Method::Tet => None,
|
||||
};
|
||||
if let (Some(m), Some(source)) = (sweep, design.source_of(&shape.name)) {
|
||||
groups.entry((source.to_string(), m)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut joint: Vec<Option<usize>> = vec![None; shapes.len()];
|
||||
let mut not_together: BTreeMap<usize, String> = BTreeMap::new();
|
||||
let mut swept: Vec<(String, Vec<usize>, Vec<Input>, CellsMesh)> = Vec::new();
|
||||
for ((source, m), members) in groups {
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let inputs: Vec<Input> = members
|
||||
.iter()
|
||||
.filter_map(|&i| shapes.get(i))
|
||||
.map(|shape| Ok(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }))
|
||||
.collect::<Result<_>>()?;
|
||||
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds)? {
|
||||
Ok(cm) => {
|
||||
for &i in &members {
|
||||
if let Some(slot) = joint.get_mut(i) {
|
||||
*slot = Some(swept.len());
|
||||
}
|
||||
}
|
||||
swept.push((source, members, inputs, cm));
|
||||
}
|
||||
Err(why) => {
|
||||
for &i in &members {
|
||||
not_together.insert(i, why.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut meshed: Vec<(Meshed, Value)> = Vec::with_capacity(shapes.len());
|
||||
for 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;
|
||||
}
|
||||
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()?;
|
||||
@@ -175,6 +233,9 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
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,9 +244,29 @@ 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 {
|
||||
let mut report: Vec<(usize, Value)> = Vec::with_capacity(shapes.len());
|
||||
let mut interfaces = Vec::new();
|
||||
for (source, members, inputs, cm) in &swept {
|
||||
add_cells(model, inputs, cm)?;
|
||||
let m = &cm.mesh;
|
||||
for (c, (&i, b)) in members.iter().zip(inputs).enumerate() {
|
||||
let elements: Vec<usize> = (0..m.element_count()).filter(|&e| cm.element_cell.get(e).is_some_and(|&x| x as usize == c)).collect();
|
||||
let nodes: BTreeSet<u32> = elements.iter().filter_map(|&e| m.element(e)).flatten().copied().collect();
|
||||
report.push((
|
||||
i,
|
||||
json!({"body": b.name, "method": m.report.method.name(), "swept_together": source, "description": m.report.description, "kind": m.kind.name(), "elements": elements.len(), "nodes": nodes.len()}),
|
||||
));
|
||||
}
|
||||
interfaces.extend(swept_interfaces(&design, inputs, cm));
|
||||
}
|
||||
let mut k = 0;
|
||||
for (i, _) in shapes.iter().enumerate() {
|
||||
if joint.get(i).copied().flatten().is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((cell, mut info)) = meshed.get_mut(k).map(|(c, v)| (c, std::mem::take(v))) else { break };
|
||||
k += 1;
|
||||
let (kind, elements, nodes) = match &*cell {
|
||||
Meshed::Hex(b, m) => {
|
||||
add_hex(model, b, m)?;
|
||||
(m.kind, m.element_count(), m.nodes.len())
|
||||
@@ -200,13 +281,135 @@ 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();
|
||||
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})))
|
||||
}
|
||||
|
||||
/// Sweep the cells `inputs` together ([`hex_mesh_swept_cells`]) with `method` (extrude,
|
||||
/// revolve, or swept: either). `Ok(Err(why))` when they do not sweep together; limits and a
|
||||
/// cancel are errors.
|
||||
fn swept_together(
|
||||
p: &Value,
|
||||
inputs: &[Input],
|
||||
method: HexMethod,
|
||||
quadratic: bool,
|
||||
size: Option<f64>,
|
||||
tolerance: f64,
|
||||
seeds: &[fieldcraft_mesh::seeds::EdgeSeed],
|
||||
) -> Result<std::result::Result<CellsMesh, String>> {
|
||||
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
|
||||
let mut surfaces = Vec::with_capacity(inputs.len());
|
||||
for b in inputs {
|
||||
let options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
match BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &options) {
|
||||
Ok(surface) => surfaces.push(surface),
|
||||
Err(e) => return Ok(Err(format!("body `{}`: {e}", b.name))),
|
||||
}
|
||||
}
|
||||
let cells: Vec<Cell> = surfaces.iter().zip(inputs).map(|(surface, b)| Cell { name: &b.name, surface, faces: &b.surfaces }).collect();
|
||||
let options = HexOptions {
|
||||
method,
|
||||
quadratic,
|
||||
chordal_tolerance: tolerance,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: seeds.to_vec(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
match hex_mesh_swept_cells(&cells, &UniformSize(h), &options) {
|
||||
Ok(cm) => Ok(Ok(cm)),
|
||||
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
|
||||
Err(e @ fieldcraft_mesh::Error::Limit(_)) => Err(EngineError::Params(e.to_string())),
|
||||
Err(e) => Ok(Err(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The interfaces of cells swept together: each pair of cells whose elements share faces, with
|
||||
/// the partition between them and the nodes they share (one mesh: merged by construction).
|
||||
fn swept_interfaces(design: &fieldcraft_geometry::Design, inputs: &[Input], cm: &CellsMesh) -> Vec<Value> {
|
||||
let m = &cm.mesh;
|
||||
let mut owner: BTreeMap<[u32; 4], u32> = BTreeMap::new();
|
||||
let mut pairs: BTreeMap<(u32, u32), BTreeSet<u32>> = BTreeMap::new();
|
||||
for e in 0..m.element_count() {
|
||||
let (Some(h), Some(&c)) = (m.element(e), cm.element_cell.get(e)) else { continue };
|
||||
for local in fieldcraft_model::ElementKind::Hex8.faces() {
|
||||
let mut key = [0u32; 4];
|
||||
for (slot, &i) in key.iter_mut().zip(local.iter()) {
|
||||
*slot = h.get(i).copied().unwrap_or(0);
|
||||
}
|
||||
key.sort_unstable();
|
||||
match owner.insert(key, c) {
|
||||
Some(o) if o != c => pairs.entry((o.min(c), o.max(c))).or_default().extend(key),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The partitions whose cut faces a cell has, with the side: two cells meet across a
|
||||
// partition they are on opposite sides of.
|
||||
let cuts = |c: u32| -> BTreeSet<(String, String)> {
|
||||
let name = inputs.get(c as usize).map_or("", |b| b.name.as_str());
|
||||
let faces = design.body(name).map(|b| b.faces().iter().map(|f| f.name.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let (p, side) = f.strip_prefix("partition:")?.rsplit_once(':')?;
|
||||
let side = side.split_once('#').map_or(side, |(s, _)| s);
|
||||
Some((p.to_string(), side.to_string()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|((a, b), nodes)| {
|
||||
let (na, nb) = (inputs.get(a as usize).map_or("", |x| x.name.as_str()), inputs.get(b as usize).map_or("", |x| x.name.as_str()));
|
||||
let theirs = cuts(b);
|
||||
let partition =
|
||||
cuts(a).into_iter().find(|(p, side)| theirs.iter().any(|(q, other)| q == p && other != side)).map(|(p, _)| p).unwrap_or_default();
|
||||
json!({"partition": partition, "cells": [na, nb], "joined": "merged", "nodes": nodes.len(), "swept_together": true})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A cell's mesh in numbers: its volume, smallest and mean scaled Jacobian and largest edge
|
||||
/// ratio.
|
||||
fn cell_quality(model: &Model, body: &str) -> Value {
|
||||
let (mut volume, mut min, mut sum, mut n, mut ratio) = (0.0, f64::INFINITY, 0.0, 0usize, None::<f64>);
|
||||
for label in model.body_elements(body) {
|
||||
let Some(e) = model.element(label) else { continue };
|
||||
let xyz: Vec<[f64; 3]> = e.nodes.iter().filter_map(|&l| model.node(l).map(|x| x.xyz)).collect();
|
||||
let Ok(q) = fieldcraft_mesh::quality::element_quality(e.kind, &xyz) else { continue };
|
||||
volume += q.size;
|
||||
if let Some(r) = q.get(fieldcraft_mesh::quality::Metric::EdgeRatio) {
|
||||
ratio = Some(ratio.map_or(r, |x| x.max(r)));
|
||||
}
|
||||
if let Some(sj) = q.get(fieldcraft_mesh::quality::Metric::ScaledJacobian) {
|
||||
min = min.min(sj);
|
||||
sum += sj;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let min = if n > 0 { Some(min) } else { None };
|
||||
json!({"volume": volume, "min_scaled_jacobian": min, "mean_scaled_jacobian": if n > 0 { Some(sum / n as f64) } else { None }, "max_edge_ratio": ratio})
|
||||
}
|
||||
|
||||
/// One side of an interface: a cell's element faces on one side of a partition.
|
||||
struct Side {
|
||||
body: String,
|
||||
@@ -268,54 +471,114 @@ 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)
|
||||
}
|
||||
|
||||
/// 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 +612,15 @@ 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.
|
||||
// A tet cell next to a hex cell: tied, the hex cell main.
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["cells"][0]["kind"], "tet4", "{r}");
|
||||
assert_eq!(r["cells"][1]["kind"], "hex8", "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "tied", "{r}");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
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.
|
||||
@@ -491,4 +755,95 @@ mod tests {
|
||||
assert!((u - expected).abs() < 1e-6 * expected, "{u} vs {expected}: {n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A box cut by planes into cells, `methods` per cell, a heat source in every cell and
|
||||
/// convection on its outer faces: the heat flows of the steady solve.
|
||||
fn cut_box_heat(cuts: &[(&str, f64)], methods: Value, size: f64) -> (Value, Value) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10, "body_name": "Box"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
for (i, (plane, offset)) in cuts.iter().enumerate() {
|
||||
let name = format!("D{i}");
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": plane, "offset": offset}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{i}"), "datum": name})).unwrap();
|
||||
}
|
||||
let r = s.execute("mesh.cells", &json!({"methods": methods, "default": "hex", "size": size})).unwrap();
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let outer: Vec<Value> = bodies
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|b| {
|
||||
b["faces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|f| !f["name"].as_str().unwrap().starts_with("partition:"))
|
||||
.map(|f| json!({"body": b["name"], "face": f["name"]}))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": s.model.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "skin", "geometry": outer}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "all", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Air"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
let h = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
(r, h)
|
||||
}
|
||||
|
||||
/// A tet cell among hex cells (#389, #288): the hex cells sweep together and share their
|
||||
/// nodes, the tet cell is tied once (one tie for all its cut faces, the hex cells main), and
|
||||
/// the heat balances to round-off.
|
||||
#[test]
|
||||
fn a_tet_cell_among_swept_hex_cells_is_tied_once_and_the_heat_balances() {
|
||||
let (r, h) = cut_box_heat(&[("yz", 10.0), ("yz", 20.0), ("zx", 10.0)], json!({"Box/b/a/a": "tet"}), 2.5);
|
||||
let interfaces = r["interfaces"].as_array().unwrap();
|
||||
let tied: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "tied").collect();
|
||||
assert_eq!(tied.len(), 3, "{r}");
|
||||
assert!(tied.iter().all(|i| i["secondary"] == "Box/b/a/a" && i["tie"] == "tie_Box_b_a_a"), "{r}");
|
||||
let merged: Vec<&Value> = interfaces.iter().filter(|i| i["joined"] == "merged").collect();
|
||||
assert_eq!(merged.len(), 4, "{r}");
|
||||
// Box/a/a and Box/a/b meet across y = 10, the third cut.
|
||||
assert!(merged.iter().any(|i| i["cells"] == json!(["Box/a/a", "Box/a/b"]) && i["partition"] == "P2"), "{r}");
|
||||
let flows = &h["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(h["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{h}");
|
||||
}
|
||||
|
||||
/// Cells cut from one box by planes, one of them (`y = 10`) cutting only the cell left of
|
||||
/// `x = 10`, so a cut ends on another (a T-junction): with no seeds the cells sweep together
|
||||
/// and every interface merges node for node (#288), and the mesh fills the box.
|
||||
#[test]
|
||||
fn cells_of_a_body_sweep_together_and_merge_without_seeds() {
|
||||
let (_dir, mut s) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10, "corner": [0, 0, 0], "body_name": "Box"}},
|
||||
]));
|
||||
super::super::datum::tests::cut_at(&mut s, &[(0, 10.0), (0, 21.0)]);
|
||||
s.execute("geometry.datum", &json!({"name": "Y", "by": {"type": "offset", "plane": "zx", "offset": 7.5}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "PY", "datum": "Y", "bodies": ["Box/a"]})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "auto", "size": 2.0})).unwrap();
|
||||
let cells = r["cells"].as_array().unwrap();
|
||||
assert_eq!(cells.len(), 4, "{r}");
|
||||
assert!(cells.iter().all(|c| c["swept_together"] == "Box" && c["kind"] == "hex8"), "{r}");
|
||||
let interfaces = r["interfaces"].as_array().unwrap();
|
||||
assert_eq!(interfaces.len(), 4, "{r}");
|
||||
assert!(interfaces.iter().all(|i| i["joined"] == "merged"), "{r}");
|
||||
assert!(s.model.ties().is_empty());
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
let volume = q["volume"].as_f64().unwrap();
|
||||
assert!((volume - 6000.0).abs() < 1e-6, "{q}");
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
|
||||
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes.
|
||||
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes;
|
||||
//! `interaction.rigid_wall*`: rigid walls, which belong to an explicit dynamics step (its
|
||||
//! `rigid_walls`, also edited in the step's form).
|
||||
//!
|
||||
//! A contact's surfaces are sets of element faces, or of element edges in a 2D model
|
||||
//! ([`fieldcraft_model::contact`]). The commands
|
||||
@@ -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) {
|
||||
|
||||
+300
-45
@@ -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)} — 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() })
|
||||
@@ -431,21 +475,24 @@ pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
Ok(design.partitioned(&cuts(model))?)
|
||||
}
|
||||
|
||||
/// Cut the session's design by its model's partitions again.
|
||||
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. The bodies that are gone (cells
|
||||
/// whose partition went, a body a new partition cut) take their meshes with them (#385): what
|
||||
/// went is in the returned [`super::replace::Cleared`], which the command reports.
|
||||
fn recut(s: &mut Session) -> Result<super::replace::Cleared> {
|
||||
let Some(d) = &s.design else { return Ok(super::replace::Cleared::default()) };
|
||||
let cut = partitioned(d, &s.model)?;
|
||||
let gone: Vec<String> = d.bodies.iter().filter(|b| cut.body(&b.name).is_none()).map(|b| b.name.clone()).collect();
|
||||
s.design = Some(Arc::new(cut));
|
||||
Ok(super::replace::clear(&mut s.model, gone.iter().map(String::as_str)))
|
||||
}
|
||||
|
||||
fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let datum: String = super::req(p, "datum")?;
|
||||
let bodies: Vec<String> = super::opt(p, "bodies")?.unwrap_or_default();
|
||||
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 +506,68 @@ 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();
|
||||
for cell in &cells {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) {
|
||||
warnings.push(format!(
|
||||
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// The cut shows as edges on the cells, so the plane steps out of the way (view state, #295):
|
||||
// selecting it in the browser or its eye shows it again.
|
||||
if s.view.hidden.len() < MAX_HIDDEN {
|
||||
s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum });
|
||||
}
|
||||
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, bounds a volume mesh:
|
||||
/// the defects (open edges, edges on three triangles) when it does not.
|
||||
fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, None)?;
|
||||
let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() };
|
||||
fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options)
|
||||
.map(|_| ())
|
||||
.map_err(|e| EngineError::Params(e.to_string()))
|
||||
}
|
||||
|
||||
fn datum_suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let suppressed = super::opt(p, "suppressed")?.unwrap_or(true);
|
||||
s.model.set_datum_suppressed(name, suppressed)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies})))
|
||||
}
|
||||
|
||||
fn partition_suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let suppressed = super::opt(p, "suppressed")?.unwrap_or(true);
|
||||
s.model.set_partition_suppressed(name, suppressed)?;
|
||||
let cleared = recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(cleared.report_if_any(s, json!({"name": name, "suppressed": suppressed, "bodies": bodies})))
|
||||
}
|
||||
|
||||
fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
s.model.remove_partition(name)?;
|
||||
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 +638,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 +841,145 @@ 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.
|
||||
#[test]
|
||||
fn cuts_stay_on_the_plane_and_risky_ones_are_named() {
|
||||
let (_dir, mut s) = pi5();
|
||||
let plane = |s: &mut Session, name: &str, x: f64| {
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": "yz", "offset": x}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{name}"), "datum": name, "bodies": ["Air"]})).unwrap()
|
||||
};
|
||||
// Beside the board's mounting holes: clean cells, cut faces on the plane.
|
||||
let r = plane(&mut s, "X", 16.5);
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
let design = s.design.clone().unwrap();
|
||||
for cell in ["Air/a", "Air/b"] {
|
||||
let shape = design.body(cell).unwrap();
|
||||
let cut: Vec<f64> = shape
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(shape.face_surfaces().unwrap())
|
||||
.filter(|(f, _)| f.name.starts_with("partition:PX:"))
|
||||
.filter_map(|(_, srf)| match srf {
|
||||
Some(fieldcraft_geometry::FaceSurface::Plane { point, .. }) => Some(point[0]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!cut.is_empty() && cut.iter().all(|x| (x - 16.5).abs() < 1e-9), "{cell}: {cut:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Along the side of the heat sink's pedestal: named.
|
||||
let r = plane(&mut s, "Side", 34.0);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PSide`: the plane lies on face `F45:-x` of `Air`"), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Through a standoff's hole: the cell whose boundary breaks at the hole's edge is named.
|
||||
let r = plane(&mut s, "Axis", 74.5);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PAxis`: cell `Air/") && warnings.contains("boundary no mesher takes"), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ use fieldcraft_model::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use fieldcraft_io::{Item, ItemKind};
|
||||
use fieldcraft_model::rename::Kind as RenameKind;
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -319,7 +322,7 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "buckle", "modes": 5, "shift"?: 0} | {"type": "heat_transfer", "transient"?: {"end_time": 10, "increment": 0.5, "theta": 1, "initial_temperature": 20}} (steady without `transient`) | {"type": "coupled_temperature_displacement"} (steady heat and the static equilibrium of its thermal strains) | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
form: Some(super::forms::STEP_CREATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
@@ -356,6 +359,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 +530,27 @@ pub(crate) mod tests {
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
/// A rename rewrites the references, checks clean, is undone in one step, follows the hidden
|
||||
/// state and refuses a name in use (#290).
|
||||
#[test]
|
||||
fn rename_keeps_the_model_whole_and_undoes() {
|
||||
let mut s = Session::default();
|
||||
defined_bar(&mut s);
|
||||
s.execute("view.hide", &json!({"items": [{"kind": "load", "name": "push"}]})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "set", "from": "end", "to": "Tip"})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "load", "from": "push", "to": "Push"})).unwrap();
|
||||
s.execute("model.rename", &json!({"kind": "step", "from": "Bend", "to": "Load"})).unwrap();
|
||||
assert!(s.model.sets().get("end").is_none() && !s.model.set_users("Tip").is_empty());
|
||||
assert!(s.model.step("Load").is_some_and(|st| st.loads.contains("Push")));
|
||||
assert!(s.view.is_hidden(fieldcraft_io::ItemKind::Load, "Push") && !s.view.is_hidden(fieldcraft_io::ItemKind::Load, "push"));
|
||||
assert_eq!(s.execute("model.check", &json!({})).unwrap()["ready"], true);
|
||||
let e = s.execute("model.rename", &json!({"kind": "set", "from": "Tip", "to": "root"})).unwrap_err().to_string();
|
||||
assert!(e.contains("already exists"), "{e}");
|
||||
assert!(s.execute("model.rename", &json!({"kind": "partition", "from": "a", "to": "b"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.model.step("Bend").is_some() && s.model.step("Load").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_defines_a_model_that_checks_clean() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,16 @@ use fieldcraft_io::roots::{Access, Roots};
|
||||
use super::{file_param, req};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
|
||||
|
||||
pub const NEW: CommandSpec = CommandSpec {
|
||||
id: "file.new",
|
||||
label: "New Model",
|
||||
params: r#"{} — starts an empty model in place of the current one: no design, mesh, definitions, results or journal; unsaved changes are discarded (the window asks first) and the undo history starts empty"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: new_model,
|
||||
};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
@@ -89,6 +99,15 @@ pub const SAMPLE: CommandSpec = CommandSpec {
|
||||
run: open_sample,
|
||||
};
|
||||
|
||||
fn new_model(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut empty = Session::default();
|
||||
// Nothing to save until a command changes it.
|
||||
empty.saved_revision = Some(empty.revision);
|
||||
empty.saved_view = empty.view.clone();
|
||||
s.replace_with(empty);
|
||||
Ok(summary(s, &[]))
|
||||
}
|
||||
|
||||
fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: &str = req(p, "name")?;
|
||||
let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::<Vec<_>>().join(", ");
|
||||
@@ -106,6 +125,7 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A sample just opened has nothing to save: it opens again the same way.
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
opened.saved_view = opened.view.clone();
|
||||
opened.sample = Some(sample.label);
|
||||
s.replace_with(opened);
|
||||
let mut reply = summary(s, &[]);
|
||||
reply["sample"] = json!(sample.name);
|
||||
@@ -374,6 +394,19 @@ mod tests {
|
||||
s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap();
|
||||
}
|
||||
|
||||
/// `file.new` empties the model, its journal and its undo history, and has nothing to save.
|
||||
#[test]
|
||||
fn new_starts_an_empty_model() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
s.execute("mesh.node.add", &json!({"label": 10_000, "xyz": [0, 0, 0]})).unwrap();
|
||||
let r = s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!((r["nodes"].as_u64(), r["elements"].as_u64(), r["journal"].as_u64()), (Some(0), Some(0), Some(0)), "{r}");
|
||||
assert!(s.model.materials().is_empty() && s.model.steps().is_empty() && s.design.is_none() && s.sample.is_none());
|
||||
assert!(!s.is_dirty(), "an empty model has nothing to save");
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err(), "the undo history starts empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_keep_the_model_journal_and_design() {
|
||||
let d = dir("roundtrip");
|
||||
|
||||
+194
-25
@@ -54,8 +54,10 @@ pub const SET: &[Field] = &[
|
||||
)
|
||||
.help("Bodies of the design, or their named faces, edges or vertices. Kept by name, so the set survives remeshing.")
|
||||
.hint("whole bodies, or their faces, edges or vertices"),
|
||||
json("nodes", "Nodes").help("Mesh node labels, as a JSON list.").hint("[labels]"),
|
||||
json("elements", "Elements").help("Mesh element labels, as a JSON list.").hint("[labels]"),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Mesh node labels; Use selection takes the picked nodes.").hint("labels: 1, 2, 10-20"),
|
||||
labels("elements", "Elements", LabelOf::Element)
|
||||
.help("Mesh element labels; Use selection takes the picked elements.")
|
||||
.hint("labels: 1, 2, 10-20"),
|
||||
json("faces", "Element faces").help("Element faces, as a JSON list of element label and local face number.").hint("[{\"element\", \"face\"}]"),
|
||||
json("edges", "Element edges").help("Element edges, as a JSON list of element label and local edge number.").hint("[{\"element\", \"edge\"}]"),
|
||||
];
|
||||
@@ -351,9 +353,9 @@ pub const REFERENCE_POINT: &[Field] = &[
|
||||
int("label", "Node label", 0, u32::MAX as i64).help("The node label written to decks.").hint("0: the highest free label"),
|
||||
];
|
||||
|
||||
const DOFS: Field = json("dofs", "Degrees of freedom")
|
||||
const DOFS: Field = multi("dofs", "Degrees of freedom", &[("1", "UX"), ("2", "UY"), ("3", "UZ"), ("4", "URX"), ("5", "URY"), ("6", "URZ")])
|
||||
.help("Which of the six DOFs to couple (1–3 translations, 4–6 rotations); all when empty.")
|
||||
.hint("[1, …, 6], default all");
|
||||
.hint("all");
|
||||
|
||||
pub const COUPLING: &[Field] = &[
|
||||
NAME,
|
||||
@@ -809,9 +811,20 @@ const PROCEDURE: Field = variants(
|
||||
)
|
||||
.help("What the step solves: linear or nonlinear static, buckling, frequencies, explicit dynamics, heat transfer or flow.");
|
||||
|
||||
const OUTPUT: Field = json("output", "Output")
|
||||
.help("Which results to keep, as JSON; by procedure when empty.")
|
||||
.hint("[\"displacement\", \"stress\", …]; by procedure when empty");
|
||||
const OUTPUT: Field = multi(
|
||||
"output",
|
||||
"Output",
|
||||
&[
|
||||
("\"displacement\"", "Displacement"),
|
||||
("\"reaction_force\"", "Reaction force"),
|
||||
("\"stress\"", "Stress"),
|
||||
("\"strain\"", "Strain"),
|
||||
("\"temperature\"", "Temperature"),
|
||||
("\"heat_flux\"", "Heat flux"),
|
||||
],
|
||||
)
|
||||
.help("Which results to keep; by procedure when none is ticked.")
|
||||
.hint("by procedure");
|
||||
const STEP_BCS: Field =
|
||||
references("boundary_conditions", "Boundary conditions", C::BoundaryCondition).help("The boundary conditions active in the step.");
|
||||
const STEP_LOADS: Field = references("loads", "Loads", C::Load).help("The loads active in the step.");
|
||||
@@ -834,6 +847,43 @@ pub const STEP_ACTIVATE: &[Field] = &[
|
||||
flag("active", "Active").help("On: they act in the step. Off: they are switched off from it on.").def("true"),
|
||||
];
|
||||
|
||||
pub const DATUM_SUPPRESS: &[Field] = &[
|
||||
reference("name", "Datum", C::Datum).help("The datum to keep but leave out of the model.").req(),
|
||||
flag("suppressed", "Suppressed").help("On: left out (its partitions stop cutting). Off: back in the model.").def("true"),
|
||||
];
|
||||
|
||||
pub const PARTITION_SUPPRESS: &[Field] = &[
|
||||
reference("name", "Partition", C::Partition).help("The partition to keep but leave out: its cells merge back.").req(),
|
||||
flag("suppressed", "Suppressed").help("On: the design is cut without it. Off: it cuts again.").def("true"),
|
||||
];
|
||||
|
||||
pub const RENAME: &[Field] = &[
|
||||
choice(
|
||||
"kind",
|
||||
"Kind",
|
||||
&[
|
||||
("set", "Set"),
|
||||
("material", "Material"),
|
||||
("section", "Section"),
|
||||
("step", "Step"),
|
||||
("boundary_condition", "Boundary condition"),
|
||||
("load", "Load"),
|
||||
("reference_point", "Reference point"),
|
||||
("coupling", "Coupling"),
|
||||
("contact", "Contact"),
|
||||
("tie", "Tie"),
|
||||
("point_mass", "Point mass"),
|
||||
("spring", "Spring"),
|
||||
("fluid_domain", "Fluid domain"),
|
||||
("datum", "Datum"),
|
||||
],
|
||||
)
|
||||
.help("What kind of definition to rename.")
|
||||
.req(),
|
||||
text("from", "Name").help("Its current name.").req(),
|
||||
text("to", "New name").help("Every definition that refers to it is changed to the new name.").req(),
|
||||
];
|
||||
|
||||
pub const UNITS: &[Field] = &[choice(
|
||||
"system",
|
||||
"Unit system",
|
||||
@@ -1050,10 +1100,22 @@ pub const HEX_BLOCKS: &[Field] = &[
|
||||
FEATURE_ANGLE,
|
||||
];
|
||||
|
||||
const CELL_METHODS: &[(&str, &str)] = &[
|
||||
("auto", "Auto (hex, else tet)"),
|
||||
("hex", "Hex"),
|
||||
("swept", "Swept"),
|
||||
("extrude", "Extrude"),
|
||||
("sweep", "Sweep"),
|
||||
("revolve", "Revolve"),
|
||||
("mapped", "Mapped"),
|
||||
("tet", "Tet"),
|
||||
];
|
||||
|
||||
pub const CELLS: &[Field] = &[
|
||||
json("methods", "Methods")
|
||||
.help("The meshing method per cell, as JSON.")
|
||||
.hint(r#"{"cell": "auto | hex | swept | extrude | sweep | revolve | mapped | tet"}"#),
|
||||
references("bodies", "Cells", C::Body)
|
||||
.help("The cells (bodies) to mesh: tick them, or pick them in the viewport (Select: Bodies) and Use selection. Every body of the design when none is ticked.")
|
||||
.hint("every body of the design"),
|
||||
ref_choices("methods", "Methods", C::Body, CELL_METHODS).help("A meshing method for single cells; the others take Other cells' method."),
|
||||
choice(
|
||||
"default",
|
||||
"Other cells",
|
||||
@@ -1062,12 +1124,15 @@ pub const CELLS: &[Field] = &[
|
||||
.help("The method for cells not listed in Methods. All hex meshes every cell as a mapped block, with the division counts of all cells assigned together so shared faces match (mesh.hex.blocks).")
|
||||
.notes(&[("blocks", "All hex: every cell must have six sides, each with four edges; leave Methods empty.")])
|
||||
.def("\"auto\""),
|
||||
references("bodies", "Cells", C::Body).help("The cells (bodies) to mesh; every body of the design when empty.").hint("every body of the design"),
|
||||
HEX_SIZE,
|
||||
HEX_ORDER,
|
||||
TOLERANCE,
|
||||
];
|
||||
|
||||
pub const CLEAR_MESH: &[Field] = &[references("bodies", "Bodies", C::Body)
|
||||
.help("The bodies whose meshes go. When none is ticked: the meshes of bodies the open design no longer has because its partitions changed (model.check lists them).")
|
||||
.hint("the bodies partitions removed")];
|
||||
|
||||
pub const MIRROR: &[Field] = &[
|
||||
json("plane", "Plane")
|
||||
.help("The mirror plane: a datum plane's name, or a point and normal as JSON.")
|
||||
@@ -1120,7 +1185,7 @@ pub const BLOCK: &[Field] = &[
|
||||
.help("The element type of the block.")
|
||||
.req()
|
||||
.def("\"hex8\""),
|
||||
json("corners", "Corners").help("The eight corner points (four for 2D), as JSON.").req().hint("[[x, y, z] × 8] (2D: × 4 at z = 0)"),
|
||||
points("corners", "Corners", D::Length, 8).help("The eight corner points (four for 2D, at z = 0).").req(),
|
||||
json("divisions", "Divisions").help("Elements along each edge direction, as JSON.").req().hint("[n12, n14, n15] (2D: [n12, n14])"),
|
||||
json("bias", "Bias").help("Element size ratio along each direction, as JSON; 1 is uniform.").hint("[1, 1, 1]"),
|
||||
json("edges", "Curved and named edges").help("Edge curves and names, as JSON."),
|
||||
@@ -1145,7 +1210,7 @@ pub const PLANAR: &[Field] = &[
|
||||
];
|
||||
|
||||
pub const QUALITY: &[Field] = &[
|
||||
json("elements", "Elements").help("The elements to measure, as JSON labels; all when empty.").hint("[labels]; all when empty"),
|
||||
labels("elements", "Elements", LabelOf::Element).help("The elements to measure; all when empty.").hint("all"),
|
||||
int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"),
|
||||
];
|
||||
|
||||
@@ -1185,7 +1250,52 @@ pub const ELEMENT_ADD: &[Field] = &[
|
||||
.help("The element type.")
|
||||
.req()
|
||||
.def("\"hex8\""),
|
||||
json("nodes", "Nodes").help("The element's node labels in order, as JSON.").req().hint("[labels]"),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("The element's node labels in order.").req().hint("labels in order: 1, 2, 3, 4"),
|
||||
];
|
||||
|
||||
const MERGE_TOLERANCE: Field =
|
||||
positive("tolerance", "Tolerance", D::Length).help("Nodes closer than this are one node.").hint("a millionth of the mesh's size");
|
||||
|
||||
pub const NODE_MERGE: &[Field] = &[
|
||||
MERGE_TOLERANCE,
|
||||
reference("set", "Set", C::Set).help("Merge only the nodes of this set (and of its elements and faces).").hint("every node"),
|
||||
choice("keep", "Keep", &[("lowest", "Lowest label"), ("highest", "Highest label")]).help("Which node of each group survives.").def("\"lowest\""),
|
||||
];
|
||||
|
||||
pub const CHECK_DUPLICATES: &[Field] = &[MERGE_TOLERANCE];
|
||||
|
||||
pub const NODE_RELAX: &[Field] = &[
|
||||
xyz("center", "Centre", D::Length).help("The centre of the sphere of influence; click the mesh to place it.").hint("click the mesh"),
|
||||
positive("radius", "Radius", D::Length).help("Nodes closer to the centre than this move; Alt + scroll over the view changes it."),
|
||||
int("around_element", "Around element", 1, 99_999_999)
|
||||
.help("Instead of a centre: a sphere round this element (radius default twice its longest edge).")
|
||||
.hint("none"),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Instead of a sphere: these nodes.").hint("none"),
|
||||
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("every body"),
|
||||
int("iterations", "Iterations", 1, 100).help("Sweeps over the nodes.").def("5"),
|
||||
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
|
||||
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
|
||||
.help("How the strength falls from the centre to the radius, so the edit blends into the mesh.")
|
||||
.def("\"smooth\""),
|
||||
text("field", "Keep as").help("The relaxation field to keep, to apply again after a remesh.").hint("Relax1, Relax2, …"),
|
||||
flag("reapply", "Reapply after remesh").help("Apply the field again by itself after each remesh.").def("false"),
|
||||
];
|
||||
|
||||
pub const NODE_RELAX_APPLY: &[Field] = &[
|
||||
text("field", "Field").req().help("The relaxation field kept by Relax Nodes."),
|
||||
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("the field's bodies"),
|
||||
range("polish", "Polish", D::None, 0.0, 1.0).help("Strength of one guarded smoothing sweep after the field is applied; 0 for none.").def("0.3"),
|
||||
];
|
||||
|
||||
pub const RENUMBER: &[Field] = &[
|
||||
choice("what", "Renumber", &[("both", "Nodes and elements"), ("nodes", "Nodes"), ("elements", "Elements")])
|
||||
.help("Which labels change.")
|
||||
.def("\"both\""),
|
||||
choice("by", "Order", &[("compact", "Compact (label order)"), ("bandwidth", "Bandwidth (reverse Cuthill–McKee)"), ("body", "Body by body")])
|
||||
.help("The order of the new labels.")
|
||||
.def("\"compact\""),
|
||||
int("start", "Start", 1, 99_999_999).help("The first new label.").def("1"),
|
||||
int("offset", "Offset", -99_999_999, 99_999_999).help("Instead of an order and a start: add this to every label.").hint("none"),
|
||||
];
|
||||
|
||||
pub const SEED_EDGE: &[Field] = &[
|
||||
@@ -1214,7 +1324,7 @@ pub const SEED_EDGE: &[Field] = &[
|
||||
|
||||
pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")];
|
||||
|
||||
pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").help("The points to triangulate, as JSON.").req().hint("[[x, y, z], …]")];
|
||||
pub const DELAUNAY_POINTS: &[Field] = &[points("points", "Points", D::Length, 20_000).help("The points to triangulate.").req()];
|
||||
|
||||
pub const TAG: &[Field] = &[
|
||||
group(
|
||||
@@ -1229,10 +1339,10 @@ pub const TAG: &[Field] = &[
|
||||
)
|
||||
.help("The geometry the mesh entities are tagged with.")
|
||||
.req(),
|
||||
json("elements", "Elements").help("Element labels, as JSON."),
|
||||
labels("elements", "Elements", LabelOf::Element).help("Element labels."),
|
||||
json("faces", "Element faces").help("Element faces, as JSON."),
|
||||
json("edges", "Element edges").help("Element edges, as JSON."),
|
||||
json("nodes", "Nodes").help("Node labels, as JSON."),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Node labels."),
|
||||
];
|
||||
|
||||
// ---- Geometry and files ----
|
||||
@@ -1346,6 +1456,33 @@ pub const DATUM_AXIS: &[Field] = &[
|
||||
.req()
|
||||
.def(r#"{"type": "principal"}"#),
|
||||
];
|
||||
pub const RIGID_WALL: &[Field] = &[
|
||||
reference("step", "Step", C::Step)
|
||||
.help("The explicit dynamics step the wall acts in; the model's only one when empty.")
|
||||
.hint("the explicit step"),
|
||||
reference("set", "Set", C::Set).help("The nodes the wall stops: pick them and Use selection.").req(),
|
||||
xyz("point", "Point", D::Length).help("A point on the wall.").req().def("[0, 0, 0]"),
|
||||
xyz("normal", "Normal", D::None).help("The wall's normal, pointing to the side the nodes stay on.").req().def("[0, 0, 1]"),
|
||||
FRICTION,
|
||||
];
|
||||
pub const RIGID_WALL_DELETE: &[Field] = &[
|
||||
reference("step", "Step", C::Step).help("The explicit dynamics step.").req(),
|
||||
int("index", "Wall", 0, MAX_ROWS as i64).help("The wall's place in the step's rigid walls, from 0.").req(),
|
||||
];
|
||||
pub const CENTRE: &[Field] = &[
|
||||
rows(
|
||||
"geometry",
|
||||
"Geometry",
|
||||
&[
|
||||
reference("body", "Body", C::Body).help("A body of the open design.").req(),
|
||||
text("face", "Face").help("A persistent face name of the body."),
|
||||
text("edge", "Edge").help("A persistent edge name of the body."),
|
||||
text("vertex", "Vertex").help("A persistent vertex name of the body."),
|
||||
],
|
||||
)
|
||||
.help("Faces or edges (a hole's faces or rim give its centre), or one vertex."),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Mesh node labels: their mean.").hint("labels: 1, 2, 10-20"),
|
||||
];
|
||||
pub const DATUM_POINT: &[Field] = &[
|
||||
NAME,
|
||||
variants(
|
||||
@@ -1355,10 +1492,12 @@ pub const DATUM_POINT: &[Field] = &[
|
||||
variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]),
|
||||
variant(
|
||||
"vertex",
|
||||
"Mesh vertex",
|
||||
"Vertex",
|
||||
&[
|
||||
reference("body", "Body", C::Body).help("The body the vertex belongs to.").req(),
|
||||
text("vertex", "Vertex").help("The vertex's name, as the mesh tags it.").req(),
|
||||
text("vertex", "Vertex")
|
||||
.help("The vertex's persistent name: pick it with Select: Vertices and Use selection (geometry.bodies lists every body's vertices).")
|
||||
.req(),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
@@ -1441,6 +1580,11 @@ pub const CRASH_REPORT: &[Field] = &[
|
||||
text("id", "Report").help("The crash report to show.").req(),
|
||||
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
|
||||
];
|
||||
pub const CRASH_REPORTS_DELETE: &[Field] = &[
|
||||
text("id", "Report").help("The crash report to delete.").hint("or All"),
|
||||
flag("all", "All").help("Delete every crash report instead of one."),
|
||||
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
|
||||
];
|
||||
pub const BUG_REPORT: &[Field] = &[
|
||||
text("title", "Title").help("The issue's title; one is made up when empty."),
|
||||
text("observed", "What happened").help("What went wrong; the last error when empty."),
|
||||
@@ -1466,6 +1610,7 @@ pub const SAMPLE: &[Field] = &[choice(
|
||||
("cantilever", "Cantilever"),
|
||||
("impact", "Impact"),
|
||||
("parts", "Parts"),
|
||||
("ibeam", "I-beam cantilever"),
|
||||
("drop-test", "Drop test"),
|
||||
("rubber-seal", "Rubber seal"),
|
||||
("flow-past", "Flow past a bracket"),
|
||||
@@ -1494,8 +1639,9 @@ pub const DECK: &[Field] = &[
|
||||
const STEP: Field = reference("step", "Step", C::Step).help("The step to solve; the only one when empty.").hint("the only one");
|
||||
const SOLVED: Field = reference("step", "Step", C::SolvedStep).help("The solved step; the step shown when empty.").hint("the step shown");
|
||||
const MAX_DOFS: Field = int("max_dofs", "Max DOFs", 1, 6_000_000).help("Cap on the degrees of freedom solved.").hint("6000000");
|
||||
const MAX_FACTOR: Field =
|
||||
int("max_factor_entries", "Max factor entries", 1, 400_000_000).help("Cap on the factorised matrix size, which bounds memory.").hint("400000000");
|
||||
const MAX_FACTOR: Field = int("max_factor_entries", "Max factor entries", 1, super::solve::FACTOR_ENTRY_CEILING as i64)
|
||||
.help("Cap on factor entries (8 bytes each): 400M by default; raise deliberately up to 2B (16 GB of values alone). Indices, workspace and other solver storage need additional memory.")
|
||||
.hint("400000000");
|
||||
const THREADS: Field = int("threads", "Threads", 1, 256).help("Threads to use; the session's setting when empty.").hint("the session's");
|
||||
|
||||
pub const SOLVE_STATIC: &[Field] = &[STEP, MAX_DOFS, MAX_FACTOR, THREADS];
|
||||
@@ -1538,13 +1684,13 @@ pub const SOLVE_FLUID: &[Field] = &[
|
||||
];
|
||||
pub const SOLVE_PROBE: &[Field] = &[
|
||||
SOLVED.req(),
|
||||
json("nodes", "Nodes").help("Node labels to read, as JSON.").hint("[labels]"),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Node labels to read.").hint("labels: 1, 2, 10-20"),
|
||||
reference("set", "Set", C::Set).help("A set whose nodes to read."),
|
||||
json("at", "Points").help("Points to read at the nearest nodes, as JSON.").hint("[[x, y, z], …]: nearest nodes"),
|
||||
points("at", "Points", D::Length, 10_000).help("Points to read at the nearest nodes."),
|
||||
int("mode", "Mode", 1, 1_000_000).help("The mode to read, for frequency and buckling steps."),
|
||||
int("frame", "Frame", 0, 1_000_000_000).help("The frame to read, for explicit and transient steps."),
|
||||
flag("elements", "Element results").help("Also read element results (stresses) around the nodes."),
|
||||
json("points", "Fluid points").help("Points in the fluid to read, as JSON.").hint("[[x, y, z], …]"),
|
||||
points("points", "Fluid points", D::Length, 10_000).help("Points in the fluid to read."),
|
||||
json("forces", "Fluid walls").help("Walls whose fluid forces to sum, as JSON.").hint("[\"x_min\", \"F2:top\"]"),
|
||||
];
|
||||
|
||||
@@ -1553,7 +1699,7 @@ pub const SOLVE_PROBE: &[Field] = &[
|
||||
pub const DEFORMED: &[Field] = &[
|
||||
SOLVED,
|
||||
flag("show", "Show").help("Show the deformed shape.").def("true"),
|
||||
json("scale", "Scale").help("Displacement magnification: auto, or a factor (1 is true scale).").def("\"auto\"").hint("\"auto\" or a factor"),
|
||||
number_or_auto("scale", "Scale", D::None, Some(0.0)).help("Displacement magnification: auto, or a factor (1 is true scale).").def("\"auto\""),
|
||||
];
|
||||
pub const CONTOUR: &[Field] = &[
|
||||
SOLVED,
|
||||
@@ -1587,6 +1733,15 @@ pub const CONTOUR: &[Field] = &[
|
||||
.req()
|
||||
.def("\"von_mises\""),
|
||||
int("bands", "Bands", 2, 24).help("Number of colour bands.").def("12"),
|
||||
group(
|
||||
"range",
|
||||
"Legend range",
|
||||
&[
|
||||
num("min", "Min", D::None).help("Values below it take the lowest colour.").hint("the field's minimum"),
|
||||
num("max", "Max", D::None).help("Values above it are drawn in the over colour.").hint("the field's maximum"),
|
||||
],
|
||||
)
|
||||
.help("Clamp the legend, e.g. below a singular peak; empty follows the field."),
|
||||
];
|
||||
pub const MODES: &[Field] = &[SOLVED, int("mode", "Mode", 1, 1_000_000).help("The mode shape shown.").def("1")];
|
||||
pub const ANIMATE: &[Field] = &[flag("on", "Animate").help("Play the deformation or the frames.").def("true")];
|
||||
@@ -1595,6 +1750,18 @@ pub const FRAME: &[Field] = &[
|
||||
int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."),
|
||||
positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"),
|
||||
];
|
||||
pub const EXPORT_CSV: &[Field] = &[
|
||||
choice("what", "Export", &[("reactions", "Reaction sums"), ("probe", "Probed nodes")]).help("What to write.").req().def("\"reactions\""),
|
||||
path("path", "File", true).help("The CSV file to write.").req(),
|
||||
SOLVED,
|
||||
references("sets", "Sets", C::Set).help("Reactions: the sets to sum; the step's BC sets when empty.").hint("the step's BC sets"),
|
||||
xyz("about", "About point", D::Length).help("Reactions: the point moments are taken about."),
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("Probe: the node labels."),
|
||||
reference("set", "Set", C::Set).help("Probe: a set whose nodes to read."),
|
||||
points("at", "Points", D::Length, 10_000).help("Probe: points to read at the nearest nodes."),
|
||||
int("mode", "Mode", 1, 1_000_000).help("Probe: the mode to read, for frequency and buckling steps."),
|
||||
int("frame", "Frame", 0, 1_000_000_000).help("Probe: the frame to read, for explicit and transient steps."),
|
||||
];
|
||||
pub const REACTIONS: &[Field] = &[
|
||||
SOLVED,
|
||||
references("sets", "Sets", C::Set)
|
||||
@@ -1720,9 +1887,11 @@ mod tests {
|
||||
"`boundary_conditions` or `loads`",
|
||||
"give one of",
|
||||
"is not an explicit",
|
||||
"has no explicit dynamics step",
|
||||
"has a result",
|
||||
"no crash report",
|
||||
"FIELDCRAFT_CRASH_TEST",
|
||||
"no relaxation field",
|
||||
];
|
||||
|
||||
/// Fill top-level required names (a fresh one) and references (the first existing item).
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) `sets` (sets naming a body or face it lacks) and `seeds` (edge seeds on edges it lacks). One undo step"#,
|
||||
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) `sets` (sets naming a body or face it lacks) and `seeds` (edge seeds on edges it lacks). `errors` lists the features that failed to rebuild with the `bodies` each may change, and `warnings` says so in one line each. One undo step"#,
|
||||
form: Some(super::forms::OPEN_DESIGN),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
@@ -26,13 +26,23 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length)",
|
||||
params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length) and vertices (persistent name `<face>|<face>|<face>` after the faces that meet there, `~k` when several touch the same faces, and point)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[], // No design is an answer too: no bodies.
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
pub const CENTRE: CommandSpec = CommandSpec {
|
||||
id: "geometry.centre",
|
||||
label: "Centre of Selection",
|
||||
params: r#"{"geometry": [{"body": "Body1", "face": "F3:hole"} | {"body", "edge"} | {"body", "vertex"}], "nodes": [labels]} — the point a reference point or datum point goes at for what is picked: a vertex's point; the mean of nodes; the centre of a hole (cylindrical faces on one axis, or circular edges on one circle: the axis point at their mid-depth, the circle's centre); otherwise the area centroid of the faces and the length centroid of the edges. Replies `point` and `from` (`vertex`, `nodes`, `circle`, `centroid`)"#,
|
||||
form: Some(super::forms::CENTRE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: centre,
|
||||
};
|
||||
|
||||
pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
@@ -86,7 +96,9 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message, "bodies": e.bodies})).collect::<Vec<_>>(),
|
||||
// Shown with the open: a body that differs from the design is never silent.
|
||||
"warnings": design.errors.iter().map(|e| e.warning()).collect::<Vec<_>>(),
|
||||
"model": if new_model { "new" } else { "kept" },
|
||||
"related": fit.related,
|
||||
"stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets, "seeds": fit.seeds},
|
||||
@@ -143,6 +155,7 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
for b in &design.bodies {
|
||||
let m = b.measure()?;
|
||||
let edges: Vec<Value> = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect();
|
||||
let vertices: Vec<Value> = b.vertices()?.iter().map(|v| json!({"name": v.name, "point": v.point})).collect();
|
||||
out.push(json!({
|
||||
"name": b.name,
|
||||
"volume": m.volume,
|
||||
@@ -151,11 +164,193 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"bbox": [m.bbox_min, m.bbox_max],
|
||||
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
|
||||
"edges": edges,
|
||||
"vertices": vertices,
|
||||
}));
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
/// Most geometry items and nodes `geometry.centre` takes.
|
||||
const MAX_CENTRE_ITEMS: usize = 10_000;
|
||||
|
||||
fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
|
||||
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn scale3(a: [f64; 3], k: f64) -> [f64; 3] {
|
||||
a.map(|v| v * k)
|
||||
}
|
||||
|
||||
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// The centre of the circle through three points, `None` when they lie on a line.
|
||||
fn circumcentre(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let (u, v) = (sub3(b, a), sub3(c, a));
|
||||
let n = cross3(u, v);
|
||||
let nn = dot3(n, n);
|
||||
if !(nn > 1e-24 * dot3(u, u) * dot3(v, v)) {
|
||||
return None;
|
||||
}
|
||||
let k = add3(scale3(cross3(n, u), dot3(v, v)), scale3(cross3(v, n), dot3(u, u)));
|
||||
Some(add3(a, scale3(k, 0.5 / nn)))
|
||||
}
|
||||
|
||||
/// The centre of an edge's polyline when it is an arc or circle (every point at one distance
|
||||
/// from it, within a thousandth), else `None`.
|
||||
fn circle_centre(points: &[[f64; 3]]) -> Option<[f64; 3]> {
|
||||
let n = points.len();
|
||||
if n < 3 {
|
||||
return None;
|
||||
}
|
||||
let closed = points.first().zip(points.last()).is_some_and(|(a, b)| dot3(sub3(*a, *b), sub3(*a, *b)) < 1e-18);
|
||||
let pick = |f: f64| points.get(((n - 1) as f64 * f).round() as usize).copied();
|
||||
let (a, b, c) = if closed { (pick(0.0)?, pick(1.0 / 3.0)?, pick(2.0 / 3.0)?) } else { (pick(0.0)?, pick(0.5)?, pick(1.0)?) };
|
||||
let centre = circumcentre(a, b, c)?;
|
||||
let r = dot3(sub3(a, centre), sub3(a, centre)).sqrt();
|
||||
let on = points.iter().all(|p| (dot3(sub3(*p, centre), sub3(*p, centre)).sqrt() - r).abs() <= 1e-3 * r);
|
||||
on.then_some(centre)
|
||||
}
|
||||
|
||||
/// The length-weighted centre of a polyline and its length.
|
||||
fn polyline_centroid(points: &[[f64; 3]]) -> ([f64; 3], f64) {
|
||||
let (mut sum, mut length) = ([0.0; 3], 0.0);
|
||||
for w in points.windows(2) {
|
||||
let l = dot3(sub3(w[1], w[0]), sub3(w[1], w[0])).sqrt();
|
||||
sum = add3(sum, scale3(add3(w[0], w[1]), 0.5 * l));
|
||||
length += l;
|
||||
}
|
||||
(if length > 0.0 { scale3(sum, 1.0 / length) } else { points.first().copied().unwrap_or_default() }, length)
|
||||
}
|
||||
|
||||
fn centre(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let geometry: Vec<GeometryRef> = super::opt(p, "geometry")?.unwrap_or_default();
|
||||
let nodes: Vec<u32> = super::opt(p, "nodes")?.unwrap_or_default();
|
||||
if geometry.len() + nodes.len() > MAX_CENTRE_ITEMS {
|
||||
return Err(EngineError::Params(format!("geometry.centre takes at most {MAX_CENTRE_ITEMS} items")));
|
||||
}
|
||||
if geometry.is_empty() && nodes.is_empty() {
|
||||
return Err(EngineError::Params("give one of `geometry` or `nodes`: what to find the centre of".into()));
|
||||
}
|
||||
if !geometry.is_empty() && !nodes.is_empty() {
|
||||
return Err(EngineError::Params("give either `geometry` or `nodes`, not both".into()));
|
||||
}
|
||||
if !nodes.is_empty() {
|
||||
let mut sum = [0.0; 3];
|
||||
for n in &nodes {
|
||||
let node = s.model.node(*n).ok_or_else(|| EngineError::Params(format!("no node {n}")))?;
|
||||
sum = add3(sum, node.xyz);
|
||||
}
|
||||
return Ok(json!({"point": scale3(sum, 1.0 / nodes.len() as f64), "from": "nodes"}));
|
||||
}
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let shape = |b: &str| design.body(b).ok_or_else(|| EngineError::Params(format!("the design has no body `{b}`")));
|
||||
if let [g] = geometry.as_slice()
|
||||
&& let Entity::Vertex(v) = &g.entity
|
||||
{
|
||||
let vertex = shape(&g.body)?.vertices()?.into_iter().find(|x| &x.name == v);
|
||||
let vertex = vertex.ok_or_else(|| EngineError::Params(format!("body `{}` has no vertex `{v}`", g.body)))?;
|
||||
return Ok(json!({"point": vertex.point, "from": "vertex"}));
|
||||
}
|
||||
// Hole centres: every face a cylinder about one axis, or every edge an arc of one circle.
|
||||
let mut circles: Vec<[f64; 3]> = Vec::new();
|
||||
let mut axis: Option<([f64; 3], [f64; 3])> = None;
|
||||
let mut round = true;
|
||||
// Centroids, weighted by area (faces) or length (edges).
|
||||
let (mut faces_sum, mut faces_area, mut edges_sum, mut edges_length) = ([0.0; 3], 0.0, [0.0; 3], 0.0);
|
||||
for g in &geometry {
|
||||
let body = shape(&g.body)?;
|
||||
match &g.entity {
|
||||
Entity::Face(f) => {
|
||||
let i = body.face_index(f).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{f}`", g.body)))?;
|
||||
let c = body.face_centre(f)?;
|
||||
let mesh = body.tessellate((body.size()? * fieldcraft_geometry::EDGE_TOLERANCE).max(1e-4))?;
|
||||
let area: f64 = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, t)| **t == i)
|
||||
.filter_map(|(t, _)| {
|
||||
let q = |k: usize| mesh.positions.get(t[k] as usize).copied();
|
||||
let (a, b, c) = (q(0)?, q(1)?, q(2)?);
|
||||
let n = cross3(sub3(b, a), sub3(c, a));
|
||||
Some(0.5 * dot3(n, n).sqrt())
|
||||
})
|
||||
.sum();
|
||||
faces_sum = add3(faces_sum, scale3(c, area));
|
||||
faces_area += area;
|
||||
match body.face_surfaces()?.get(i as usize).copied().flatten() {
|
||||
Some(fieldcraft_geometry::FaceSurface::Cylinder { origin, axis: d, .. }) => {
|
||||
let len = dot3(d, d).sqrt();
|
||||
let d = scale3(d, 1.0 / len.max(1e-300));
|
||||
// The face centre's foot on the axis.
|
||||
let foot = add3(origin, scale3(d, dot3(sub3(c, origin), d)));
|
||||
let same = axis.is_none_or(|(o, a)| {
|
||||
let off = sub3(foot, o);
|
||||
let across = sub3(off, scale3(a, dot3(off, a)));
|
||||
dot3(cross3(a, d), cross3(a, d)) < 1e-12 && dot3(across, across).sqrt() <= 1e-6 * body.size().unwrap_or(1.0).max(1e-9)
|
||||
});
|
||||
round &= same;
|
||||
axis.get_or_insert((foot, d));
|
||||
circles.push(foot);
|
||||
}
|
||||
_ => round = false,
|
||||
}
|
||||
}
|
||||
Entity::Edge(e) => {
|
||||
let edges = body.edges()?;
|
||||
let edge = edges.iter().find(|x| &x.name == e).ok_or_else(|| EngineError::Params(format!("body `{}` has no edge `{e}`", g.body)))?;
|
||||
let (c, l) = polyline_centroid(&edge.points);
|
||||
edges_sum = add3(edges_sum, scale3(c, l));
|
||||
edges_length += l;
|
||||
match circle_centre(&edge.points) {
|
||||
Some(c) => circles.push(c),
|
||||
None => round = false,
|
||||
}
|
||||
}
|
||||
Entity::Vertex(_) | Entity::Body => {
|
||||
return Err(EngineError::Params("geometry.centre takes one vertex, or faces and edges".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arcs of one circle share a centre; faces of one axis were checked as they came.
|
||||
let spread = circles.iter().map(|c| circles.first().map_or(0.0, |f| dot3(sub3(*c, *f), sub3(*c, *f)).sqrt())).fold(0.0f64, f64::max);
|
||||
let size = geometry.first().and_then(|g| design.body(&g.body)).and_then(|b| b.size().ok()).unwrap_or(1.0).max(1e-9);
|
||||
let edges_only = geometry.iter().all(|g| matches!(g.entity, Entity::Edge(_)));
|
||||
if round && !circles.is_empty() && (!edges_only || spread <= 1e-6 * size) {
|
||||
let point = match axis {
|
||||
// Cylinder faces: the axis point at the mean of their centres' feet.
|
||||
Some((_, d)) => {
|
||||
let mean = scale3(circles.iter().fold([0.0; 3], |a, c| add3(a, *c)), 1.0 / circles.len() as f64);
|
||||
let o = circles.first().copied().unwrap_or(mean);
|
||||
add3(o, scale3(d, dot3(sub3(mean, o), d)))
|
||||
}
|
||||
None => circles.first().copied().unwrap_or_default(),
|
||||
};
|
||||
return Ok(json!({"point": point, "from": "circle"}));
|
||||
}
|
||||
let point = if faces_area > 0.0 {
|
||||
scale3(faces_sum, 1.0 / faces_area)
|
||||
} else if edges_length > 0.0 {
|
||||
scale3(edges_sum, 1.0 / edges_length)
|
||||
} else {
|
||||
return Err(EngineError::Params("what is picked has no area or length to find a centre of".into()));
|
||||
};
|
||||
if !point.iter().all(|v| v.is_finite()) {
|
||||
return Err(EngineError::Params("the centre is not a finite point".into()));
|
||||
}
|
||||
Ok(json!({"point": point, "from": "centroid"}))
|
||||
}
|
||||
|
||||
fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
|
||||
@@ -212,6 +407,73 @@ mod tests {
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The plate's hole (Ø10 at x = y = 15 through z 0..10): its faces give the axis point at
|
||||
/// mid-depth, a rim arc its circle's centre, a corner vertex its point, a flat face its
|
||||
/// centroid; datum points take the design's vertices by name.
|
||||
#[test]
|
||||
fn centres_of_holes_vertices_and_faces() {
|
||||
use fieldcraft_geometry::FaceSurface;
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
let design = Arc::new(open_design(&plate_design(&Plate::default()).unwrap()).unwrap());
|
||||
let mut s = Session { design: Some(design.clone()), ..Session::default() };
|
||||
let body = &design.bodies[0];
|
||||
let near = |v: &Value, p: [f64; 3]| {
|
||||
let q: [f64; 3] = serde_json::from_value(v["point"].clone()).unwrap();
|
||||
assert!((0..3).all(|k| (q[k] - p[k]).abs() < 1e-6), "{v} vs {p:?}");
|
||||
};
|
||||
let surfaces = body.face_surfaces().unwrap();
|
||||
let hole: Vec<Value> = body
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(&surfaces)
|
||||
.filter(|(_, s)| matches!(s, Some(FaceSurface::Cylinder { .. })))
|
||||
.map(|(f, _)| json!({"body": body.name, "face": f.name}))
|
||||
.collect();
|
||||
assert_eq!(hole.len(), 2);
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": hole})).unwrap();
|
||||
near(&r, [15.0, 15.0, 5.0]);
|
||||
assert_eq!(r["from"], "circle");
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [hole[0]]})).unwrap();
|
||||
near(&r, [15.0, 15.0, 5.0]);
|
||||
// A rim arc on top.
|
||||
let edges = body.edges().unwrap();
|
||||
let arc = edges.iter().find(|e| e.points.iter().all(|p| (p[2] - 10.0).abs() < 1e-9) && e.points.len() > 3).unwrap();
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "edge": arc.name}]})).unwrap();
|
||||
near(&r, [15.0, 15.0, 10.0]);
|
||||
assert_eq!(r["from"], "circle");
|
||||
// The corner at the origin, and a datum point on it by name.
|
||||
let corner = body.vertices().unwrap().into_iter().find(|v| v.point == [0.0; 3]).unwrap();
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "vertex": corner.name}]})).unwrap();
|
||||
near(&r, [0.0; 3]);
|
||||
s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "vertex", "body": body.name, "vertex": corner.name}})).unwrap();
|
||||
assert_eq!(s.model.datum("P").and_then(|d| d.point()), Some([0.0; 3]));
|
||||
let listed = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
assert!(listed[0]["vertices"].as_array().unwrap().iter().any(|v| v["name"] == corner.name.as_str()), "{listed}");
|
||||
// The bottom face (z = 0): its centroid, off the hole.
|
||||
let bottom = body
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(&surfaces)
|
||||
.find(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, point }) if normal[2].abs() > 0.99 && point[2].abs() < 1e-9))
|
||||
.unwrap()
|
||||
.0;
|
||||
let r = s.execute("geometry.centre", &json!({"geometry": [{"body": body.name, "face": bottom.name}]})).unwrap();
|
||||
assert_eq!(r["from"], "centroid");
|
||||
// Nodes, and what is refused.
|
||||
s.model.set_node(1, [1.0, 2.0, 3.0]).unwrap();
|
||||
s.model.set_node(2, [3.0, 2.0, 1.0]).unwrap();
|
||||
near(&s.execute("geometry.centre", &json!({"nodes": [1, 2]})).unwrap(), [2.0, 2.0, 2.0]);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"nodes": [99]}),
|
||||
json!({"geometry": [{"body": "nope"}]}),
|
||||
json!({"geometry": [{"body": body.name}]}),
|
||||
json!({"nodes": "x"}),
|
||||
] {
|
||||
assert!(s.execute("geometry.centre", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_tagged_surfaces_and_failures_change_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-import-mesh-{}", std::process::id()));
|
||||
|
||||
@@ -220,27 +220,15 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
seeds: super::seeds::edge_seeds(s)?,
|
||||
..HexOptions::default()
|
||||
};
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| match e {
|
||||
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
|
||||
e => EngineError::Params(e.to_string()),
|
||||
})?;
|
||||
let m = &cm.mesh;
|
||||
// Replace the cells' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, inputs.iter().map(|b| b.name.as_str()));
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let mut body_tags = Vec::with_capacity(inputs.len());
|
||||
for b in &inputs {
|
||||
body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
|
||||
}
|
||||
for e in 0..m.element_count() {
|
||||
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, first_element)?;
|
||||
add_cells(model, &inputs, &cm)?;
|
||||
let blocks: Vec<Value> =
|
||||
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
|
||||
let reply = json!({
|
||||
@@ -349,7 +337,30 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool,
|
||||
seeds: b.seeds.clone(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::of_body(&b.name, e))
|
||||
}
|
||||
|
||||
/// Add a mesh of several cells (`inputs[c]` is cell `c`) after the model's largest labels, its
|
||||
/// nodes shared between the cells, tagging each element with its cell's body and the boundary
|
||||
/// element faces with the cell's B-rep faces.
|
||||
pub(super) fn add_cells(model: &mut Model, inputs: &[Input], cm: &fieldcraft_mesh::hex::CellsMesh) -> Result<()> {
|
||||
let m = &cm.mesh;
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let mut body_tags = Vec::with_capacity(inputs.len());
|
||||
for b in inputs {
|
||||
body_tags.push(model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?);
|
||||
}
|
||||
for e in 0..m.element_count() {
|
||||
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(model, inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, first_element)
|
||||
}
|
||||
|
||||
/// Add one body's hex mesh after the model's largest labels, tagging the elements with the body
|
||||
@@ -523,7 +534,7 @@ mod tests {
|
||||
let (_dir, mut s) = split_box(true);
|
||||
let before = s.model.clone();
|
||||
let e = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap_err().to_string();
|
||||
assert!(e.contains("do not conform") && e.contains("`Box`"), "{e}");
|
||||
assert!(e.contains("do not conform") && e.contains("`Box.1`"), "{e}");
|
||||
assert_eq!(s.model, before);
|
||||
for p in [
|
||||
json!({"size": 0}),
|
||||
|
||||
@@ -252,7 +252,9 @@ pub(super) fn mesh_body(
|
||||
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
|
||||
Some(_) => Vec::new(),
|
||||
};
|
||||
fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, "");
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
fieldcraft_model::progress::stage(&format!("{}: Delaunay", input.name), None, "");
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mesh = if quality {
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
|
||||
@@ -269,7 +271,7 @@ pub(super) fn mesh_body(
|
||||
surface_volume: surface.volume(),
|
||||
})
|
||||
};
|
||||
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
mesh().map_err(|e| if several && !e.is_cancelled() { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -894,6 +896,35 @@ mod tests {
|
||||
assert!(e.starts_with("body `Box"), "{e}");
|
||||
}
|
||||
|
||||
/// Persistent names grow with the design's split history: a tray floor split by joined ribs
|
||||
/// names every split, past the old 256-character cap. Such a design meshes, and a set on that
|
||||
/// face resolves by its full name, again after a remesh (#374).
|
||||
#[test]
|
||||
fn long_persistent_face_names_mesh_and_resolve() {
|
||||
let mut commands = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0], "body_name": "Case"}}),
|
||||
json!({"command": "solid.shell", "params": {"faces": [[20, 15, 20]], "thickness": 2, "direction": "inside", "body": "Case"}}),
|
||||
];
|
||||
// Ribs across the floor split it into strips, each named by every split before it.
|
||||
for x in (3..=36).step_by(3) {
|
||||
commands.push(json!({"command": "solid.box", "params": {"length": 1, "width": 26, "height": 5, "corner": [x, 2, 2], "operation": "join", "targets": ["Case"]}}));
|
||||
}
|
||||
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let long = bodies[0]["faces"].as_array().unwrap().iter().filter_map(|f| f["name"].as_str()).max_by_key(|n| n.len()).unwrap().to_string();
|
||||
assert!(long.len() > 256, "the fixture needs a face name past the old cap: {long}");
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Case"})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "floor", "geometry": [{"body": "Case", "face": long}]})).unwrap();
|
||||
let first = s.model.resolve_set("floor").unwrap();
|
||||
assert!(!first.faces.is_empty());
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Case", "tolerance": 0.5})).unwrap();
|
||||
let again = s.model.resolve_set("floor").unwrap();
|
||||
assert!(!again.faces.is_empty(), "the set follows the remesh by the full name");
|
||||
}
|
||||
|
||||
/// The quality report's one-line summary counts the inverted elements.
|
||||
#[test]
|
||||
fn quality_summary_counts_invalid_elements() {
|
||||
|
||||
@@ -42,11 +42,7 @@ fn not_a_plane() -> EngineError {
|
||||
/// The plane: a datum plane by name, or a point and a normal.
|
||||
fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> {
|
||||
match p.get("plane") {
|
||||
Some(Value::String(name)) => s
|
||||
.model
|
||||
.datum(name)
|
||||
.and_then(|d| d.plane())
|
||||
.ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", name.chars().take(64).collect::<String>()))),
|
||||
Some(Value::String(name)) => super::datum::datum_plane_of(&s.model, name),
|
||||
Some(v @ Value::Object(_)) => match (super::xyz(v, "point"), super::xyz(v, "normal")) {
|
||||
(Ok(point), Ok(normal)) => Ok((point, normal)),
|
||||
_ => Err(not_a_plane()),
|
||||
|
||||
@@ -15,10 +15,15 @@ mod hex;
|
||||
pub(crate) mod mesh;
|
||||
|
||||
pub use datum::edit_params as datum_edit_params;
|
||||
pub use datum::preview as datum_preview;
|
||||
pub use mesh::analytic_surfaces;
|
||||
pub use nodes::merge_preview as node_merge_preview;
|
||||
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
|
||||
mod mirror;
|
||||
mod model;
|
||||
pub(crate) mod nodes;
|
||||
pub(crate) mod octree;
|
||||
pub(crate) mod relax;
|
||||
pub mod replace;
|
||||
mod results;
|
||||
pub(crate) mod seeds;
|
||||
@@ -41,6 +46,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
model::INFO,
|
||||
geometry::OPEN,
|
||||
geometry::BODIES,
|
||||
geometry::CENTRE,
|
||||
geometry::TESSELLATE,
|
||||
geometry::IMPORT_MESH,
|
||||
datum::DATUM_PLANE,
|
||||
@@ -55,11 +61,14 @@ pub static ALL: &[CommandSpec] = &[
|
||||
view::ISOLATE,
|
||||
view::SHOW_ALL,
|
||||
datum::PARTITION,
|
||||
datum::DATUM_SUPPRESS,
|
||||
datum::PARTITION_SUPPRESS,
|
||||
datum::PARTITION_DELETE,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
mesh::ELEMENT_REMOVE,
|
||||
replace::CLEAR,
|
||||
mesh::QUALITY,
|
||||
mesh::DELAUNAY_POINTS,
|
||||
mesh::SURFACE_CHECK,
|
||||
@@ -76,6 +85,11 @@ pub static ALL: &[CommandSpec] = &[
|
||||
seeds::SEED_CLEAR,
|
||||
seeds::SEED_LIST,
|
||||
mirror::MIRROR,
|
||||
nodes::MERGE,
|
||||
nodes::DUPLICATES,
|
||||
nodes::RENUMBER,
|
||||
relax::RELAX,
|
||||
relax::APPLY,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
model::UNITS,
|
||||
@@ -106,6 +120,8 @@ pub static ALL: &[CommandSpec] = &[
|
||||
contact::TIE,
|
||||
contact::TIE_EDIT,
|
||||
contact::TIE_DELETE,
|
||||
contact::RIGID_WALL,
|
||||
contact::RIGID_WALL_DELETE,
|
||||
define::MASS_CREATE,
|
||||
define::MASS_EDIT,
|
||||
define::MASS_DELETE,
|
||||
@@ -127,7 +143,9 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::STEP_EDIT,
|
||||
define::STEP_DELETE,
|
||||
define::STEP_ACTIVATE,
|
||||
define::RENAME,
|
||||
define::ACTIVITY,
|
||||
file::NEW,
|
||||
file::OPEN,
|
||||
file::SAVE,
|
||||
file::SAVE_AS,
|
||||
@@ -154,9 +172,11 @@ pub static ALL: &[CommandSpec] = &[
|
||||
results::ANIMATE,
|
||||
results::FRAME,
|
||||
results::REACTIONS,
|
||||
results::EXPORT_CSV,
|
||||
results::CLEAR,
|
||||
diagnostics::CRASH_REPORTS,
|
||||
diagnostics::CRASH_REPORT,
|
||||
diagnostics::CRASH_REPORTS_DELETE,
|
||||
diagnostics::BUG_REPORT,
|
||||
diagnostics::CRASH_TEST,
|
||||
update::CHECK,
|
||||
|
||||
@@ -55,7 +55,21 @@ fn units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let diagnostics = s.model.check();
|
||||
let mut diagnostics = s.model.check();
|
||||
if let Some(design) = s.design.as_deref() {
|
||||
for (body, n) in super::replace::stale(&s.model, design) {
|
||||
diagnostics.push(fieldcraft_model::Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
subject: format!("body `{body}`"),
|
||||
message: format!(
|
||||
"{n} elements mesh a body the open design no longer has (its partitions changed since it was meshed); mesh.clear removes them"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(why) = s.mesh_outdated() {
|
||||
diagnostics.push(fieldcraft_model::Diagnostic { severity: Severity::Warning, subject: "mesh".into(), message: why });
|
||||
}
|
||||
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
|
||||
let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string);
|
||||
let progress = crate::progress::to_json(&s.progress_given(first_error));
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Node tools (#234): `mesh.node.merge` equivalences coincident nodes, `mesh.check.duplicates`
|
||||
//! finds what is not merged or does not join (coincident nodes, repeated elements, free faces and
|
||||
//! edges, cracks), and `mesh.renumber` gives nodes and elements new labels. Merging and
|
||||
//! renumbering move every reference with the labels (connectivity, sets, geometry tags, a step's
|
||||
//! displacement limit), so boundary conditions and loads, which act on sets, stay on the same
|
||||
//! entities; deck writers write the labels as they are. Both are one undo step; results solved
|
||||
//! before go stale, as after any mesh edit.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::nodes::{Keep, bandwidth, duplicates, merge_plan, reverse_cuthill_mckee};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{MAX_LABEL, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const MERGE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.merge",
|
||||
label: "Merge Nodes",
|
||||
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal), "set"?: "Name" (only the set's nodes and those of its elements and faces), "keep"?: "lowest" (default) | "highest"} — equivalences coincident nodes: every node within the distance of another becomes that one (the lowest or highest label of each group survives, keeping its position) in every element, set and geometry tag, and is removed. A merge that would put two nodes of one element together (collapsing it) is left out and listed under `refused`; the rest are made. Reports `merged`, the `pairs` [node, survivor] (the first 1000) and `refused`"#,
|
||||
form: Some(super::forms::NODE_MERGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: merge,
|
||||
};
|
||||
|
||||
pub const DUPLICATES: CommandSpec = CommandSpec {
|
||||
id: "mesh.check.duplicates",
|
||||
label: "Check Duplicates",
|
||||
params: r#"{"tolerance"?: mm (default a millionth of the mesh's bounding-box diagonal)} — checks the mesh for what was not merged or does not join, changing nothing: `coincident` nodes within the distance of another ([node, other, distance]), `duplicate_elements` (groups on the same nodes), `free_faces` (faces of solid elements no other element shares: the skin), `cracks` (free faces lying on another free face: parts that touch without sharing nodes) and `free_edges` (edges of the skin and of 2D elements on one facet only: open borders). Lists hold the first 1000; `counts` has the totals"#,
|
||||
form: Some(super::forms::CHECK_DUPLICATES),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: check_duplicates,
|
||||
};
|
||||
|
||||
pub const RENUMBER: CommandSpec = CommandSpec {
|
||||
id: "mesh.renumber",
|
||||
label: "Renumber",
|
||||
params: r#"{"what"?: "both" (default) | "nodes" | "elements", "by"?: "compact" (default: in label order) | "bandwidth" (nodes in reverse Cuthill–McKee order, elements by their lowest new node label) | "body" (body by body, by name; within a body in label order), "start"?: 1, "offset"?: n (instead of `by` and `start`: add n to every label)} — gives nodes and elements new labels from `start` up (or shifted by `offset`), every set, geometry tag and step following, so boundary conditions and loads stay on the same entities and decks write the new labels. Refused, changing nothing, when a label would leave 1..=99999999 or a node would take a reference point's label. Reports the counts renumbered, the label ranges and the bandwidth before and after"#,
|
||||
form: Some(super::forms::RENUMBER),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: renumber,
|
||||
};
|
||||
|
||||
/// Most items a reply lists.
|
||||
const MAX_LISTED: usize = 1000;
|
||||
|
||||
/// The merge distance: `tolerance`, or a millionth of the mesh's bounding-box diagonal.
|
||||
pub(crate) fn tolerance(model: &Model, p: &Value) -> Result<f64> {
|
||||
match p.get("tolerance") {
|
||||
Some(v) => v.as_f64().filter(|t| *t > 0.0 && t.is_finite()).ok_or_else(|| EngineError::param("tolerance", ProblemKind::NotPositive)),
|
||||
None => {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for n in model.nodes().values() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(n.xyz[k]);
|
||||
hi[k] = hi[k].max(n.xyz[k]);
|
||||
}
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt();
|
||||
Ok(if diagonal > 0.0 && diagonal.is_finite() { 1e-6 * diagonal } else { 1e-9 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The merge's candidates: the nodes of `set` (its own, and those of its elements and faces).
|
||||
fn candidates(model: &Model, p: &Value) -> Result<Option<BTreeSet<u32>>> {
|
||||
let Some(name) = super::opt::<String>(p, "set")? else { return Ok(None) };
|
||||
let set = model.resolve_set(&name)?;
|
||||
let mut nodes: BTreeSet<u32> = set.nodes.into_iter().collect();
|
||||
for e in set.elements.iter().chain(set.faces.iter().map(|f| &f.element)) {
|
||||
if let Some(el) = model.element(*e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
Ok(Some(nodes))
|
||||
}
|
||||
|
||||
fn keep(p: &Value) -> Result<Keep> {
|
||||
match super::opt::<String>(p, "keep")?.as_deref() {
|
||||
None | Some("lowest") => Ok(Keep::Lowest),
|
||||
Some("highest") => Ok(Keep::Highest),
|
||||
Some(_) => Err(EngineError::param("keep", ProblemKind::NotAChoice)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The merges `mesh.node.merge` with `params` would make, as [node, survivor] pairs: what its
|
||||
/// form previews. `None` when the parameters are not (yet) valid.
|
||||
pub fn merge_preview(s: &Session, params: &Value) -> Option<Vec<[u32; 2]>> {
|
||||
let tol = tolerance(&s.model, params).ok()?;
|
||||
let only = candidates(&s.model, params).ok()?;
|
||||
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(params).ok()?).ok()?;
|
||||
Some(plan.map.into_iter().map(|(a, b)| [a, b]).collect())
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = tolerance(&s.model, p)?;
|
||||
let only = candidates(&s.model, p)?;
|
||||
let plan = merge_plan(&s.model, only.as_ref(), tol, keep(p)?)?;
|
||||
if !plan.map.is_empty() {
|
||||
s.model.merge_nodes(&plan.map)?;
|
||||
}
|
||||
let pairs: Vec<[u32; 2]> = plan.map.iter().take(MAX_LISTED).map(|(&a, &b)| [a, b]).collect();
|
||||
let refused: Vec<Value> =
|
||||
plan.refused.iter().take(MAX_LISTED).map(|r| json!({"node": r.node, "survivor": r.survivor, "element": r.element})).collect();
|
||||
let mut summary = format!("merged {} node{} within {tol:.3e}", plan.map.len(), if plan.map.len() == 1 { "" } else { "s" });
|
||||
if !plan.refused.is_empty() {
|
||||
summary.push_str(&format!("; {} left apart (they would collapse an element)", plan.refused.len()));
|
||||
}
|
||||
Ok(json!({
|
||||
"merged": plan.map.len(),
|
||||
"pairs": pairs,
|
||||
"refused": refused,
|
||||
"tolerance": tol,
|
||||
"nodes": s.model.nodes().len(),
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn check_duplicates(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tol = tolerance(&s.model, p)?;
|
||||
let d = duplicates(&s.model, tol)?;
|
||||
let coincident: Vec<Value> = d.coincident.iter().take(MAX_LISTED).map(|&(a, b, dist)| json!([a, b, dist])).collect();
|
||||
let summary = format!(
|
||||
"{} coincident node{}, {} duplicate element group{}, {} crack face{}, {} free edge{} ({} free faces)",
|
||||
d.coincident.len(),
|
||||
plural(d.coincident.len()),
|
||||
d.elements.len(),
|
||||
plural(d.elements.len()),
|
||||
d.cracks.len(),
|
||||
plural(d.cracks.len()),
|
||||
d.free_edges.len(),
|
||||
plural(d.free_edges.len()),
|
||||
d.free_faces,
|
||||
);
|
||||
Ok(json!({
|
||||
"tolerance": tol,
|
||||
"coincident": coincident,
|
||||
"duplicate_elements": d.elements.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"free_faces": d.free_faces,
|
||||
"cracks": d.cracks.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"free_edges": d.free_edges.iter().take(MAX_LISTED).collect::<Vec<_>>(),
|
||||
"counts": {
|
||||
"coincident": d.coincident.len(),
|
||||
"duplicate_elements": d.elements.len(),
|
||||
"cracks": d.cracks.len(),
|
||||
"free_edges": d.free_edges.len(),
|
||||
},
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
fn plural(n: usize) -> &'static str {
|
||||
if n == 1 { "" } else { "s" }
|
||||
}
|
||||
|
||||
/// The body name of each element tagged with one.
|
||||
fn element_bodies(model: &Model) -> BTreeMap<u32, String> {
|
||||
let c = model.classification();
|
||||
c.elements()
|
||||
.iter()
|
||||
.filter_map(|(&e, t)| {
|
||||
let r = c.entity(t.region?)?;
|
||||
(r.entity == Entity::Body).then(|| (e, r.body.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// New labels for `order` from `start` up; `None` when they would leave the label range.
|
||||
fn from_start(order: &[u32], start: u32) -> Option<BTreeMap<u32, u32>> {
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &l)| Some((l, start.checked_add(u32::try_from(i).ok()?).filter(|x| *x <= MAX_LABEL)?)))
|
||||
.filter(|x| x.as_ref().is_none_or(|(a, b)| a != b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn renumber(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes_too, elements_too) = match super::opt::<String>(p, "what")?.as_deref() {
|
||||
None | Some("both") => (true, true),
|
||||
Some("nodes") => (true, false),
|
||||
Some("elements") => (false, true),
|
||||
Some(_) => return Err(EngineError::param("what", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let model = &s.model;
|
||||
let out_of_range = || EngineError::Params(format!("the new labels would leave 1..={MAX_LABEL}"));
|
||||
let (node_map, element_map) = if let Some(v) = p.get("offset") {
|
||||
if p.get("by").is_some() || p.get("start").is_some() {
|
||||
return Err(EngineError::Params("give `offset`, or `by` and `start`, not both".into()));
|
||||
}
|
||||
let offset = v
|
||||
.as_i64()
|
||||
.ok_or_else(|| EngineError::param("offset", ProblemKind::NotAWholeNumber { min: -i64::from(MAX_LABEL), max: i64::from(MAX_LABEL) }))?;
|
||||
let shift = |labels: &mut dyn Iterator<Item = u32>| -> Result<BTreeMap<u32, u32>> {
|
||||
labels
|
||||
.filter(|_| offset != 0)
|
||||
.map(|l| u32::try_from(i64::from(l) + offset).ok().filter(|x| (1..=MAX_LABEL).contains(x)).map(|n| (l, n)).ok_or_else(out_of_range))
|
||||
.collect()
|
||||
};
|
||||
let n = if nodes_too { shift(&mut model.nodes().keys().copied())? } else { BTreeMap::new() };
|
||||
let e = if elements_too { shift(&mut model.elements().keys().copied())? } else { BTreeMap::new() };
|
||||
(n, e)
|
||||
} else {
|
||||
let start = match p.get("start") {
|
||||
None => 1,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|x| u32::try_from(x).ok())
|
||||
.filter(|x| (1..=MAX_LABEL).contains(x))
|
||||
.ok_or_else(|| EngineError::param("start", ProblemKind::NotAWholeNumber { min: 1, max: i64::from(MAX_LABEL) }))?,
|
||||
};
|
||||
let (node_order, element_order): (Vec<u32>, Vec<u32>) = match super::opt::<String>(p, "by")?.as_deref() {
|
||||
None | Some("compact") => (model.nodes().keys().copied().collect(), model.elements().keys().copied().collect()),
|
||||
Some("bandwidth") => {
|
||||
let nodes = reverse_cuthill_mckee(model);
|
||||
let rank: BTreeMap<u32, usize> = nodes.iter().enumerate().map(|(i, &l)| (l, i)).collect();
|
||||
let mut elements: Vec<(usize, u32)> = model
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|(&l, e)| (e.nodes.iter().filter_map(|n| rank.get(n).copied()).min().unwrap_or(usize::MAX), l))
|
||||
.collect();
|
||||
elements.sort_unstable();
|
||||
(nodes, elements.into_iter().map(|(_, l)| l).collect())
|
||||
}
|
||||
Some("body") => {
|
||||
let bodies = element_bodies(model);
|
||||
// Elements by body name (untagged last), then label; nodes as their first
|
||||
// element in that order meets them, then the nodes no element uses.
|
||||
let mut elements: Vec<(Option<&String>, u32)> = model.elements().keys().map(|&l| (bodies.get(&l), l)).collect();
|
||||
elements.sort_by(|a, b| (a.0.is_none(), a.0, a.1).cmp(&(b.0.is_none(), b.0, b.1)));
|
||||
let elements: Vec<u32> = elements.into_iter().map(|(_, l)| l).collect();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut nodes: Vec<u32> = Vec::with_capacity(model.nodes().len());
|
||||
for e in &elements {
|
||||
for &n in model.element(*e).map(|x| x.nodes.as_slice()).unwrap_or(&[]) {
|
||||
if seen.insert(n) {
|
||||
nodes.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free: Vec<u32> = model.nodes().keys().copied().filter(|n| !seen.contains(n)).collect();
|
||||
free.sort_unstable();
|
||||
nodes.extend(free);
|
||||
(nodes, elements)
|
||||
}
|
||||
Some(_) => return Err(EngineError::param("by", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let n = if nodes_too { from_start(&node_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
|
||||
let e = if elements_too { from_start(&element_order, start).ok_or_else(out_of_range)? } else { BTreeMap::new() };
|
||||
(n, e)
|
||||
};
|
||||
let before = bandwidth(model, &|l| l);
|
||||
s.model.renumber(&node_map, &element_map)?;
|
||||
let after = bandwidth(&s.model, &|l| l);
|
||||
let range = |labels: &mut dyn Iterator<Item = u32>| -> Value {
|
||||
let (lo, hi) = labels.fold((u32::MAX, 0), |(lo, hi), l| (lo.min(l), hi.max(l)));
|
||||
if hi == 0 { Value::Null } else { json!([lo, hi]) }
|
||||
};
|
||||
Ok(json!({
|
||||
"nodes": node_map.len(),
|
||||
"elements": element_map.len(),
|
||||
"node_labels": range(&mut s.model.nodes().keys().copied()),
|
||||
"element_labels": range(&mut s.model.elements().keys().copied()),
|
||||
"bandwidth": [before, after],
|
||||
"summary": format!("renumbered {} nodes and {} elements; bandwidth {before} → {after}", node_map.len(), element_map.len()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 2 × 1 × 1 bar of hex8 blocks: whole, or as two halves that do not share their nodes
|
||||
/// on x = 1.
|
||||
fn bar(halves: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
let block = |body: &str, x0: f64, x1: f64, n: u32| {
|
||||
let c = [[x0, 0., 0.], [x1, 0., 0.], [x1, 1., 0.], [x0, 1., 0.], [x0, 0., 1.], [x1, 0., 1.], [x1, 1., 1.], [x0, 1., 1.]];
|
||||
json!({"body": body, "kind": "hex8", "corners": c, "divisions": [n, 2, 2], "merge": 0})
|
||||
};
|
||||
if halves {
|
||||
s.execute("mesh.block", &block("A", 0.0, 1.0, 2)).unwrap();
|
||||
s.execute("mesh.block", &block("B", 1.0, 2.0, 2)).unwrap();
|
||||
} else {
|
||||
s.execute("mesh.block", &block("A", 0.0, 2.0, 4)).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn positions(s: &Session) -> Vec<[u64; 3]> {
|
||||
let mut p: Vec<[u64; 3]> = s.model.nodes().values().map(|n| n.xyz.map(|x| (x + 0.0).to_bits())).collect();
|
||||
p.sort_unstable();
|
||||
p
|
||||
}
|
||||
|
||||
/// Two halves meshed apart are found coincident and cracked, and merging them gives the
|
||||
/// whole bar's nodes: no crack left, and undo brings the halves back.
|
||||
#[test]
|
||||
fn merging_two_halves_gives_the_whole() {
|
||||
let whole = bar(false);
|
||||
let mut s = bar(true);
|
||||
s.execute(
|
||||
"model.set.create",
|
||||
&json!({"name": "seam", "nodes": s.model.nodes().iter().filter(|(_, n)| n.xyz[0] == 1.0).map(|(l, _)| *l).collect::<Vec<_>>()}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!(d["counts"]["coincident"], 9, "{d}");
|
||||
assert_eq!(d["counts"]["cracks"], 8, "{d}");
|
||||
assert_eq!(d["counts"]["free_edges"], 0, "{d}");
|
||||
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
|
||||
|
||||
let r = s.execute("mesh.node.merge", &json!({})).unwrap();
|
||||
assert_eq!(r["merged"], 9, "{r}");
|
||||
assert_eq!(positions(&s), positions(&whole));
|
||||
// The seam set keeps its nine positions, on the survivors.
|
||||
assert_eq!(s.model.set("seam").unwrap().nodes.len(), 9);
|
||||
let d = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!((d["counts"]["coincident"].as_u64(), d["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{d}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.nodes().len(), whole.model.nodes().len() + 9);
|
||||
|
||||
// Keeping the highest labels; only a set's nodes; hostile parameters change nothing.
|
||||
let r = s.execute("mesh.node.merge", &json!({"keep": "highest", "set": "seam"})).unwrap();
|
||||
assert!(r["pairs"].as_array().unwrap().iter().all(|p| p[0].as_u64() < p[1].as_u64()), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for bad in [json!({"tolerance": -1}), json!({"tolerance": "x"}), json!({"keep": "middle"}), json!({"set": "nope"})] {
|
||||
assert!(s.execute("mesh.node.merge", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(merge_preview(&s, &json!({})).is_some_and(|p| p.len() == 9));
|
||||
}
|
||||
|
||||
/// Renumbering keeps the analysis: the cantilever solves to the same displacements, point
|
||||
/// for point, after its nodes take the bandwidth order from 1000 and its elements are
|
||||
/// shifted; the deck writes the new labels.
|
||||
#[test]
|
||||
fn renumbering_keeps_the_solution() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::cantilever()).unwrap();
|
||||
let at = json!([[100.0, 10.0, 10.0], [50.0, 0.0, 20.0], [25.0, 20.0, 0.0]]);
|
||||
let probe = |s: &mut Session| {
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
s.execute("solve.probe", &json!({"step": "Load", "at": at})).unwrap()["nodes"].clone()
|
||||
};
|
||||
let before = probe(&mut s);
|
||||
let bw = bandwidth(&s.model, &|l| l);
|
||||
let r = s.execute("mesh.renumber", &json!({"what": "nodes", "by": "bandwidth", "start": 1000})).unwrap();
|
||||
assert_eq!(r["node_labels"][0], 1000, "{r}");
|
||||
assert!(r["bandwidth"][1].as_u64().unwrap() <= u64::from(bw), "{r}");
|
||||
s.execute("mesh.renumber", &json!({"what": "elements", "offset": 500})).unwrap();
|
||||
assert_eq!(s.model.elements().keys().next().copied(), Some(501));
|
||||
let after = probe(&mut s);
|
||||
for (a, b) in before.as_array().unwrap().iter().zip(after.as_array().unwrap()) {
|
||||
for k in 0..3 {
|
||||
let (x, y) = (a["displacement"][k].as_f64().unwrap(), b["displacement"][k].as_f64().unwrap());
|
||||
// Equal to roundoff of the tip deflection (0.24 mm).
|
||||
assert!((x - y).abs() <= 1e-9, "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
let deck = fieldcraft_decks::abaqus::write(&s.model).unwrap();
|
||||
assert!(deck.lines().any(|l| l.starts_with("1000,")) && deck.lines().any(|l| l.starts_with("501,")), "{deck}");
|
||||
// Body by body and compact, then refusals that change nothing.
|
||||
s.execute("mesh.renumber", &json!({"by": "body"})).unwrap();
|
||||
s.execute("mesh.renumber", &json!({})).unwrap();
|
||||
assert_eq!(s.model.nodes().keys().next().copied(), Some(1));
|
||||
let model = s.model.clone();
|
||||
for bad in [
|
||||
json!({"offset": -1}),
|
||||
json!({"start": 99_999_999}),
|
||||
json!({"start": 0}),
|
||||
json!({"by": "random"}),
|
||||
json!({"what": "faces"}),
|
||||
json!({"offset": 1, "start": 3}),
|
||||
] {
|
||||
assert!(s.execute("mesh.renumber", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::of_body(&b.name, e))?;
|
||||
total += mesh.hexes.len();
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Node relaxation (#398): `mesh.node.relax` moves the nodes inside a sphere of influence (or
|
||||
//! listed nodes, elements or a set) to fix the worst elements there, nodes on the geometry
|
||||
//! staying on it ([`fieldcraft_mesh::relax`]), and keeps what it did as a relaxation field of the
|
||||
//! model; `mesh.node.relax.apply` does a field again on the current mesh (after a remesh), and a
|
||||
//! field marked `reapply` is applied by itself after every remesh of its bodies.
|
||||
//!
|
||||
//! **Where a node may go** comes from the mesh's geometry tags: a node on one B-rep face slides
|
||||
//! in that face's parameters, a node where two faces meet slides along their edge, a node on a
|
||||
//! geometry vertex or on three or more faces stays. Faces of the same surface (the two halves a
|
||||
//! kernel splits a cylinder into, or one face as cut into cells) count once. Partition cut faces
|
||||
//! (`partition:<name>:a` and `:b`) are not geometry: a node on one moves freely, and the cells on
|
||||
//! both sides share it, so the mesh stays conforming across the cut. A node on the skin whose
|
||||
//! geometry is unknown (an imported mesh) stays.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Freedom, RegionQuality, Relaxed};
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
use fieldcraft_mesh::tet::geometry::{AnalyticSurface, Tessellation};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{Model, Relaxation};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const RELAX: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax",
|
||||
label: "Relax Nodes",
|
||||
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "nodes": [labels] | "elements": [labels] | "set": "Name", "bodies"?: ["Body1"] (only their nodes), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, and the field kept. One undo step"#,
|
||||
form: Some(super::forms::NODE_RELAX),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: relax_nodes,
|
||||
};
|
||||
|
||||
pub const APPLY: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax.apply",
|
||||
label: "Apply Relaxation",
|
||||
params: r#"{"field": "Name", "bodies"?: ["Body1"] (default the field's), "polish"?: 0.3 (strength of one guarded smoothing sweep after; 0 for none)} — applies a relaxation field kept by mesh.node.relax to the current mesh (typically after a remesh): each node inside the relaxed region takes the displacement interpolated from the field at its parametric coordinates in the old cells, on its face or edge as mesh.node.relax moves it; a move that would invert an element is undone; then one guarded smoothing sweep. Reports the nodes moved and the largest displacement"#,
|
||||
form: Some(super::forms::NODE_RELAX_APPLY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: apply,
|
||||
};
|
||||
|
||||
/// The commands after which `reapply` fields are applied again.
|
||||
const REMESH: &[&str] = &[
|
||||
"mesh.tet.generate",
|
||||
"mesh.hex.generate",
|
||||
"mesh.hex.sweep",
|
||||
"mesh.hex.mapped",
|
||||
"mesh.hex.blocks",
|
||||
"mesh.hex.octree",
|
||||
"mesh.cells",
|
||||
"mesh.block",
|
||||
];
|
||||
|
||||
const DEFAULT_ITERATIONS: usize = 5;
|
||||
|
||||
/// The region's quality as JSON.
|
||||
fn quality_json(q: &RegionQuality) -> Value {
|
||||
json!({
|
||||
"elements": q.elements,
|
||||
"min_scaled_jacobian": q.min_quality,
|
||||
"max_edge_ratio": if q.max_edge_ratio.is_finite() { json!(q.max_edge_ratio) } else { Value::Null },
|
||||
"invalid": q.invalid,
|
||||
"worst": q.worst.iter().map(|(e, v)| json!({"element": e, "quality": v})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Faces a node lies on (geometry references), from the element faces, shells and vertex tags.
|
||||
struct NodeTags {
|
||||
faces: BTreeMap<u32, BTreeSet<(String, String)>>,
|
||||
vertex: BTreeSet<u32>,
|
||||
/// Nodes on a partition cut face.
|
||||
cut: BTreeSet<u32>,
|
||||
}
|
||||
|
||||
fn node_tags(model: &Model, nodes: &BTreeSet<u32>) -> NodeTags {
|
||||
let c = model.classification();
|
||||
let mut t = NodeTags { faces: BTreeMap::new(), vertex: BTreeSet::new(), cut: BTreeSet::new() };
|
||||
let note = |n: u32, tag: u32, t: &mut NodeTags| {
|
||||
let Some(g) = c.entity(tag) else { return };
|
||||
if let Entity::Face(name) = &g.entity {
|
||||
if name.starts_with("partition:") {
|
||||
t.cut.insert(n);
|
||||
} else {
|
||||
t.faces.entry(n).or_default().insert((g.body.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
};
|
||||
for (&label, tags) in c.elements() {
|
||||
let Some(e) = model.element(label) else { continue };
|
||||
if !e.nodes.iter().any(|n| nodes.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
if e.kind.dimension() == 2
|
||||
&& let Some(r) = tags.region
|
||||
{
|
||||
for &n in &e.nodes {
|
||||
note(n, r, &mut t);
|
||||
}
|
||||
}
|
||||
for (&face, &tag) in &tags.faces {
|
||||
let Some(local) = e.kind.faces().get(usize::from(face).wrapping_sub(1)) else { continue };
|
||||
for &i in local.iter() {
|
||||
if let Some(&n) = e.nodes.get(i) {
|
||||
note(n, tag, &mut t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (&n, &tag) in c.nodes() {
|
||||
if nodes.contains(&n) && c.entity(tag).is_some_and(|g| matches!(g.entity, Entity::Vertex(_))) {
|
||||
t.vertex.insert(n);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Whether two exact surfaces are the same surface.
|
||||
fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
|
||||
use AnalyticSurface as S;
|
||||
let sub = |x: [f64; 3], y: [f64; 3]| [x[0] - y[0], x[1] - y[1], x[2] - y[2]];
|
||||
let dot = |x: [f64; 3], y: [f64; 3]| x[0] * y[0] + x[1] * y[1] + x[2] * y[2];
|
||||
let len = |x: [f64; 3]| dot(x, x).sqrt();
|
||||
let unit = |x: [f64; 3]| {
|
||||
let l = len(x);
|
||||
if l > 0.0 { [x[0] / l, x[1] / l, x[2] / l] } else { x }
|
||||
};
|
||||
let parallel = |x: [f64; 3], y: [f64; 3]| (dot(unit(x), unit(y)).abs() - 1.0).abs() < 1e-9;
|
||||
let on_axis = |p: [f64; 3], o: [f64; 3], a: [f64; 3]| {
|
||||
let d = sub(p, o);
|
||||
let a = unit(a);
|
||||
let along = dot(d, a);
|
||||
len([d[0] - a[0] * along, d[1] - a[1] * along, d[2] - a[2] * along]) <= tol
|
||||
};
|
||||
match (*a, *b) {
|
||||
(S::Plane { normal: n1, point: p1 }, S::Plane { normal: n2, point: p2 }) => parallel(n1, n2) && dot(sub(p2, p1), unit(n1)).abs() <= tol,
|
||||
(S::Cylinder { origin: o1, axis: a1, radius: r1 }, S::Cylinder { origin: o2, axis: a2, radius: r2 }) => {
|
||||
(r1 - r2).abs() <= tol && parallel(a1, a2) && on_axis(o2, o1, a1)
|
||||
}
|
||||
(S::Sphere { center: c1, radius: r1 }, S::Sphere { center: c2, radius: r2 }) => len(sub(c1, c2)) <= tol && (r1 - r2).abs() <= tol,
|
||||
(S::Cone { apex: p1, axis: a1, half_angle: h1 }, S::Cone { apex: p2, axis: a2, half_angle: h2 }) => {
|
||||
len(sub(p1, p2)) <= tol && dot(unit(a1), unit(a2)) > 1.0 - 1e-9 && (h1 - h2).abs() <= 1e-9
|
||||
}
|
||||
(S::Torus { center: c1, axis: a1, major: m1, minor: n1 }, S::Torus { center: c2, axis: a2, major: m2, minor: n2 }) => {
|
||||
len(sub(c1, c2)) <= tol && parallel(a1, a2) && (m1 - m2).abs() <= tol && (n1 - n2).abs() <= tol
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The design's geometry as relaxation constraints.
|
||||
struct DesignGeometry {
|
||||
/// Per body: its tessellation with exact surfaces.
|
||||
bodies: Vec<Tessellation>,
|
||||
/// Face ids: (body, B-rep face).
|
||||
faces: Vec<(usize, u32)>,
|
||||
/// Curve ids: (body, the two B-rep faces).
|
||||
curves: Vec<(usize, [u32; 2])>,
|
||||
freedom: BTreeMap<u32, Freedom>,
|
||||
}
|
||||
|
||||
impl Constraints for DesignGeometry {
|
||||
fn freedom(&self, node: u32) -> Option<Freedom> {
|
||||
self.freedom.get(&node).copied()
|
||||
}
|
||||
fn slide_face(&self, face: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.faces.get(face as usize)?;
|
||||
self.bodies.get(b)?.slide_face(f, p, d)
|
||||
}
|
||||
fn slide_curve(&self, curve: u32, p: [f64; 3], d: [f64; 3]) -> Option<[f64; 3]> {
|
||||
let &(b, f) = self.curves.get(curve as usize)?;
|
||||
self.bodies.get(b)?.slide_curve(f, p, d)
|
||||
}
|
||||
}
|
||||
|
||||
/// The constraints of `nodes` from the mesh's tags and the open design.
|
||||
fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
|
||||
let tags = node_tags(&s.model, nodes);
|
||||
let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
|
||||
// Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
|
||||
let mut loaded: BTreeMap<String, Option<(usize, Vec<Option<AnalyticSurface>>)>> = BTreeMap::new();
|
||||
let mut load = |name: &str, g: &mut DesignGeometry| -> Result<Option<(usize, Vec<Option<AnalyticSurface>>)>> {
|
||||
if let Some(l) = loaded.get(name) {
|
||||
return Ok(l.clone());
|
||||
}
|
||||
let shape = s.design.as_deref().and_then(|d| d.body(name));
|
||||
let entry = match shape {
|
||||
None => None,
|
||||
Some(shape) => {
|
||||
let tol = (shape.size()? * 1e-3).max(1e-6);
|
||||
let mesh = shape.tessellate(tol)?;
|
||||
let surface = BoundarySurface::new(
|
||||
&mesh.positions,
|
||||
&mesh.triangles,
|
||||
&mesh.tri_face,
|
||||
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
|
||||
)?;
|
||||
let analytic = super::mesh::analytic_surfaces(shape);
|
||||
g.bodies.push(Tessellation::new(&surface, analytic.clone()));
|
||||
Some((g.bodies.len() - 1, analytic))
|
||||
}
|
||||
};
|
||||
loaded.insert(name.to_string(), entry.clone());
|
||||
Ok(entry)
|
||||
};
|
||||
let tol = 1e-6 * s.model.nodes().values().fold(0.0f64, |m, n| m.max(n.xyz.iter().fold(0.0f64, |a, x| a.max(x.abs())))).max(1.0);
|
||||
let mut face_ids: BTreeMap<(usize, u32), u32> = BTreeMap::new();
|
||||
let mut curve_ids: BTreeMap<(usize, [u32; 2]), u32> = BTreeMap::new();
|
||||
for &n in nodes {
|
||||
if tags.vertex.contains(&n) {
|
||||
g.freedom.insert(n, Freedom::Fixed);
|
||||
continue;
|
||||
}
|
||||
let Some(refs) = tags.faces.get(&n) else {
|
||||
if tags.cut.contains(&n) {
|
||||
g.freedom.insert(n, Freedom::Free);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// Resolve each face; group those on one surface.
|
||||
let mut groups: Vec<Vec<(usize, u32, Option<AnalyticSurface>)>> = Vec::new();
|
||||
let mut unknown = false;
|
||||
for (body, face) in refs {
|
||||
let Some((b, analytic)) = load(body, &mut g)? else {
|
||||
unknown = true;
|
||||
continue;
|
||||
};
|
||||
let Some(f) = s.design.as_deref().and_then(|d| d.body(body)).and_then(|sh| sh.face_index(face)) else {
|
||||
unknown = true;
|
||||
continue;
|
||||
};
|
||||
let surface = analytic.get(f as usize).copied().flatten();
|
||||
let group = groups.iter_mut().find(|grp| {
|
||||
grp.iter().any(|(gb, gf, gs)| (*gb == b && *gf == f) || matches!((gs, surface), (Some(x), Some(y)) if same_surface(x, &y, tol)))
|
||||
});
|
||||
match group {
|
||||
Some(grp) => grp.push((b, f, surface)),
|
||||
None => groups.push(vec![(b, f, surface)]),
|
||||
}
|
||||
}
|
||||
let freedom = match groups.as_slice() {
|
||||
_ if unknown => Freedom::Fixed,
|
||||
[one] => {
|
||||
let Some(&(b, f, _)) = one.first() else { continue };
|
||||
let next = g.faces.len() as u32;
|
||||
let id = *face_ids.entry((b, f)).or_insert(next);
|
||||
if id == next {
|
||||
g.faces.push((b, f));
|
||||
}
|
||||
Freedom::Face(id)
|
||||
}
|
||||
[x, y] => {
|
||||
// A body with a face of each surface carries their edge.
|
||||
let pair = x.iter().find_map(|&(bx, fx, _)| y.iter().find(|(by, _, _)| *by == bx).map(|&(_, fy, _)| (bx, [fx.min(fy), fx.max(fy)])));
|
||||
match pair {
|
||||
Some(key) => {
|
||||
let next = g.curves.len() as u32;
|
||||
let id = *curve_ids.entry(key).or_insert(next);
|
||||
if id == next {
|
||||
g.curves.push(key);
|
||||
}
|
||||
Freedom::Curve(id)
|
||||
}
|
||||
None => Freedom::Fixed,
|
||||
}
|
||||
}
|
||||
_ => Freedom::Fixed,
|
||||
};
|
||||
g.freedom.insert(n, freedom);
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
/// The nodes of `bodies`' elements, when given.
|
||||
fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<u32>)>> {
|
||||
let Some(bodies) = super::opt::<Vec<String>>(p, "bodies")? else { return Ok(None) };
|
||||
let mut nodes = BTreeSet::new();
|
||||
for b in &bodies {
|
||||
let elements = model.body_elements(b);
|
||||
if elements.is_empty() {
|
||||
return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(80).collect::<String>())));
|
||||
}
|
||||
for e in elements {
|
||||
if let Some(el) = model.element(e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some((bodies, nodes)))
|
||||
}
|
||||
|
||||
fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
|
||||
match super::opt::<[f64; 3]>(p, key)? {
|
||||
Some(c) if c.iter().all(|x| x.is_finite()) => Ok(Some(c)),
|
||||
Some(_) => Err(EngineError::param(key, ProblemKind::NotANumber)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sphere around element `label`: its centroid, and twice its longest corner edge.
|
||||
fn around_element(model: &Model, label: u32) -> Result<([f64; 3], f64)> {
|
||||
let e = model.element(label).ok_or_else(|| EngineError::Params(format!("no element {label}")))?;
|
||||
let x: Vec<[f64; 3]> = e.nodes.iter().take(e.kind.corner_count()).filter_map(|n| model.node(*n).map(|p| p.xyz)).collect();
|
||||
if x.is_empty() {
|
||||
return Err(EngineError::Params(format!("element {label} has no nodes")));
|
||||
}
|
||||
let c = x.iter().fold([0.0; 3], |s, p| [s[0] + p[0], s[1] + p[1], s[2] + p[2]]).map(|v| v / x.len() as f64);
|
||||
let mut longest = 0.0f64;
|
||||
for edge in e.kind.edges() {
|
||||
let at = |i: usize| edge.get(i).and_then(|&k| x.get(k)).copied();
|
||||
if let (Some(a), Some(b)) = (at(0), at(1)) {
|
||||
longest = longest.max(((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt());
|
||||
}
|
||||
}
|
||||
Ok((c, 2.0 * longest))
|
||||
}
|
||||
|
||||
/// The nodes to move and their weights, and the sphere when there is one.
|
||||
pub(crate) fn weights(model: &Model, p: &Value) -> Result<(BTreeMap<u32, f64>, Option<([f64; 3], f64)>)> {
|
||||
let strength = super::optional_number(p, "strength", 1.0)?;
|
||||
if !(strength > 0.0 && strength <= 1.0) {
|
||||
return Err(EngineError::param("strength", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let falloff = match super::opt::<String>(p, "falloff")? {
|
||||
None => Falloff::Smooth,
|
||||
Some(f) => Falloff::from_name(&f).ok_or_else(|| EngineError::param("falloff", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
let only = body_nodes(model, p)?.map(|(_, n)| n);
|
||||
let around = match super::opt::<u32>(p, "around_element")? {
|
||||
Some(e) => Some(around_element(model, e)?),
|
||||
None => None,
|
||||
};
|
||||
let center = point(p, "center")?.or(around.map(|a| a.0));
|
||||
let radius = match p.get("radius") {
|
||||
Some(_) => Some(super::optional_number(p, "radius", 0.0)?)
|
||||
.filter(|r| *r > 0.0 && r.is_finite())
|
||||
.ok_or_else(|| EngineError::param("radius", ProblemKind::NotPositive))
|
||||
.map(Some)?,
|
||||
None => around.map(|a| a.1),
|
||||
};
|
||||
let listed = ["nodes", "elements", "set"].iter().filter(|k| p.get(**k).is_some()).count();
|
||||
if let (Some(c), Some(r)) = (center, radius) {
|
||||
if listed > 0 {
|
||||
return Err(EngineError::Params(
|
||||
"give a sphere (`center` and `radius`, or `around_element`) or `nodes`, `elements` or `set`, not both".into(),
|
||||
));
|
||||
}
|
||||
return Ok((relax::sphere_weights(model, c, r, strength, falloff, only.as_ref())?, Some((c, r))));
|
||||
}
|
||||
if center.is_some() != radius.is_some() {
|
||||
return Err(EngineError::Params("a sphere of influence needs both `center` and `radius`".into()));
|
||||
}
|
||||
let mut nodes: BTreeSet<u32> = BTreeSet::new();
|
||||
if p.get("nodes").is_some() {
|
||||
nodes.extend(super::labels(p, "nodes", fieldcraft_model::MAX_NODES)?);
|
||||
}
|
||||
let mut elements: Vec<u32> = Vec::new();
|
||||
if p.get("elements").is_some() {
|
||||
elements.extend(super::labels(p, "elements", fieldcraft_model::MAX_ELEMENTS)?);
|
||||
}
|
||||
if let Some(name) = super::opt::<String>(p, "set")? {
|
||||
let set = model.resolve_set(&name)?;
|
||||
nodes.extend(set.nodes.iter().copied());
|
||||
elements.extend(set.elements.iter().copied());
|
||||
elements.extend(set.faces.iter().map(|f| f.element));
|
||||
}
|
||||
for e in elements {
|
||||
let el = model.element(e).ok_or_else(|| EngineError::Params(format!("no element {e}")))?;
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
if listed == 0 {
|
||||
return Err(EngineError::Params(
|
||||
"say which nodes to relax: give one of `center` and `radius`, `around_element`, `nodes`, `elements` or `set`".into(),
|
||||
));
|
||||
}
|
||||
if let Some(only) = &only {
|
||||
nodes.retain(|n| only.contains(n));
|
||||
}
|
||||
Ok((relax::uniform_weights(model, &nodes, strength)?, None))
|
||||
}
|
||||
|
||||
fn iterations(p: &Value) -> Result<usize> {
|
||||
match p.get("iterations") {
|
||||
None => Ok(DEFAULT_ITERATIONS),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.filter(|i| (1..=relax::MAX_ITERATIONS).contains(i))
|
||||
.ok_or_else(|| EngineError::param("iterations", ProblemKind::NotInRange { min: 1.0, max: relax::MAX_ITERATIONS as f64 })),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes the relaxation may touch: the weighted ones and their elements' nodes.
|
||||
fn touched(model: &Model, weights: &BTreeMap<u32, f64>) -> BTreeSet<u32> {
|
||||
let mut out: BTreeSet<u32> = weights.keys().copied().collect();
|
||||
for e in model.elements().values() {
|
||||
if e.nodes.iter().any(|n| weights.contains_key(n)) {
|
||||
out.extend(e.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What `mesh.node.relax` with `params` would do, without doing it: the open form's preview.
|
||||
pub struct Preview {
|
||||
pub sphere: Option<([f64; 3], f64)>,
|
||||
pub relaxed: Relaxed,
|
||||
}
|
||||
|
||||
pub fn preview(s: &Session, params: &Value) -> Option<Preview> {
|
||||
let (w, sphere) = weights(&s.model, params).ok()?;
|
||||
let g = design_geometry(s, &touched(&s.model, &w)).ok()?;
|
||||
let relaxed = relax::relax(&s.model, &w, iterations(params).ok()?, &g).ok()?;
|
||||
Some(Preview { sphere, relaxed })
|
||||
}
|
||||
|
||||
/// The sphere `params` describe, for the form's gizmo before the rest is valid.
|
||||
pub fn sphere(s: &Session, params: &Value) -> Option<([f64; 3], f64)> {
|
||||
let around = params.get("around_element").and_then(Value::as_u64).and_then(|e| around_element(&s.model, u32::try_from(e).ok()?).ok());
|
||||
let c = point(params, "center").ok().flatten().or(around.map(|a| a.0))?;
|
||||
let r = params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite()).or(around.map(|a| a.1))?;
|
||||
Some((c, r))
|
||||
}
|
||||
|
||||
fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, sphere) = weights(&s.model, p)?;
|
||||
if w.is_empty() {
|
||||
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
|
||||
}
|
||||
let iterations = iterations(p)?;
|
||||
let g = design_geometry(s, &touched(&s.model, &w))?;
|
||||
let r = relax::relax(&s.model, &w, iterations, &g)?;
|
||||
let field_name = match super::opt::<String>(p, "field")? {
|
||||
Some(n) => n,
|
||||
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
|
||||
};
|
||||
let bodies = match body_nodes(&s.model, p)? {
|
||||
Some((b, _)) => b,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let reapply = super::opt::<bool>(p, "reapply")?.unwrap_or(false);
|
||||
if !r.positions.is_empty() {
|
||||
let cells = relax::field_cells(&s.model, &r);
|
||||
s.model.set_relaxation(&field_name, Relaxation { bodies, cells, reapply })?;
|
||||
}
|
||||
for (n, x) in &r.positions {
|
||||
s.model.set_node(*n, *x)?;
|
||||
}
|
||||
let summary = format!(
|
||||
"moved {} node{} (largest {:.3e}); scaled Jacobian min from {:.3} to {:.3}{}",
|
||||
r.moved,
|
||||
if r.moved == 1 { "" } else { "s" },
|
||||
r.max_displacement,
|
||||
r.before.min_quality,
|
||||
r.after.min_quality,
|
||||
if r.positions.is_empty() { String::new() } else { format!("; kept as field {field_name}") }
|
||||
);
|
||||
Ok(json!({
|
||||
"moved": r.moved,
|
||||
"nodes_changed": r.positions.len(),
|
||||
"max_displacement": r.max_displacement,
|
||||
"iterations": r.iterations,
|
||||
"sphere": sphere.map(|(c, r)| json!({"center": c, "radius": r})),
|
||||
"before": quality_json(&r.before),
|
||||
"after": quality_json(&r.after),
|
||||
"field": (!r.positions.is_empty()).then_some(field_name),
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Apply field `name` to the nodes of `bodies` (or of the whole mesh).
|
||||
fn apply_field(s: &mut Session, name: &str, bodies: &[String], polish: f64) -> Result<Relaxed> {
|
||||
let field = s
|
||||
.model
|
||||
.relaxation(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| EngineError::Params(format!("no relaxation field `{}`", name.chars().take(80).collect::<String>())))?;
|
||||
let mut nodes = BTreeSet::new();
|
||||
let bodies = if bodies.is_empty() { &field.bodies[..] } else { bodies };
|
||||
if bodies.is_empty() {
|
||||
nodes.extend(s.model.nodes().keys().copied());
|
||||
} else {
|
||||
for b in bodies {
|
||||
for e in s.model.body_elements(b) {
|
||||
if let Some(el) = s.model.element(e) {
|
||||
nodes.extend(el.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only nodes the field reaches need constraints.
|
||||
nodes.retain(|n| s.model.node(*n).is_some_and(|p| relax::field_displacement(&field.cells, p.xyz).is_some()));
|
||||
let g = design_geometry(s, &nodes)?;
|
||||
let r = relax::apply_field(&s.model, &field.cells, Some(&nodes), &g, polish)?;
|
||||
for (n, x) in &r.positions {
|
||||
s.model.set_node(*n, *x)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: String = super::req(p, "field")?;
|
||||
let bodies = super::opt::<Vec<String>>(p, "bodies")?.unwrap_or_default();
|
||||
let polish = super::optional_number(p, "polish", 0.3)?;
|
||||
if !(0.0..=1.0).contains(&polish) {
|
||||
return Err(EngineError::param("polish", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let r = apply_field(s, &name, &bodies, polish)?;
|
||||
Ok(json!({
|
||||
"moved": r.moved,
|
||||
"nodes_changed": r.positions.len(),
|
||||
"max_displacement": r.max_displacement,
|
||||
"summary": format!("applied field {name}: moved {} node{} (largest {:.3e})", r.moved, if r.moved == 1 { "" } else { "s" }, r.max_displacement),
|
||||
}))
|
||||
}
|
||||
|
||||
/// After a remesh (`id`), apply again every field marked `reapply`; its reply lists them.
|
||||
pub(crate) fn after_remesh(s: &mut Session, id: &str, mut reply: Value) -> Result<Value> {
|
||||
if !REMESH.contains(&id) {
|
||||
return Ok(reply);
|
||||
}
|
||||
let fields: Vec<String> = s.model.relaxations().iter().filter(|(_, f)| f.reapply).map(|(n, _)| n.clone()).collect();
|
||||
let mut applied = Vec::new();
|
||||
for name in fields {
|
||||
let r = apply_field(s, &name, &[], 0.3)?;
|
||||
applied.push(json!({"field": name, "moved": r.moved}));
|
||||
}
|
||||
if !applied.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.insert("relaxations_applied".into(), Value::Array(applied));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,194 @@
|
||||
use super::*;
|
||||
|
||||
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15): a point on the hole's wall.
|
||||
const ON_HOLE: [f64; 3] = [20.0, 15.0, 5.0];
|
||||
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
|
||||
|
||||
fn plate() -> Session {
|
||||
super::super::datum::tests::plate().0
|
||||
}
|
||||
|
||||
fn hole_radius(p: [f64; 3]) -> f64 {
|
||||
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Which analytic face a node of the plate lies on, if any.
|
||||
fn on_faces(p: [f64; 3]) -> Vec<&'static str> {
|
||||
let mut f = Vec::new();
|
||||
if (hole_radius(p) - HOLE.1).abs() < 1e-6 {
|
||||
f.push("hole");
|
||||
}
|
||||
if p[2].abs() < 1e-6 || (p[2] - 10.0).abs() < 1e-6 {
|
||||
f.push("cap");
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Spikes near the hole: interior nodes pushed about; nodes on the hole turned round it and
|
||||
/// moved along its axis (still on the cylinder), nodes on the caps moved in their plane.
|
||||
fn perturb(s: &mut Session, centre: [f64; 3], radius: f64, amount: f64) {
|
||||
let nodes: Vec<(u32, [f64; 3])> = s.model.nodes().iter().map(|(n, p)| (*n, p.xyz)).collect();
|
||||
for (k, (n, p)) in nodes.into_iter().enumerate() {
|
||||
let d2: f64 = (0..3).map(|i| (p[i] - centre[i]).powi(2)).sum();
|
||||
if d2 > radius * radius {
|
||||
continue;
|
||||
}
|
||||
let wiggle = |m: usize| amount * ((k * m % 7) as f64 - 3.0) / 3.0;
|
||||
let faces = on_faces(p);
|
||||
let q = match faces.as_slice() {
|
||||
[] => [p[0] + wiggle(3), p[1] + wiggle(5), p[2] + wiggle(11)],
|
||||
["hole"] => {
|
||||
let a = wiggle(3) / HOLE.1;
|
||||
let (x, y) = (p[0] - HOLE.0[0], p[1] - HOLE.0[1]);
|
||||
[HOLE.0[0] + x * a.cos() - y * a.sin(), HOLE.0[1] + x * a.sin() + y * a.cos(), p[2] + wiggle(5)]
|
||||
}
|
||||
["cap"] if hole_radius(p) > HOLE.1 + 1e-6 => [p[0] + wiggle(3), p[1] + wiggle(5), p[2]],
|
||||
_ => continue,
|
||||
};
|
||||
s.model.set_node(n, q).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_on_geometry(before: &Model, after: &Model) {
|
||||
for (n, p) in after.nodes() {
|
||||
let old = before.node(*n).unwrap().xyz;
|
||||
for f in on_faces(old) {
|
||||
match f {
|
||||
"hole" => assert!((hole_radius(p.xyz) - HOLE.1).abs() < 1e-6, "node {n} left the hole: r = {}", hole_radius(p.xyz)),
|
||||
_ => assert!((p.xyz[2] - old[2]).abs() < 1e-6, "node {n} left its cap"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(s: &mut Session) -> u64 {
|
||||
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The issue's hex test on a design: spikes near the hole relax, every surface node stays on its
|
||||
/// face (the hole's cylinder exactly), quality rises, nothing inverts, nothing outside the sphere
|
||||
/// moves; one undo step; the field is kept and saved with the model.
|
||||
#[test]
|
||||
fn hex_spikes_near_a_hole_relax_on_the_geometry() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 3.0, 0.35);
|
||||
let perturbed = s.model.clone();
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 10})).unwrap();
|
||||
let (before, after) = (r["before"]["min_scaled_jacobian"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap());
|
||||
assert!(after > before + 0.05, "{r}");
|
||||
assert_eq!(r["after"]["invalid"], 0, "{r}");
|
||||
assert!(r["moved"].as_u64().unwrap() > 0 && r["field"] == "Relax1", "{r}");
|
||||
check_on_geometry(&perturbed, &s.model);
|
||||
for (n, p) in s.model.nodes() {
|
||||
let old = perturbed.node(*n).unwrap().xyz;
|
||||
if old != p.xyz {
|
||||
let d2: f64 = (0..3).map(|i| (old[i] - ON_HOLE[i]).powi(2)).sum();
|
||||
assert!(d2 < 16.0, "node {n} outside the sphere moved");
|
||||
}
|
||||
}
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
// The field is saved with the model.
|
||||
let text = serde_json::to_string(&s.model).unwrap();
|
||||
let back: Model = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.relaxation("Relax1"), s.model.relaxation("Relax1"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, perturbed);
|
||||
}
|
||||
|
||||
/// The same on tet10 with curved mid-side nodes on the hole: they stay on the cylinder.
|
||||
#[test]
|
||||
fn tet10_relaxes_with_curved_mid_side_nodes_on_the_hole() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.tet.generate", &json!({"size": 2.5, "order": "quadratic"})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 3.0, 0.2);
|
||||
// Put the mid-side nodes back on their edges' middles, on the hole where the edge is.
|
||||
let mids: Vec<(u32, u32, u32)> = s
|
||||
.model
|
||||
.elements()
|
||||
.values()
|
||||
.flat_map(|e| e.kind.edges().iter().map(|ed| (e.nodes[ed[0]], e.nodes[ed[1]], e.nodes[ed[2]])).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let original = s.model.clone();
|
||||
for (a, b, m) in mids {
|
||||
let (pa, pb, pm) = (original.node(a).unwrap().xyz, original.node(b).unwrap().xyz, original.node(m).unwrap().xyz);
|
||||
let mut q = [(pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0, (pa[2] + pb[2]) / 2.0];
|
||||
if on_faces(pm).contains(&"hole") {
|
||||
let r = hole_radius(q);
|
||||
q = [HOLE.0[0] + (q[0] - HOLE.0[0]) * HOLE.1 / r, HOLE.0[1] + (q[1] - HOLE.0[1]) * HOLE.1 / r, q[2]];
|
||||
}
|
||||
s.model.set_node(m, q).unwrap();
|
||||
}
|
||||
let perturbed = s.model.clone();
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 4.0, "iterations": 8})).unwrap();
|
||||
assert!(r["after"]["min_scaled_jacobian"].as_f64().unwrap() >= r["before"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
|
||||
assert_eq!(r["after"]["invalid"], 0, "{r}");
|
||||
check_on_geometry(&perturbed, &s.model);
|
||||
}
|
||||
|
||||
/// A partition cut is not geometry: nodes on it move freely, shared by the cells on both sides.
|
||||
#[test]
|
||||
fn nodes_on_a_partition_cut_move_freely() {
|
||||
let mut s = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 27}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "Cut"})).unwrap();
|
||||
s.execute("mesh.cells", &json!({"size": 2.0, "default": "hex"})).unwrap();
|
||||
let centre = [27.0, 15.0, 5.0];
|
||||
perturb(&mut s, centre, 2.5, 0.3);
|
||||
let before = s.model.clone();
|
||||
s.execute("mesh.node.relax", &json!({"center": centre, "radius": 3.5, "iterations": 10})).unwrap();
|
||||
let moved_off = s
|
||||
.model
|
||||
.nodes()
|
||||
.iter()
|
||||
.filter(|(n, p)| {
|
||||
let old = before.node(**n).unwrap().xyz;
|
||||
(old[0] - 27.0).abs() < 1e-6 && on_faces(old).is_empty() && (p.xyz[0] - 27.0).abs() > 1e-6
|
||||
})
|
||||
.count();
|
||||
assert!(moved_off > 0, "nodes on the cut are free");
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
}
|
||||
|
||||
/// A field marked `reapply` is applied again after a remesh; `mesh.node.relax.apply` applies it
|
||||
/// on demand; around the worst element works.
|
||||
#[test]
|
||||
fn a_kept_field_is_applied_again_after_a_remesh() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 3.0})).unwrap();
|
||||
perturb(&mut s, ON_HOLE, 4.0, 0.3);
|
||||
let r = s.execute("mesh.node.relax", &json!({"center": ON_HOLE, "radius": 5.0, "field": "Spike", "reapply": true})).unwrap();
|
||||
assert_eq!(r["field"], "Spike");
|
||||
let r = s.execute("mesh.hex.generate", &json!({"size": 1.5})).unwrap();
|
||||
assert_eq!(r["relaxations_applied"][0]["field"], "Spike", "{r}");
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
let r = s.execute("mesh.node.relax.apply", &json!({"field": "Spike"})).unwrap();
|
||||
assert!(r["summary"].as_str().unwrap().contains("Spike"));
|
||||
let worst = s.execute("mesh.quality", &json!({})).unwrap()["metrics"]["scaled_jacobian"]["worst"].as_u64().unwrap();
|
||||
let r = s.execute("mesh.node.relax", &json!({"around_element": worst})).unwrap();
|
||||
assert!(r["after"]["min_scaled_jacobian"].as_f64() >= r["before"]["min_scaled_jacobian"].as_f64(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_never_panic() {
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 4.0})).unwrap();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"center": [0, 0, 0]}),
|
||||
json!({"center": [1e300, 0, 0], "radius": 1}),
|
||||
json!({"center": [0, 0, 0], "radius": -1}),
|
||||
json!({"center": [0, 0, 0], "radius": 1, "nodes": [1]}),
|
||||
json!({"nodes": [1], "strength": 2}),
|
||||
json!({"nodes": [1], "iterations": 0}),
|
||||
json!({"nodes": [1], "iterations": 1000}),
|
||||
json!({"nodes": [1], "falloff": "cubic"}),
|
||||
json!({"around_element": 99999999}),
|
||||
json!({"set": "nope"}),
|
||||
json!({"nodes": [1], "bodies": ["nope"]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("mesh.node.relax.apply", &json!({"field": "nope"})).is_err());
|
||||
assert!(s.execute("mesh.node.relax.apply", &json!({})).is_err());
|
||||
}
|
||||
@@ -46,6 +46,11 @@ pub(super) fn clear<'a>(model: &mut Model, bodies: impl IntoIterator<Item = &'a
|
||||
}
|
||||
|
||||
impl Cleared {
|
||||
/// [`Cleared::report`] when anything was removed; `reply` as it is otherwise.
|
||||
pub(super) fn report_if_any(self, s: &Session, reply: Value) -> Value {
|
||||
if self.bodies.is_empty() { reply } else { self.report(s, reply) }
|
||||
}
|
||||
|
||||
/// Add `replaced`, `stale_sets` and `results_cleared` to a command's reply (an object).
|
||||
pub(super) fn report(self, s: &Session, mut reply: Value) -> Value {
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
@@ -63,6 +68,55 @@ impl Cleared {
|
||||
}
|
||||
}
|
||||
|
||||
pub const CLEAR: crate::CommandSpec = crate::CommandSpec {
|
||||
id: "mesh.clear",
|
||||
label: "Clear Mesh",
|
||||
params: r#"{"bodies"?: ["Body1/a"]} — removes the meshes of the bodies (their elements and the nodes only they use; sets that listed them are named as `stale_sets`, results solved on them are cleared); without `bodies`, of every body the open design no longer has because its partitions changed since it was meshed (model.check lists them; none: nothing changes). Reports what went as `replaced`"#,
|
||||
form: Some(super::forms::CLEAR_MESH),
|
||||
kind: crate::CommandKind::Model,
|
||||
requires: &[crate::requirement::Requirement::Mesh],
|
||||
run: clear_meshes,
|
||||
};
|
||||
|
||||
fn clear_meshes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bodies: Vec<String> = match p.get("bodies") {
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
None => s.design.as_deref().map(|d| stale(&s.model, d).into_iter().map(|(b, _)| b).collect()).unwrap_or_default(),
|
||||
};
|
||||
for b in &bodies {
|
||||
if s.model.body_elements(b).is_empty() {
|
||||
return Err(EngineError::Params(format!("body `{}` has no mesh", b.chars().take(64).collect::<String>())));
|
||||
}
|
||||
}
|
||||
let cleared = clear(&mut s.model, bodies.iter().map(String::as_str));
|
||||
let (nodes, elements) = (s.model.nodes().len(), s.model.elements().len());
|
||||
Ok(cleared.report(s, json!({"nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// The meshed bodies the open design no longer has because its partitions changed (#385): cells
|
||||
/// of a partition that is gone (`Base/a/b`), or a body partitions now cut (`Base`), each with
|
||||
/// its element count. Other bodies the design lacks (mirror images, imported surfaces, blocks)
|
||||
/// are not stale.
|
||||
pub(crate) fn stale(model: &Model, design: &fieldcraft_geometry::Design) -> Vec<(String, usize)> {
|
||||
let cell = |name: &str| -> bool {
|
||||
let Some(source) = design.source_of(name) else { return false };
|
||||
let rest = name.get(source.len()..).unwrap_or_default();
|
||||
rest.split('/').skip(1).all(|seg| seg.strip_prefix(['a', 'b']).is_some_and(|k| k.bytes().all(|c| c.is_ascii_digit())))
|
||||
};
|
||||
model
|
||||
.classification()
|
||||
.entities()
|
||||
.iter()
|
||||
.filter(|g| matches!(g.entity, fieldcraft_model::geometry::Entity::Body))
|
||||
.map(|g| g.body.as_str())
|
||||
.filter(|b| design.body(b).is_none() && cell(b))
|
||||
.filter_map(|b| {
|
||||
let n = model.body_elements(b).len();
|
||||
(n > 0).then(|| (b.to_string(), n))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The bodies a meshing command fills, from its parameters, without meshing anything.
|
||||
pub type Targets = fn(&Session, &Value) -> Result<Vec<String>>;
|
||||
|
||||
@@ -102,6 +156,28 @@ fn design_bodies(s: &Session) -> Result<Vec<String>> {
|
||||
Ok(design.bodies.iter().map(|b| b.name.clone()).collect())
|
||||
}
|
||||
|
||||
/// Tetrahedra and hexahedra of either order: the kinds an order change swaps.
|
||||
fn family(k: ElementKind) -> Option<u8> {
|
||||
match k {
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => Some(0),
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => Some(1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The solid element type `command` makes with `params`, when it makes one type.
|
||||
fn made_kind(command: &str, params: &Value) -> Option<ElementKind> {
|
||||
match command {
|
||||
"mesh.tet.generate" => {
|
||||
Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 })
|
||||
}
|
||||
"mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" => {
|
||||
Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What running a meshing command would replace, for a confirmation before it runs.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Replacement {
|
||||
@@ -141,6 +217,14 @@ pub fn replacement(s: &Session, command: &str, params: &Value) -> Option<Replace
|
||||
}
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
// The element type changing (tet10 → tet4 when the order is left out) is easy to miss.
|
||||
if let Some(new) = made_kind(command, params) {
|
||||
let old: BTreeSet<ElementKind> = removed.elements.iter().filter_map(|l| s.model.element(*l)).map(|e| e.kind).collect();
|
||||
let changed: Vec<&str> = old.iter().filter(|k| **k != new && family(**k) == family(new)).map(|k| k.name()).collect();
|
||||
if !changed.is_empty() {
|
||||
warnings.push(format!("The element type changes: {} → {}.", changed.join(", "), new.name()));
|
||||
}
|
||||
}
|
||||
let stale = s.model.sets_using(&removed);
|
||||
if !stale.is_empty() {
|
||||
warnings.push(format!("Sets that list its nodes or elements by label ({}) will name labels of the old mesh.", stale.join(", ")));
|
||||
@@ -153,7 +237,7 @@ pub fn replacement(s: &Session, command: &str, params: &Value) -> Option<Replace
|
||||
}
|
||||
|
||||
/// `n` with thousands separators: 7274 → "7,274".
|
||||
fn thousands(n: usize) -> String {
|
||||
pub fn thousands(n: usize) -> String {
|
||||
let digits = n.to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
@@ -181,7 +265,7 @@ mod tests {
|
||||
assert_eq!(thousands(1_000_000), "1,000,000");
|
||||
}
|
||||
|
||||
/// A 20 × 10 × 10 box split at x = 10 into `Box` (x < 10) and `Box (1)`.
|
||||
/// A 20 × 10 × 10 box split at x = 10, opened as `Box.1` (x < 10) and `Box.2`.
|
||||
fn two_boxes() -> (crate::tests::TempDir, Session) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}},
|
||||
@@ -194,6 +278,17 @@ mod tests {
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Remeshing without the order would turn tet10 into tet4: the question says so (#343).
|
||||
#[test]
|
||||
fn an_element_type_change_is_named() {
|
||||
let (_dir, mut s) = two_boxes();
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 5, "order": "quadratic"})).unwrap();
|
||||
let r = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w == "The element type changes: tet10 → tet4."), "{r:?}");
|
||||
let same = replacement(&s, "mesh.tet.generate", &json!({"body": "Box.1", "order": "quadratic"})).unwrap();
|
||||
assert!(same.warnings.iter().all(|w| !w.contains("element type")), "{same:?}");
|
||||
}
|
||||
|
||||
/// A body's elements, by label.
|
||||
fn elements_of(s: &Session, body: &str) -> BTreeMap<u32, Element> {
|
||||
s.model.body_elements(body).into_iter().filter_map(|l| Some((l, s.model.element(l)?.clone()))).collect()
|
||||
@@ -204,25 +299,25 @@ mod tests {
|
||||
/// was, names the set that listed the old labels, and one undo brings the first mesh back.
|
||||
#[test]
|
||||
fn every_mesher_replaces_the_mesh_of_its_body_only() {
|
||||
let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box (1)", "size": 5}});
|
||||
let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box", "size": 2.5}});
|
||||
let tet_other = json!({"command": "mesh.tet.generate", "params": {"body": "Box.2", "size": 5}});
|
||||
let hex_box = json!({"command": "mesh.hex.generate", "params": {"body": "Box.1", "size": 2.5}});
|
||||
let square = json!([[0, 0], [10, 0], [10, 10], [0, 10]]);
|
||||
let cases: Vec<(&str, Vec<Value>, Value, &str, &str)> = vec![
|
||||
("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 4}), "Box", "Box (1)"),
|
||||
("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"),
|
||||
("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"),
|
||||
("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"),
|
||||
("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box", "size": 2.5}), "Box", "Box (1)"),
|
||||
("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5}), "Box", "Box (1)"),
|
||||
("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box"], "size": 2.5, "default": "hex"}), "Box", "Box (1)"),
|
||||
("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box"}), "Box", "Box (1)"),
|
||||
("mesh.tet.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 4}), "Box.1", "Box.2"),
|
||||
("mesh.hex.generate", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.sweep", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.mapped", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.octree", vec![tet_other.clone()], json!({"body": "Box.1", "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.hex.blocks", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5}), "Box.1", "Box.2"),
|
||||
("mesh.cells", vec![tet_other.clone()], json!({"bodies": ["Box.1"], "size": 2.5, "default": "hex"}), "Box.1", "Box.2"),
|
||||
("mesh.planar", vec![tet_other.clone()], json!({"polygon": square, "size": 2.5, "name": "Box.1"}), "Box.1", "Box.2"),
|
||||
// The image's tets (meshed before) are replaced by the mirrored hexahedra.
|
||||
(
|
||||
"mesh.mirror",
|
||||
vec![hex_box, tet_other],
|
||||
json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box"]}),
|
||||
"Box (1)",
|
||||
"Box",
|
||||
json!({"plane": {"point": [10, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Box.1"]}),
|
||||
"Box.2",
|
||||
"Box.1",
|
||||
),
|
||||
];
|
||||
let covered: BTreeSet<&str> = cases.iter().map(|c| c.0).collect();
|
||||
@@ -272,16 +367,16 @@ mod tests {
|
||||
fn tets_over_hexahedra_leave_only_tets() {
|
||||
let (_dir, mut s) = two_boxes();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.5})).unwrap();
|
||||
let hexes = elements_of(&s, "Box").len();
|
||||
let other = elements_of(&s, "Box (1)");
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Box", "size": 4})).unwrap();
|
||||
let hexes = elements_of(&s, "Box.1").len();
|
||||
let other = elements_of(&s, "Box.2");
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Box.1", "size": 4})).unwrap();
|
||||
assert_eq!(r["replaced"][0]["elements"], hexes, "{r}");
|
||||
assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Tet4));
|
||||
assert_eq!(elements_of(&s, "Box (1)"), other);
|
||||
assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Tet4));
|
||||
assert_eq!(elements_of(&s, "Box.2"), other);
|
||||
let tets = s.model.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(elements_of(&s, "Box").len(), hexes, "one undo brings the hexahedra back");
|
||||
assert!(elements_of(&s, "Box").values().all(|e| e.kind == ElementKind::Hex8));
|
||||
assert_eq!(elements_of(&s, "Box.1").len(), hexes, "one undo brings the hexahedra back");
|
||||
assert!(elements_of(&s, "Box.1").values().all(|e| e.kind == ElementKind::Hex8));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, tets);
|
||||
// Meshing every body replaces both.
|
||||
@@ -290,8 +385,8 @@ mod tests {
|
||||
assert!(s.model.elements().values().all(|e| e.kind == ElementKind::Tet4));
|
||||
// A body without a mesh asks nothing.
|
||||
let (_dir2, s2) = two_boxes();
|
||||
assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box"})), None);
|
||||
assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box"})), None, "not a mesher");
|
||||
assert_eq!(replacement(&s2, "mesh.tet.generate", &json!({"body": "Box.1"})), None);
|
||||
assert_eq!(replacement(&s, "mesh.block", &json!({"body": "Box.1"})), None, "not a mesher");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
pub const CONTOUR: CommandSpec = CommandSpec {
|
||||
id: "results.contour",
|
||||
label: "Contours",
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -63,13 +63,23 @@ pub const FRAME: CommandSpec = CommandSpec {
|
||||
pub const REACTIONS: CommandSpec = CommandSpec {
|
||||
id: "results.reactions",
|
||||
label: "Reaction Sums",
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` defaults to the sets the step's boundary conditions hold"#,
|
||||
params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` (none or empty) defaults to the sets the step's boundary conditions hold"#,
|
||||
form: Some(super::forms::REACTIONS),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: reactions,
|
||||
};
|
||||
|
||||
pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
||||
id: "results.export.csv",
|
||||
label: "Export Results (CSV)",
|
||||
params: r#"{"what": "probe" | "reactions", "path": "tip.csv", "step": "Load", …} — writes probe or reaction results as a CSV file (RFC 4180, a header line, numbers in shortest round-trip form); the other parameters are solve.probe's (`nodes`, `at` or `set`, `mode`, `frame`) or results.reactions' (`sets`, `about`). Probe: a row per node with node, x, y, z and the quantities the step has; reactions: a row per set, a total row and an applied row. Replies with the path, rows, columns, step and units"#,
|
||||
form: Some(super::forms::EXPORT_CSV),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: export_csv,
|
||||
};
|
||||
|
||||
pub const CLEAR: CommandSpec = CommandSpec {
|
||||
id: "results.clear",
|
||||
label: "Hide Results",
|
||||
@@ -196,14 +206,52 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
|
||||
}
|
||||
let range = match p.get("range") {
|
||||
None => None,
|
||||
Some(r) => Some(legend_range(r)?),
|
||||
};
|
||||
let v = view_of(s, &step)?;
|
||||
if v.contour != field {
|
||||
(v.range_min, v.range_max) = (None, None);
|
||||
}
|
||||
v.contour = field;
|
||||
if let Some(b) = bands {
|
||||
v.bands = b;
|
||||
}
|
||||
if let Some((lo, hi)) = range {
|
||||
(v.range_min, v.range_max) = (lo, hi);
|
||||
}
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
/// `results.contour`'s `range`: `"auto"` or null (follow the field), or `{min?, max?}`, each a
|
||||
/// finite number, null or `"auto"`; `min` below `max`.
|
||||
fn legend_range(r: &Value) -> Result<(Option<f64>, Option<f64>)> {
|
||||
let bound = |k: &str| -> Result<Option<f64>> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(a)) if a == "auto" => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| EngineError::Params(format!("`range.{k}` must be a number or \"auto\"")))
|
||||
}
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Value::Null => Ok((None, None)),
|
||||
Value::String(a) if a == "auto" => Ok((None, None)),
|
||||
Value::Object(_) => {
|
||||
let (lo, hi) = (bound("min")?, bound("max")?);
|
||||
if let (Some(a), Some(b)) = (lo, hi)
|
||||
&& a >= b
|
||||
{
|
||||
return Err(EngineError::Params(format!("`range.min` ({a}) must be below `range.max` ({b})")));
|
||||
}
|
||||
Ok((lo, hi))
|
||||
}
|
||||
_ => Err(EngineError::Params("`range` must be {\"min\"?, \"max\"?} or \"auto\"".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn modes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = step_for(s, p, Want::Frequency)?;
|
||||
let count = match s.latest_result(&step).map(|(r, _)| r.kind()) {
|
||||
@@ -281,7 +329,8 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"step `{step}` is not a static step: reactions are kept for static steps (explicit steps keep the constraint force history, solve.history)"
|
||||
)));
|
||||
};
|
||||
let sets: Vec<String> = match p.get("sets") {
|
||||
// No sets (or an empty list, a form with none ticked): the step's boundary-condition sets.
|
||||
let sets: Vec<String> = match p.get("sets").filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) {
|
||||
Some(v) => {
|
||||
let list = v
|
||||
.as_array()
|
||||
@@ -330,6 +379,171 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
|
||||
}
|
||||
|
||||
fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what: String = super::req(p, "what")?;
|
||||
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
||||
let (step, table) = match what.as_str() {
|
||||
"probe" => {
|
||||
let r = super::solve::probe(s, p)?;
|
||||
(r.get("step").cloned().unwrap_or(Value::Null), probe_table(&r, &|label| s.model.node(label).map(|n| n.xyz))?)
|
||||
}
|
||||
"reactions" => {
|
||||
let r = reactions(s, p)?;
|
||||
if r.get("stale").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let step = r.get("step").and_then(Value::as_str).unwrap_or_default();
|
||||
return Err(EngineError::Params(format!(
|
||||
"the result of step `{step}` is stale (the model changed since it was solved); solve it again"
|
||||
)));
|
||||
}
|
||||
(r.get("step").cloned().unwrap_or(Value::Null), reactions_table(&r))
|
||||
}
|
||||
_ => return Err(EngineError::Params("`what` must be \"probe\" or \"reactions\"".into())),
|
||||
};
|
||||
let text = table.csv();
|
||||
fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), text.as_bytes(), false)?;
|
||||
Ok(json!({"path": path, "rows": table.rows.len(), "columns": table.header, "step": step, "units": s.model.units()}))
|
||||
}
|
||||
|
||||
/// A table of cells (a number, text, or empty) under a header, written as CSV.
|
||||
struct Table {
|
||||
header: Vec<String>,
|
||||
rows: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Cell {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// RFC 4180: comma-separated, CRLF line ends, a field with a comma, quote or line break in
|
||||
/// quotes (its quotes doubled); numbers in Rust's shortest round-trip form.
|
||||
fn csv(&self) -> String {
|
||||
let field = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
let mut out = String::new();
|
||||
let line = |cells: Vec<String>, out: &mut String| {
|
||||
out.push_str(&cells.join(","));
|
||||
out.push_str("\r\n");
|
||||
};
|
||||
line(self.header.iter().map(|h| field(h)).collect(), &mut out);
|
||||
for row in &self.rows {
|
||||
let cells = row
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Cell::Number(v) => format!("{v}"),
|
||||
Cell::Text(t) => field(t),
|
||||
Cell::Empty => String::new(),
|
||||
})
|
||||
.collect();
|
||||
line(cells, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rows of named cells into a table whose columns are every name that has a value, in the
|
||||
/// order first met; a column no row has a value in is left out.
|
||||
fn from_named(rows: Vec<Vec<(String, Cell)>>) -> Table {
|
||||
let mut header: Vec<String> = Vec::new();
|
||||
for row in &rows {
|
||||
for (name, c) in row {
|
||||
if *c != Cell::Empty && !header.contains(name) {
|
||||
header.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let rows = rows
|
||||
.into_iter()
|
||||
.map(|row| header.iter().map(|h| row.iter().find(|(n, _)| n == h).map_or(Cell::Empty, |(_, c)| c.clone())).collect())
|
||||
.collect();
|
||||
Table { header, rows }
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSV columns of a quantity of a probe's node entry: a vector's components (and a
|
||||
/// displacement's magnitude), a stress tensor's six components, a scalar under its own name.
|
||||
fn quantity_cells(key: &str, v: &Value, out: &mut Vec<(String, Cell)>) {
|
||||
const VECTORS: [(&str, &[&str]); 7] = [
|
||||
("displacement", &["ux", "uy", "uz"]),
|
||||
("rotation", &["rx", "ry", "rz"]),
|
||||
("reaction", &["rfx", "rfy", "rfz"]),
|
||||
("reaction_moment", &["rmx", "rmy", "rmz"]),
|
||||
("velocity", &["vx", "vy", "vz"]),
|
||||
("shape", &["ux", "uy", "uz", "rx", "ry", "rz"]),
|
||||
("stress", &["s_xx", "s_yy", "s_zz", "s_xy", "s_xz", "s_yz"]),
|
||||
];
|
||||
match v {
|
||||
Value::Number(n) => out.push((key.to_string(), n.as_f64().map_or(Cell::Empty, Cell::Number))),
|
||||
Value::Array(items) if items.iter().all(Value::is_number) => {
|
||||
let nums: Vec<f64> = items.iter().filter_map(Value::as_f64).collect();
|
||||
let names: Vec<String> = match VECTORS.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, n)) if n.len() == nums.len() => n.iter().map(|s| (*s).to_string()).collect(),
|
||||
_ => (1..=nums.len()).map(|i| format!("{key}_{i}")).collect(),
|
||||
};
|
||||
out.extend(names.into_iter().zip(nums.iter().map(|&x| Cell::Number(x))));
|
||||
if key == "displacement" && nums.len() == 3 {
|
||||
out.push(("u_mag".into(), Cell::Number(nums.iter().map(|x| x * x).sum::<f64>().sqrt())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `solve.probe`'s reply as a table: a row per node with its label, undeformed position and
|
||||
/// the quantities the step has.
|
||||
fn probe_table(r: &Value, position: &dyn Fn(u32) -> Option<[f64; 3]>) -> Result<Table> {
|
||||
let nodes = r.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("the probe has no node results to export".into()))?;
|
||||
let rows = nodes
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let label = e.get("node").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok());
|
||||
let mut row = vec![("node".to_string(), label.map_or(Cell::Empty, |l| Cell::Number(f64::from(l))))];
|
||||
if let Some(name) = e.get("reference_point").and_then(Value::as_str) {
|
||||
row.push(("reference_point".into(), Cell::Text(name.to_string())));
|
||||
}
|
||||
let xyz = label.and_then(position);
|
||||
for (k, axis) in ["x", "y", "z"].iter().enumerate() {
|
||||
row.push(((*axis).to_string(), xyz.and_then(|p| p.get(k).copied()).map_or(Cell::Empty, Cell::Number)));
|
||||
}
|
||||
if let Some(o) = e.as_object() {
|
||||
for (k, v) in o.iter().filter(|(k, _)| !matches!(k.as_str(), "node" | "reference_point" | "xyz" | "distance")) {
|
||||
quantity_cells(k, v, &mut row);
|
||||
}
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok(Table::from_named(rows))
|
||||
}
|
||||
|
||||
/// `results.reactions`' reply as a table: a row per set, the total and the applied load.
|
||||
fn reactions_table(r: &Value) -> Table {
|
||||
let row = |name: &str, e: &Value| {
|
||||
let mut row = vec![("set".to_string(), Cell::Text(name.to_string()))];
|
||||
row.push(("nodes".into(), e.get("nodes").and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number)));
|
||||
for (key, names) in [("force", ["fx", "fy", "fz"]), ("moment", ["mx", "my", "mz"])] {
|
||||
let v = e.get(key).and_then(Value::as_array);
|
||||
for (k, n) in names.iter().enumerate() {
|
||||
row.push(((*n).to_string(), v.and_then(|a| a.get(k)).and_then(Value::as_f64).map_or(Cell::Empty, Cell::Number)));
|
||||
}
|
||||
}
|
||||
row
|
||||
};
|
||||
let mut rows: Vec<Vec<(String, Cell)>> = r
|
||||
.get("sets")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| row(e.get("set").and_then(Value::as_str).unwrap_or_default(), e))
|
||||
.collect();
|
||||
rows.push(row("total", r.get("total").unwrap_or(&Value::Null)));
|
||||
if let Some(applied) = r.get("applied").filter(|a| a.is_array()) {
|
||||
rows.push(row("applied", &json!({"force": applied})));
|
||||
}
|
||||
Table::from_named(rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -343,6 +557,103 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// `range` clamps the legend (#191): kept with the field, cleared by "auto" or another
|
||||
/// field, reported in the reply; the true range is still there.
|
||||
/// A CSV reader (RFC 4180) for the tests: rows of fields, quotes undone.
|
||||
fn read_csv(text: &str) -> Vec<Vec<String>> {
|
||||
let (mut rows, mut row, mut field, mut quoted, mut chars) = (Vec::new(), Vec::new(), String::new(), false, text.chars().peekable());
|
||||
while let Some(c) = chars.next() {
|
||||
match (quoted, c) {
|
||||
(true, '"') if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
field.push('"');
|
||||
}
|
||||
(true, '"') => quoted = false,
|
||||
(true, c) => field.push(c),
|
||||
(false, '"') => quoted = true,
|
||||
(false, ',') => row.push(std::mem::take(&mut field)),
|
||||
(false, '\r') => {}
|
||||
(false, '\n') => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
(false, c) => field.push(c),
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn temp_csv(tag: &str) -> String {
|
||||
std::env::temp_dir().join(format!("fieldcraft-csv-{tag}-{}.csv", std::process::id())).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `results.export.csv` writes what results.reactions and solve.probe reply, bit for bit
|
||||
/// after parsing; a set name with a comma and a quote round-trips (#92).
|
||||
#[test]
|
||||
fn results_export_as_csv() {
|
||||
let mut s = solved();
|
||||
let path = temp_csv("reactions");
|
||||
let sets = json!(["root", "tip"]);
|
||||
let reply = s.execute("results.export.csv", &json!({"what": "reactions", "path": path, "step": "Bend", "sets": sets})).unwrap();
|
||||
let sums = s.execute("results.reactions", &json!({"step": "Bend", "sets": sets})).unwrap();
|
||||
let rows = read_csv(&std::fs::read_to_string(&path).unwrap());
|
||||
assert_eq!(rows[0], ["set", "nodes", "fx", "fy", "fz", "mx", "my", "mz"]);
|
||||
assert_eq!((reply["rows"].as_u64(), reply["step"].as_str()), (Some(4), Some("Bend")), "{reply}");
|
||||
// A name with a comma and a quote round-trips through a CSV reader.
|
||||
let named =
|
||||
Table { header: vec!["set".into(), "fx".into()], rows: vec![vec![Cell::Text("the \"root\", clamped".into()), Cell::Number(0.1)]] };
|
||||
assert_eq!(read_csv(&named.csv()), [vec!["set", "fx"], vec!["the \"root\", clamped", "0.1"]]);
|
||||
let total = rows.iter().find(|r| r[0] == "total").unwrap();
|
||||
assert_eq!(total[4].parse::<f64>().unwrap(), sums["total"]["force"][2].as_f64().unwrap(), "bit for bit");
|
||||
let applied = rows.iter().find(|r| r[0] == "applied").unwrap();
|
||||
assert_eq!((applied[1].as_str(), applied[5].as_str()), ("", ""), "the applied load has no nodes or moments");
|
||||
let path = temp_csv("probe");
|
||||
s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "nodes": [1, 2, 3]})).unwrap();
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1, 2, 3]})).unwrap();
|
||||
let rows = read_csv(&std::fs::read_to_string(&path).unwrap());
|
||||
assert_eq!(rows.len(), 4);
|
||||
let col = |name: &str| rows[0].iter().position(|h| h == name).unwrap_or_else(|| panic!("no {name}: {:?}", rows[0]));
|
||||
for (i, node) in probe["nodes"].as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(rows[i + 1][col("node")], node["node"].to_string());
|
||||
assert_eq!(rows[i + 1][col("uz")].parse::<f64>().unwrap(), node["displacement"][2].as_f64().unwrap());
|
||||
assert_eq!(rows[i + 1][col("s_yz")].parse::<f64>().unwrap(), node["stress"][5].as_f64().unwrap());
|
||||
assert_eq!(rows[i + 1][col("von_mises")].parse::<f64>().unwrap(), node["von_mises"].as_f64().unwrap());
|
||||
}
|
||||
assert!(!rows[0].iter().any(|h| h == "temperature"), "columns the step does not have are left out: {:?}", rows[0]);
|
||||
// A stale result is refused.
|
||||
// Every parameter the description names is taken: a set's nodes, points, a moment point.
|
||||
let set = s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "set": "tip"})).unwrap();
|
||||
assert!(set["rows"].as_u64().unwrap_or(0) > 0, "{set}");
|
||||
s.execute("results.export.csv", &json!({"what": "probe", "path": path, "step": "Bend", "at": [[0, 0, 0]]})).unwrap();
|
||||
s.execute("results.export.csv", &json!({"what": "reactions", "path": path, "step": "Bend", "about": [1, 0, 0]})).unwrap();
|
||||
s.execute("mesh.node.add", &json!({"label": 99_999, "xyz": [0, 0, 0]})).unwrap();
|
||||
assert!(s.execute("results.export.csv", &json!({"what": "reactions", "path": temp_csv("stale"), "step": "Bend"})).is_err());
|
||||
for p in ["reactions", "probe"] {
|
||||
let _ = std::fs::remove_file(temp_csv(p));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legend_range_can_be_clamped() {
|
||||
let mut s = solved();
|
||||
let r = s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": 10}})).unwrap();
|
||||
let top = r["contour"]["range"]["max"]["value"].as_f64().unwrap();
|
||||
assert!(top > 10.0, "{r}");
|
||||
assert_eq!(r["contour"]["legend"], json!({"min": null, "max": 10.0}), "{r}");
|
||||
let v = s.results_view.clone().unwrap();
|
||||
let b = v.bands(0.0, top);
|
||||
assert!(b.over && b.max == 10.0 && b.band(top) == Some(b.count));
|
||||
s.execute("results.contour", &json!({"field": "von_mises", "bands": 6})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().unwrap().range_max, Some(10.0), "kept with the field");
|
||||
s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": "auto", "min": 2}})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((Some(2.0), None)));
|
||||
s.execute("results.contour", &json!({"field": "displacement"})).unwrap();
|
||||
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((None, None)), "another field follows its own range");
|
||||
s.execute("results.contour", &json!({"field": "displacement", "range": {"max": 1e-3}})).unwrap();
|
||||
let r = s.execute("results.contour", &json!({"field": "displacement", "range": "auto"})).unwrap();
|
||||
assert!(r["contour"].get("legend").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_display_follows_the_commands_and_is_not_an_edit() {
|
||||
let mut s = solved();
|
||||
@@ -369,6 +680,13 @@ mod tests {
|
||||
assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err());
|
||||
s.execute("results.contour", &json!({"field": "displacement", "bands": 6})).unwrap();
|
||||
s.execute("results.animate", &json!({})).unwrap();
|
||||
// Changing the field keeps the deformation, its scale and the animation (#332).
|
||||
s.execute("results.deformed", &json!({"scale": 3})).unwrap();
|
||||
let r = s.execute("results.contour", &json!({"step": "Modes", "field": "displacement_z"})).unwrap();
|
||||
assert_eq!(
|
||||
(r["view"]["deformed"].as_bool(), r["view"]["scale"].as_f64(), r["view"]["animate"].as_bool()),
|
||||
(Some(true), Some(3.0), Some(true))
|
||||
);
|
||||
assert!(s.results_view.as_ref().is_some_and(|v| v.animate && v.bands == 6));
|
||||
s.execute("results.animate", &json!({"on": false})).unwrap();
|
||||
assert!(s.results_view.as_ref().is_some_and(|v| !v.animate));
|
||||
@@ -398,6 +716,8 @@ mod tests {
|
||||
let r = s.execute("results.reactions", &json!({"sets": ["root", "tip"], "about": [10, 0, 0]})).unwrap();
|
||||
assert!(r["sets"][0]["moment"][1].as_f64().unwrap().abs() < 1e-6, "{r}");
|
||||
assert_eq!(r["sets"][1]["force"][2].as_f64(), Some(0.0));
|
||||
let empty = s.execute("results.reactions", &json!({"sets": []})).unwrap();
|
||||
assert_eq!(empty["sets"][0]["set"], "root", "an empty list sums the step's boundary-condition sets: {empty}");
|
||||
assert!(s.execute("results.reactions", &json!({"step": "Modes"})).is_err());
|
||||
assert!(s.execute("results.reactions", &json!({"sets": ["nope"]})).is_err());
|
||||
}
|
||||
@@ -440,6 +760,17 @@ mod tests {
|
||||
json!({"field": 3, "bands": 1e300}),
|
||||
json!({"field": "von_mises", "bands": 0}),
|
||||
json!({"field": "von_mises", "bands": 25}),
|
||||
json!({"field": "von_mises", "range": {"max": "high"}}),
|
||||
json!({"field": "von_mises", "range": {"min": 5, "max": 5}}),
|
||||
json!({"field": "von_mises", "range": {"min": 1e308, "max": -1e308}}),
|
||||
json!({"field": "von_mises", "range": 7}),
|
||||
json!({"what": 7, "path": "x.csv"}),
|
||||
json!({"what": "probe"}),
|
||||
json!({"what": "probe", "path": "", "nodes": [1]}),
|
||||
json!({"what": "probe", "path": "/no/such/dir/x.csv", "nodes": [1]}),
|
||||
json!({"what": "probe", "path": "x.csv", "nodes": vec![1; 1_000_000]}),
|
||||
json!({"what": "reactions", "path": "x.csv", "step": "Modes"}),
|
||||
json!({"what": "reactions", "path": "x.csv", "step": "Nope"}),
|
||||
json!({"scale": -1}),
|
||||
json!({"scale": -0.0001}),
|
||||
json!({"scale": 1e300}),
|
||||
|
||||
+234
-22
@@ -30,7 +30,7 @@ pub const RUN: CommandSpec = CommandSpec {
|
||||
pub const STATIC: CommandSpec = CommandSpec {
|
||||
id: "solve.static",
|
||||
label: "Solve Static Step",
|
||||
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; the caps may only be lowered; `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
|
||||
params: r#"{"step": "Load", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — solves a linear static or coupled temperature–displacement step (the only one when `step` is left out) and keeps the result in the session; `max_dofs` may only be lowered; `max_factor_entries` defaults to 400000000 and may be deliberately raised up to 2000000000 (8 bytes per factor value; indices and workspace are additional); `threads` (1 to 256, by default the session's: `FIELDCRAFT_THREADS` or the cores) changes only the time, never the result"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
@@ -40,7 +40,7 @@ pub const STATIC: CommandSpec = CommandSpec {
|
||||
pub const NONLINEAR: CommandSpec = CommandSpec {
|
||||
id: "solve.nonlinear",
|
||||
label: "Solve Nonlinear Static Step",
|
||||
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment, and with contact pairs (interaction.contact.pair: mortar contact by augmented Lagrangian, Coulomb friction by a return map) each contact's force, normal force, largest pressure, closed and slipping nodes, deepest penetration and augmentations (solve.probe gives each secondary node's pressure, gap and status, and with friction its shear traction and slip); `max_dofs` and `max_newton_iterations` may only be lowered (the latter over the whole step); `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_NONLINEAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
@@ -50,7 +50,7 @@ pub const NONLINEAR: CommandSpec = CommandSpec {
|
||||
pub const HEAT: CommandSpec = CommandSpec {
|
||||
id: "solve.heat",
|
||||
label: "Solve Heat Transfer Step",
|
||||
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Heat", "max_dofs": 6000000, "max_factor_entries": 400000000, "threads": 8} — steady or transient heat conduction of a heat transfer step (the only one when `step` is left out): temperatures, heat flux and the heat flow at prescribed temperatures, kept in the session; `max_dofs` may only be lowered; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
@@ -60,7 +60,7 @@ pub const HEAT: CommandSpec = CommandSpec {
|
||||
pub const FREQUENCY: CommandSpec = CommandSpec {
|
||||
id: "solve.frequency",
|
||||
label: "Solve Frequency Step",
|
||||
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Modes", "mass": "consistent|lumped", "shift": 0.0, "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — natural frequencies and mode shapes of a frequency step (its modes and range); `max_iterations` caps the Lanczos vectors; the result stays in the session; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FREQUENCY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
@@ -70,7 +70,7 @@ pub const FREQUENCY: CommandSpec = CommandSpec {
|
||||
pub const BUCKLE: CommandSpec = CommandSpec {
|
||||
id: "solve.buckle",
|
||||
label: "Solve Buckling Step",
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `max_factor_entries` and `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_BUCKLE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection, Requirement::Step],
|
||||
@@ -117,6 +117,9 @@ pub const PROBE: CommandSpec = CommandSpec {
|
||||
run: probe,
|
||||
};
|
||||
|
||||
/// Largest explicit factor cap accepted by solve commands; the default remains 400M entries.
|
||||
pub(super) const FACTOR_ENTRY_CEILING: usize = 2_000_000_000;
|
||||
|
||||
/// Most nodes one probe lists.
|
||||
const MAX_PROBE: usize = 10_000;
|
||||
|
||||
@@ -154,7 +157,11 @@ fn threads(s: &Session, p: &Value) -> Result<usize> {
|
||||
fn limits(p: &Value) -> Result<Limits> {
|
||||
Ok(Limits {
|
||||
max_dofs: cap(p, "max_dofs", Limits::MAX_DOFS)?,
|
||||
max_factor_entries: cap(p, "max_factor_entries", Limits::MAX_FACTOR_ENTRIES)?,
|
||||
max_factor_entries: if p.get("max_factor_entries").is_none() {
|
||||
Limits::MAX_FACTOR_ENTRIES
|
||||
} else {
|
||||
cap(p, "max_factor_entries", FACTOR_ENTRY_CEILING)?
|
||||
},
|
||||
max_lanczos_vectors: cap(p, "max_iterations", Limits::MAX_LANCZOS_VECTORS)?,
|
||||
max_newton_iterations: cap(p, "max_newton_iterations", Limits::MAX_NEWTON_ITERATIONS)?,
|
||||
})
|
||||
@@ -168,6 +175,12 @@ fn extreme(values: impl Iterator<Item = (u32, f64)>, better: fn(f64, f64) -> boo
|
||||
}
|
||||
}
|
||||
|
||||
/// [`parallel::with_threads`], the caller's progress reporter carried onto the pool's thread.
|
||||
fn with_threads<R: Send>(threads: usize, f: impl FnOnce() -> R + Send) -> R {
|
||||
let progress = fieldcraft_model::progress::current();
|
||||
parallel::with_threads(threads, move || fieldcraft_model::progress::scoped(progress, f))
|
||||
}
|
||||
|
||||
fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
use fieldcraft_model::Procedure;
|
||||
type Solve = fn(&mut Session, &Value) -> Result<Value>;
|
||||
@@ -192,13 +205,30 @@ fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Params("the model has no steps to solve: add one (model.step.create)".into()));
|
||||
}
|
||||
let mut done = Vec::new();
|
||||
for (name, procedure, solve) in steps {
|
||||
let summary = solve(s, &json!({"step": name})).map_err(|e| EngineError::Params(format!("step `{name}`: {e}")))?;
|
||||
let count = steps.len();
|
||||
for (index, (name, procedure, solve)) in steps.into_iter().enumerate() {
|
||||
fieldcraft_model::progress::step(&name, index + 1, count);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let summary =
|
||||
solve(s, &json!({"step": name})).map_err(|e| if e.is_cancelled() { e } else { EngineError::Params(format!("step `{name}`: {e}")) })?;
|
||||
done.push(json!({"step": name, "procedure": procedure, "summary": summary}));
|
||||
}
|
||||
Ok(json!({"steps": done}))
|
||||
}
|
||||
|
||||
/// A sum of nodal values with its round-off set to 0: a component below a billionth of the sum
|
||||
/// of the terms' magnitudes is what cancelling left (a balanced load sums to ~1e-11, not 0).
|
||||
fn settled(total: [f64; 3], terms: &[[f64; 3]]) -> [f64; 3] {
|
||||
let mut out = total;
|
||||
for (k, c) in out.iter_mut().enumerate() {
|
||||
let scale: f64 = terms.iter().filter_map(|t| t.get(k)).map(|x| x.abs()).filter(|x| x.is_finite()).sum();
|
||||
if c.abs() <= 1e-9 * scale {
|
||||
*c = 0.0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn largest(values: impl Iterator<Item = (u32, f64)>) -> Value {
|
||||
extreme(values, |v, b| v > b)
|
||||
}
|
||||
@@ -211,7 +241,7 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_static(model, step.as_deref(), &limits))?;
|
||||
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -220,8 +250,8 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"factor_entries": r.stats.factor_entries,
|
||||
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
|
||||
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
|
||||
"applied_total": r.applied_total,
|
||||
"reaction_total": r.reaction_total,
|
||||
"applied_total": settled(r.applied_total, &r.reaction),
|
||||
"reaction_total": settled(r.reaction_total, &r.reaction),
|
||||
"reference_points": r.reference_points.iter().map(|p| json!({
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
@@ -239,7 +269,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_nonlinear(model, step.as_deref(), &limits))?;
|
||||
let history = r.nonlinear.as_ref();
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -264,8 +294,8 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
|
||||
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
|
||||
"max_plastic_strain": largest(r.elements.iter().flat_map(|e| e.plastic_strain.iter().map(|v| (e.label, *v)))),
|
||||
"applied_total": r.applied_total,
|
||||
"reaction_total": r.reaction_total,
|
||||
"applied_total": settled(r.applied_total, &r.reaction),
|
||||
"reaction_total": settled(r.reaction_total, &r.reaction),
|
||||
"reference_points": r.reference_points.iter().map(|p| json!({
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
@@ -282,7 +312,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_heat(model, step.as_deref(), &limits))?;
|
||||
let t = r.temperature();
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
@@ -295,6 +325,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"max_temperature": largest(r.nodes.iter().copied().zip(t.iter().copied())),
|
||||
"heat_supplied": r.applied_total,
|
||||
"heat_removed": r.reaction_total,
|
||||
"heat_flows": crate::results::heat_flows(&r),
|
||||
"warnings": r.warnings,
|
||||
});
|
||||
s.store_result(StepResult::Implicit(ImplicitResult::Heat(r)));
|
||||
@@ -307,7 +338,7 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mass = opt::<MassKind>(p, "mass")?.unwrap_or_default();
|
||||
let shift = opt::<f64>(p, "shift")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_frequency(model, step.as_deref(), &FrequencyOptions { mass, shift }, &limits))?;
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
"dofs": r.stats.dofs,
|
||||
@@ -330,7 +361,7 @@ fn run_buckle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let limits = limits(p)?;
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_buckle(model, step.as_deref(), &limits))?;
|
||||
let summary = json!({
|
||||
"step": r.step,
|
||||
"dofs": r.stats.dofs,
|
||||
@@ -354,7 +385,7 @@ fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || solve_explicit(model, step.as_deref(), &limits))?;
|
||||
let summary = explicit_summary(&r);
|
||||
s.store_result(StepResult::Explicit(r));
|
||||
Ok(summary)
|
||||
@@ -459,7 +490,7 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?;
|
||||
let obstacles = obstacles(s, domain)?;
|
||||
let model = &s.model;
|
||||
let r = parallel::with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
|
||||
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
|
||||
let summary = fluid_summary(&r);
|
||||
s.store_result(StepResult::Fluid(r));
|
||||
Ok(summary)
|
||||
@@ -541,7 +572,7 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"step": r.step, "frame": frame, "time": time, "points": out}))
|
||||
}
|
||||
|
||||
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::param("step", ProblemKind::Required))?;
|
||||
let result = s.result(&step).ok_or_else(|| {
|
||||
EngineError::Params(if s.latest_result(&step).is_some() {
|
||||
@@ -897,6 +928,76 @@ pub(crate) mod tests {
|
||||
assert!(e.contains("stale"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factor_cap_can_be_raised_deliberately_to_a_finite_ceiling() {
|
||||
assert_eq!(limits(&json!({})).unwrap(), Limits::default());
|
||||
for entries in [1, 400_000_000, 400_000_001, 467_000_000, 2_000_000_000] {
|
||||
assert_eq!(limits(&json!({"max_factor_entries": entries})).unwrap().max_factor_entries, entries);
|
||||
}
|
||||
for entries in
|
||||
[json!(null), json!(true), json!("467000000"), json!(-1), json!(0), json!(2.5), json!(2_000_000_001u64), json!(u64::MAX), json!(1e300)]
|
||||
{
|
||||
assert!(limits(&json!({"max_factor_entries": entries})).is_err(), "{entries}");
|
||||
}
|
||||
for params in [
|
||||
json!({"max_dofs": Limits::MAX_DOFS + 1}),
|
||||
json!({"max_iterations": Limits::MAX_LANCZOS_VECTORS + 1}),
|
||||
json!({"max_newton_iterations": Limits::MAX_NEWTON_ITERATIONS + 1}),
|
||||
] {
|
||||
assert!(limits(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for id in ["solve.static", "solve.nonlinear", "solve.heat", "solve.frequency", "solve.buckle"] {
|
||||
let schema = crate::schema::json_schema(crate::find_command(id).unwrap().form.unwrap());
|
||||
let factor = &schema["properties"]["max_factor_entries"];
|
||||
assert_eq!(factor["minimum"], 1, "{id}: {factor}");
|
||||
assert_eq!(factor["maximum"], 2_000_000_000u64, "{id}: {factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raised_factor_caps_solve_small_models_and_failures_keep_results() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
s.run_script(&json!([
|
||||
{"command": "model.section.edit", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {}}},
|
||||
{"command": "model.step.create", "params": {"name": "Nonlinear", "procedure": {"type": "nonlinear_static", "nonlinear_geometry": false}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
|
||||
{"command": "model.load.create", "params": {"name": "compress", "type": "force", "target": {"set": "tip"}, "vector": [-1, 0, 0]}},
|
||||
{"command": "model.step.create", "params": {"name": "Buckle", "procedure": {"type": "buckle", "modes": 1}, "boundary_conditions": ["clamp"], "loads": ["compress"]}},
|
||||
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "cold", "type": "temperature", "target": {"set": "root"}, "value": 0}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "hot", "type": "temperature", "target": {"set": "tip"}, "value": 100}},
|
||||
{"command": "model.step.create", "params": {"name": "Heat", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["cold", "hot"]}},
|
||||
])).unwrap();
|
||||
for (command, step) in [
|
||||
("solve.static", "Bend"),
|
||||
("solve.nonlinear", "Nonlinear"),
|
||||
("solve.heat", "Heat"),
|
||||
("solve.frequency", "Modes"),
|
||||
("solve.buckle", "Buckle"),
|
||||
] {
|
||||
let expected = s.execute(command, &json!({"step": step})).unwrap();
|
||||
let probe_params = json!({"step": step, "nodes": [6]});
|
||||
let probe = s.execute("solve.probe", &probe_params).unwrap();
|
||||
for entries in [400_000_001, 467_000_000, 2_000_000_000] {
|
||||
assert_eq!(s.execute(command, &json!({"step": step, "max_factor_entries": entries})).unwrap(), expected);
|
||||
assert_eq!(s.execute("solve.probe", &probe_params).unwrap(), probe);
|
||||
}
|
||||
let before = s.model.clone();
|
||||
let results = s.solved_steps().count();
|
||||
let journal = s.journal.len();
|
||||
for entries in [json!(1), json!(0), json!(-1), json!(2.5), json!(null), json!(2_000_000_001u64), json!(u64::MAX)] {
|
||||
let e = s.execute(command, &json!({"step": step, "max_factor_entries": entries})).unwrap_err().to_string();
|
||||
if entries == 1 {
|
||||
assert!(e.contains("bytes of factor values alone") && e.contains("limit is 1 entries"), "{command}: {e}");
|
||||
}
|
||||
assert_eq!(s.model, before, "{command}: {entries}");
|
||||
assert_eq!(s.solved_steps().count(), results);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert_eq!(s.execute("solve.probe", &probe_params).unwrap(), probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_hostile_params_never_panic() {
|
||||
let mut s = Session::default();
|
||||
@@ -1388,8 +1489,8 @@ pub(crate) mod tests {
|
||||
s.execute("model.step.create", &json!({"name": "Long", "procedure": long})).unwrap();
|
||||
assert!(s.execute("solve.explicit", &json!({"step": "Long"})).unwrap_err().to_string().contains("increments"));
|
||||
assert!(s.execute("model.set.delete", &json!({"name": "all_nodes"})).unwrap_err().to_string().contains("step `Impact`"));
|
||||
// Defining steps edited the model, so the impact result is stale.
|
||||
assert_eq!(s.solved_steps().count(), 0);
|
||||
// Defining other steps left what the impact step reads alone: its result is current.
|
||||
assert_eq!(s.solved_steps().collect::<Vec<_>>(), ["Impact"]);
|
||||
}
|
||||
|
||||
/// A mass on a spring and a dashpot, defined, solved and written through commands; editing
|
||||
@@ -1469,6 +1570,30 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A heat balance per load: the convection `Air` carries away what the heater's prescribed
|
||||
/// temperature supplies (#362).
|
||||
#[test]
|
||||
fn heat_flows_balance_per_load() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
let script = json!([
|
||||
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "root"}, "value": 100}},
|
||||
{"command": "model.set.create", "params": {"name": "skin", "faces": (1..=6).map(|f| json!({"element": 10, "face": f})).collect::<Vec<_>>()}},
|
||||
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.5, "sink": 20}},
|
||||
{"command": "model.step.create", "params": {"name": "Therm", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["Heater"], "loads": ["Air"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
let r = s.execute("solve.heat", &json!({"step": "Therm"})).unwrap();
|
||||
let flows = &r["heat_flows"];
|
||||
let air = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Air").unwrap();
|
||||
assert_eq!(air["type"], "convection");
|
||||
let (out, supplied) = (air["heat"].as_f64().unwrap(), flows["reactions"].as_f64().unwrap());
|
||||
assert!(out < 0.0 && supplied > 0.0, "{flows}");
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-6 * supplied, "{flows}");
|
||||
assert_eq!(s.results_report()["heat_flows"], *flows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_heat_and_probe_temperatures() {
|
||||
// The cantilever bar as a conductor: 0 at the root, 100 at the tip, linear between.
|
||||
@@ -1633,4 +1758,91 @@ pub(crate) mod tests {
|
||||
let v = s.execute("results.contour", &json!({"step": "Squeeze", "field": "contact_shear"})).unwrap();
|
||||
assert!(v["contour"]["range"]["max"]["value"].as_f64().unwrap() > 1.0, "{v}");
|
||||
}
|
||||
|
||||
/// A chip with a pad on it, both in a pocket of air (tet4), the chip and pad hex8 at other
|
||||
/// sizes: tie `air` holds the chip's and the pad's sides on the air, tie `chip` the pad's
|
||||
/// underside on the chip. The pad's bottom edge is on both ties' secondary surfaces, and the
|
||||
/// chip's top edge, the main nodes there, follows the air: two constraints per edge node that
|
||||
/// nearly repeat each other.
|
||||
fn chip_pad_air(s: &mut Session) {
|
||||
let (_dir, opened) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 6, "corner": [0, 0, 0], "body_name": "Air"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 3, "corner": [6, 6, 0], "operation": "cut"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 2, "corner": [6, 6, 0], "operation": "new_body", "body_name": "Chip"}},
|
||||
{"command": "solid.box", "params": {"length": 8, "width": 8, "height": 1, "corner": [6, 6, 2], "operation": "new_body", "body_name": "Pad"}},
|
||||
]));
|
||||
*s = opened;
|
||||
std::mem::forget(_dir);
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Air", "size": 2.0})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Chip", "size": 0.8})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Pad", "size": 0.45})).unwrap();
|
||||
let m = &s.model;
|
||||
// Element faces of a body all of whose nodes satisfy `on`.
|
||||
let faces = |body: &str, on: &dyn Fn([f64; 3]) -> bool| -> Vec<Value> {
|
||||
m.body_elements(body)
|
||||
.into_iter()
|
||||
.flat_map(|e| (1..=m.element(e).unwrap().kind.faces().len() as u8).map(move |f| (e, f)))
|
||||
.filter(|&(e, f)| {
|
||||
let ns = m.face_nodes(fieldcraft_model::ElementFace { element: e, face: f }).unwrap();
|
||||
ns.iter().all(|n| on(m.node(*n).unwrap().xyz))
|
||||
})
|
||||
.map(|(e, f)| json!({"element": e, "face": f}))
|
||||
.collect()
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
let side = |p: [f64; 3]| near(p[0], 6.0) || near(p[0], 14.0) || near(p[1], 6.0) || near(p[1], 14.0);
|
||||
let pocket = |p: [f64; 3]| {
|
||||
(side(p) && p[2] < 3.0 + 1e-6 && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
|| (near(p[2], 3.0) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[0]) && (6.0 - 1e-6..=14.0 + 1e-6).contains(&p[1]))
|
||||
};
|
||||
let outer = |p: [f64; 3]| near(p[0], 0.0) || near(p[0], 20.0) || near(p[1], 0.0) || near(p[1], 20.0) || near(p[2], 6.0);
|
||||
let mut sides = faces("Chip", &|p| side(p));
|
||||
sides.extend(faces("Pad", &|p| side(p)));
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "chip", "elements": m.body_elements("Chip")}},
|
||||
{"command": "model.set.create", "params": {"name": "pocket", "faces": faces("Air", &pocket)}},
|
||||
{"command": "model.set.create", "params": {"name": "sides", "faces": sides}},
|
||||
{"command": "model.set.create", "params": {"name": "chip_top", "faces": faces("Chip", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "pad_bottom", "faces": faces("Pad", &|p| near(p[2], 2.0))}},
|
||||
{"command": "model.set.create", "params": {"name": "outside", "faces": faces("Air", &outer)}},
|
||||
{"command": "model.material.create", "params": {"name": "M", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0.3}, "thermal": {"conductivity": 0.5}}},
|
||||
{"command": "model.section.create", "params": {"name": "S", "set": "all", "material": "M", "kind": {"type": "solid"}}},
|
||||
{"command": "interaction.tie", "params": {"name": "air", "main": "pocket", "secondary": "sides", "position_tolerance": 0.1}},
|
||||
{"command": "interaction.tie", "params": {"name": "chip", "main": "chip_top", "secondary": "pad_bottom", "position_tolerance": 0.1}},
|
||||
{"command": "model.load.create", "params": {"name": "Q", "type": "heat_source", "set": "chip", "power": 1}},
|
||||
{"command": "model.load.create", "params": {"name": "Room", "type": "convection", "set": "outside", "film": 0.01, "sink": 25}},
|
||||
{"command": "model.step.create", "params": {"name": "Steady", "procedure": {"type": "heat_transfer"}, "loads": ["Q", "Room"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
}
|
||||
|
||||
/// #389: a node on the secondary surfaces of two ties follows their main surfaces together,
|
||||
/// so the heat balances to round-off and no temperature drops below the surroundings'. (With
|
||||
/// a constraint per tie, this model put 335 W more in than the 128 W supplied, and some
|
||||
/// temperatures below 0.)
|
||||
#[test]
|
||||
fn nodes_in_two_ties_keep_the_heat_balance() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
let r = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap();
|
||||
let flows = &r["heat_flows"];
|
||||
let supplied = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Q").unwrap()["heat"].as_f64().unwrap();
|
||||
assert!((supplied - 128.0).abs() < 1e-9, "{flows}");
|
||||
assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-9 * supplied, "{flows}");
|
||||
assert!(r["min_temperature"]["value"].as_f64().unwrap() > 25.0, "{r}");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("ties `air`, `chip`"), "{warnings}");
|
||||
}
|
||||
|
||||
/// Two ties on one interface in opposite directions hold every node there twice: an error
|
||||
/// naming both, not a field.
|
||||
#[test]
|
||||
fn ties_both_ways_on_one_interface_are_an_error_naming_them() {
|
||||
let mut s = Session::default();
|
||||
chip_pad_air(&mut s);
|
||||
s.execute("interaction.tie", &json!({"name": "back", "main": "pad_bottom", "secondary": "chip_top", "position_tolerance": 0.1})).unwrap();
|
||||
let e = s.execute("solve.heat", &json!({"step": "Steady"})).unwrap_err().to_string();
|
||||
assert!(e.contains("tie `back`") && e.contains("tie `chip`"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,7 @@ const ON_PLANE: f64 = 1e-6;
|
||||
fn symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let datum: String = super::req(p, "datum")?;
|
||||
let (point, normal) = s
|
||||
.model
|
||||
.datum(&datum)
|
||||
.and_then(|d| d.plane())
|
||||
.ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", datum.chars().take(64).collect::<String>())))?;
|
||||
let (point, normal) = super::datum::datum_plane_of(&s.model, &datum)?;
|
||||
let axis = (0..3).find(|&k| (normal[k].abs() - 1.0).abs() < 1e-9).ok_or_else(|| {
|
||||
EngineError::Params(format!("not supported yet: symmetry about a plane not normal to X, Y or Z (datum `{datum}` has normal {normal:?})"))
|
||||
})?;
|
||||
|
||||
@@ -113,19 +113,39 @@ pub(crate) fn forget_missing(s: &mut Session) {
|
||||
impl Session {
|
||||
/// The analysis setup's symbols, without the definitions the view hides.
|
||||
pub fn glyphs(&self) -> fieldcraft_render::glyphs::Glyphs {
|
||||
self.glyphs_of(None)
|
||||
}
|
||||
|
||||
/// The symbols of what solved step `step` used, over its results: its own boundary
|
||||
/// conditions and loads (none for a frequency step, which has no loads) and its own initial
|
||||
/// velocities and rigid walls; the interactions as always. Without the view's hidden items.
|
||||
pub fn step_glyphs(&self, step: &str) -> fieldcraft_render::glyphs::Glyphs {
|
||||
self.glyphs_of(Some(step))
|
||||
}
|
||||
|
||||
fn glyphs_of(&self, step: Option<&str>) -> fieldcraft_render::glyphs::Glyphs {
|
||||
use fieldcraft_model::Procedure;
|
||||
use fieldcraft_render::glyphs::Definition;
|
||||
let step = step.map(|name| self.model.step(name));
|
||||
let frequency = matches!(step, Some(Some(s)) if matches!(s.procedure, Procedure::Frequency { .. }));
|
||||
fieldcraft_render::glyphs::Glyphs::from_model_hiding(&self.model, &|definition, name| {
|
||||
let kind = match definition {
|
||||
Definition::BoundaryCondition => ItemKind::BoundaryCondition,
|
||||
Definition::Load => ItemKind::Load,
|
||||
Definition::ReferencePoint => ItemKind::ReferencePoint,
|
||||
Definition::Coupling => ItemKind::Coupling,
|
||||
Definition::Contact | Definition::Tie | Definition::Step => return false,
|
||||
let (kind, used) = match definition {
|
||||
Definition::BoundaryCondition => (ItemKind::BoundaryCondition, step.map(|s| s.is_some_and(|s| s.boundary_conditions.contains(name)))),
|
||||
Definition::Load => (ItemKind::Load, step.map(|s| !frequency && s.is_some_and(|s| s.loads.contains(name)))),
|
||||
Definition::ReferencePoint => (ItemKind::ReferencePoint, None),
|
||||
Definition::Coupling => (ItemKind::Coupling, None),
|
||||
Definition::Step => return step.is_some_and(|s| s.is_none_or(|s| s.name != name)),
|
||||
Definition::Contact | Definition::Tie => return false,
|
||||
};
|
||||
self.view.is_hidden(kind, name)
|
||||
used == Some(false) || self.view.is_hidden(kind, name)
|
||||
})
|
||||
}
|
||||
|
||||
/// The elements of the bodies the view hides: hiding a body hides its mesh and its results.
|
||||
pub fn hidden_elements(&self) -> std::collections::BTreeSet<u32> {
|
||||
self.view.hidden.iter().filter(|i| i.kind == ItemKind::Body).flat_map(|i| self.model.body_elements(&i.name)).collect()
|
||||
}
|
||||
|
||||
/// Whether the view shows item `name` of `kind`.
|
||||
pub fn shows(&self, kind: ItemKind, name: &str) -> bool {
|
||||
!self.view.is_hidden(kind, name)
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod crash;
|
||||
pub mod logfile;
|
||||
|
||||
pub use cmd::analytic_surfaces;
|
||||
pub use cmd::replace::{MESHERS, Replacement, replacement};
|
||||
pub use cmd::replace::{MESHERS, Replacement, replacement, thousands};
|
||||
pub use cmd::update::enable_checks as enable_update_checks;
|
||||
pub use fieldcraft_io::{Item as ViewItem, ItemKind};
|
||||
pub mod history;
|
||||
@@ -79,6 +79,29 @@ pub enum EngineError {
|
||||
}
|
||||
|
||||
impl EngineError {
|
||||
/// Whether the command stopped because it was cancelled
|
||||
/// ([`fieldcraft_model::progress::Progress::cancel`]).
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
use fieldcraft_model::Error::Cancelled;
|
||||
matches!(
|
||||
self,
|
||||
EngineError::Model(Cancelled)
|
||||
| EngineError::Mesh(fieldcraft_mesh::Error::Model(Cancelled))
|
||||
| EngineError::Solver(fieldcraft_solver::Error::Model(Cancelled))
|
||||
| EngineError::Explicit(fieldcraft_explicit::Error::Model(Cancelled))
|
||||
| EngineError::Fluid(fieldcraft_lbm::Error::Model(Cancelled) | fieldcraft_lbm::Error::Mesh(fieldcraft_mesh::Error::Model(Cancelled)))
|
||||
)
|
||||
}
|
||||
|
||||
/// A body's mesher failure as a parameter problem naming the body, or the cancellation as
|
||||
/// it is.
|
||||
pub(crate) fn of_body(body: &str, e: fieldcraft_mesh::Error) -> Self {
|
||||
match e {
|
||||
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
|
||||
e => EngineError::Params(format!("body `{body}`: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A problem with the parameter at `path` (`elastic/young`).
|
||||
pub fn param(path: impl Into<String>, kind: schema::ProblemKind) -> Self {
|
||||
Self::Param(schema::Problem::at(path, kind))
|
||||
@@ -171,6 +194,16 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
commands().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// Refuse a parameter the command does not take, naming the ones it does: a script that sets
|
||||
/// `deformed` on `results.contour` learns it belongs elsewhere instead of being ignored.
|
||||
fn check_keys(spec: &CommandSpec, params: &Value) -> Result<()> {
|
||||
match spec.form.and_then(|form| schema::unknown_key(form, params)) {
|
||||
Some((key, accepted)) if accepted.is_empty() => Err(EngineError::Params(format!("{} takes no parameters (got `{key}`)", spec.id))),
|
||||
Some((key, accepted)) => Err(EngineError::Params(format!("{} has no parameter `{key}`; it takes {}", spec.id, accepted.join(", ")))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new revision number, unique in the process: every state of every session has its own.
|
||||
pub(crate) fn next_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -224,6 +257,9 @@ pub struct Session {
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// The `.fieldcraft` file the session was opened from or last saved to.
|
||||
pub path: Option<String>,
|
||||
/// The sample the session was opened as (`file.sample`), by its label; the document is named
|
||||
/// after it until it is saved under a name of its own.
|
||||
pub sample: Option<&'static str>,
|
||||
history: History,
|
||||
/// Identifies the current state: it changes with every edit, undo and redo, and comes back
|
||||
/// when an undo returns to an earlier state.
|
||||
@@ -234,6 +270,9 @@ pub struct Session {
|
||||
/// The latest result of each solved step, by step name, with the revision it was solved at;
|
||||
/// [`Session::result`] hands out only those that describe the current state.
|
||||
results: BTreeMap<String, (u64, Arc<StepResult>)>,
|
||||
/// What each solved step read ([`results::inputs_fingerprint`]): an edit that leaves it the
|
||||
/// same keeps the step's result current.
|
||||
result_inputs: BTreeMap<String, u64>,
|
||||
/// What the results display shows (`results.*`); `None` shows the model.
|
||||
pub results_view: Option<results::ResultsView>,
|
||||
/// The default work caps of commands; a file replacing the session keeps them.
|
||||
@@ -266,7 +305,11 @@ impl Session {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?;
|
||||
let timer = usage::Timer::start(self.origin);
|
||||
let elements = self.model.elements().len();
|
||||
let result = self.availability(spec).map_err(EngineError::Unavailable).and_then(|()| self.run_command(spec, params));
|
||||
let result = self
|
||||
.availability(spec)
|
||||
.map_err(EngineError::Unavailable)
|
||||
.and_then(|()| check_keys(spec, params))
|
||||
.and_then(|()| self.run_command(spec, params));
|
||||
if timer.counting() {
|
||||
let milestone = usage::milestone(spec.id, (elements, self.model.elements().len()));
|
||||
let sample =
|
||||
@@ -285,7 +328,8 @@ impl Session {
|
||||
let scope = crash::Scope::enter();
|
||||
let journaled = crash::command_begin(&scope, self, id, params);
|
||||
let (revision, opened) = (self.revision, self.opened);
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let outcome =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params).and_then(|v| cmd::relax::after_remesh(self, id, v))));
|
||||
let result = match outcome {
|
||||
Ok(Err(EngineError::Param(p))) => Err(EngineError::Param(match spec.form {
|
||||
Some(form) => schema::refine(form, params, p),
|
||||
@@ -314,6 +358,9 @@ impl Session {
|
||||
cmd::view::forget_missing(self);
|
||||
}
|
||||
}
|
||||
if self.revision != revision {
|
||||
self.refresh_results();
|
||||
}
|
||||
drop(scope);
|
||||
crash::command_done(self, journaled, self.revision != revision || self.opened != opened, id, params, &result);
|
||||
result
|
||||
@@ -379,7 +426,25 @@ impl Session {
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.results.insert(result.step().to_string(), (self.revision, Arc::new(result)));
|
||||
let step = result.step().to_string();
|
||||
match results::inputs_fingerprint(&self.model, &step) {
|
||||
Some(f) => self.result_inputs.insert(step.clone(), f),
|
||||
None => self.result_inputs.remove(&step),
|
||||
};
|
||||
self.results.insert(step, (self.revision, Arc::new(result)));
|
||||
}
|
||||
|
||||
/// After the state changed (an edit, undo or redo): a result whose step reads what it read
|
||||
/// when it was solved describes the new state too, so it stays (or is again) current; the
|
||||
/// others go stale.
|
||||
fn refresh_results(&mut self) {
|
||||
let revision = self.revision;
|
||||
for (step, (solved, _)) in self.results.iter_mut().filter(|(_, (r, _))| *r != revision) {
|
||||
let read = self.result_inputs.get(step).copied();
|
||||
if read.is_some() && read == results::inputs_fingerprint(&self.model, step) {
|
||||
*solved = revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path to use for `access` to the file at `path`, checked against [`Session::file_access`].
|
||||
@@ -399,6 +464,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cancel_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -717,6 +785,7 @@ mod tests {
|
||||
let opened = s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(opened["bodies"], json!(["Body1"]));
|
||||
assert_eq!(opened["errors"], json!([]));
|
||||
assert_eq!(opened["warnings"], json!([]));
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
|
||||
@@ -97,6 +97,9 @@ pub enum Status {
|
||||
Blocked,
|
||||
/// Not done, and not needed for this model.
|
||||
Optional,
|
||||
/// Done, but what it was made from changed since (`detail` says what): a mesh made before
|
||||
/// its seeds or partitions changed.
|
||||
Outdated,
|
||||
}
|
||||
|
||||
/// One row of the checklist.
|
||||
@@ -128,6 +131,11 @@ impl Session {
|
||||
// Models without elements (point masses, fluid domains) need no material or section.
|
||||
let elementless = analysable && m.elements().is_empty();
|
||||
let modal_only = !m.steps().is_empty() && m.steps().iter().all(|st| matches!(st.procedure, Procedure::Frequency { .. }));
|
||||
// Transient dynamics may leave a body free and unloaded (a drop, a flight onto a wall: initial
|
||||
// velocities drive it): no support or load needed.
|
||||
let dynamic_only = !m.steps().is_empty() && m.steps().iter().all(|st| st.procedure.is_dynamic());
|
||||
// A model driven by a prescribed displacement or temperature needs no load either.
|
||||
let driven = m.boundary_conditions().values().any(|bc| bc.drives());
|
||||
let mut next_given = false;
|
||||
Stage::ALL
|
||||
.into_iter()
|
||||
@@ -137,7 +145,8 @@ impl Session {
|
||||
let optional = match stage {
|
||||
Stage::Prepare => true,
|
||||
Stage::Material | Stage::Section => elementless,
|
||||
Stage::Loads => modal_only,
|
||||
Stage::BoundaryConditions => dynamic_only,
|
||||
Stage::Loads => modal_only || driven || dynamic_only,
|
||||
_ => false,
|
||||
};
|
||||
let command = r.provider();
|
||||
@@ -146,7 +155,10 @@ impl Session {
|
||||
_ if stage == Stage::Solve => first_error.clone(),
|
||||
_ => None,
|
||||
};
|
||||
let (status, detail) = if done {
|
||||
let outdated = if done && stage == Stage::Mesh { self.mesh_outdated() } else { None };
|
||||
let (status, detail) = if let Some(why) = outdated {
|
||||
(Status::Outdated, Some(why))
|
||||
} else if done {
|
||||
(Status::Done, None)
|
||||
} else if optional {
|
||||
(Status::Optional, None)
|
||||
@@ -165,6 +177,35 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Why the mesh no longer matches what steers it: seeds, partitions or the datums they cut
|
||||
/// by changed after the last mesher ran (the journal says what ran when). `None` when it
|
||||
/// matches, or no mesher made it.
|
||||
pub fn mesh_outdated(&self) -> Option<String> {
|
||||
let mesher = self.journal.iter().rposition(|(c, _)| crate::MESHERS.iter().any(|(m, _)| m == c))?;
|
||||
let (command, _) = self.journal.get(mesher)?;
|
||||
let mut changed = Vec::new();
|
||||
for (c, _) in self.journal.get(mesher + 1..).unwrap_or_default() {
|
||||
let what = match c.as_str() {
|
||||
"mesh.seed.edge" | "mesh.seed.clear" => "Seeds",
|
||||
"geometry.partition" | "geometry.partition.delete" | "geometry.partition.suppress" => "Partitions",
|
||||
"geometry.datum.edit" | "geometry.datum.suppress" => "Datum planes",
|
||||
_ => continue,
|
||||
};
|
||||
if !changed.contains(&what) {
|
||||
changed.push(what);
|
||||
}
|
||||
}
|
||||
let place = crate::catalogue::entry_of(command).map_or_else(|| command.clone(), |e| format!("{} › {}", e.tab.label(), e.label));
|
||||
let what = match changed.as_slice() {
|
||||
[] => return None,
|
||||
[one] => one.to_string(),
|
||||
[first @ .., last] => format!("{} and {}", first.join(", "), last.to_lowercase()),
|
||||
};
|
||||
Some(format!("{what} changed since this mesh was made — {place} to remesh"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The checklist as JSON, with the next stage's command first for agents:
|
||||
/// `{"next": {"stage", "command"} | null, "stages": [...]}`.
|
||||
pub fn to_json(stages: &[StageState]) -> Value {
|
||||
@@ -234,6 +275,48 @@ mod tests {
|
||||
assert_eq!((status(&s, Stage::Results), to_json(&s.progress())["next"]["command"].clone()), (Next, json!("results.contour")));
|
||||
}
|
||||
|
||||
/// A model driven by a prescribed displacement (rubber-seal) needs no load: the checklist
|
||||
/// agrees with the model check and offers the solve next.
|
||||
#[test]
|
||||
fn a_displacement_driven_model_needs_no_load() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::rubber_seal()).unwrap();
|
||||
assert!(s.model.loads().is_empty());
|
||||
assert_eq!(status(&s, Stage::Loads), Optional);
|
||||
assert_eq!(status(&s, Stage::Solve), Next, "{:?}", s.progress());
|
||||
}
|
||||
|
||||
/// A free body in transient dynamics (drop-test: no support, only gravity and the floor)
|
||||
/// needs no boundary condition, nor a load (impact): the checklist offers the solve next.
|
||||
#[test]
|
||||
fn a_free_body_in_explicit_dynamics_needs_no_boundary_condition() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "drop-test"})).unwrap();
|
||||
assert!(s.model.boundary_conditions().is_empty());
|
||||
assert_eq!(status(&s, Stage::BoundaryConditions), Optional);
|
||||
assert_eq!(status(&s, Stage::Solve), Next, "{:?}", s.progress());
|
||||
// impact: no support and no load, only an initial velocity onto a rigid wall.
|
||||
s.execute("file.sample", &json!({"name": "impact"})).unwrap();
|
||||
assert!(s.model.loads().is_empty() && s.model.boundary_conditions().is_empty());
|
||||
assert_eq!((status(&s, Stage::BoundaryConditions), status(&s, Stage::Loads)), (Optional, Optional));
|
||||
assert_eq!(status(&s, Stage::Solve), Next, "{:?}", s.progress());
|
||||
}
|
||||
|
||||
/// A seed added after meshing marks the mesh out of date, until a remesh (#342).
|
||||
#[test]
|
||||
fn a_seed_after_the_mesh_marks_it_outdated() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.sample", &json!({"name": "bracket-hex"})).unwrap();
|
||||
assert_eq!(status(&s, Stage::Mesh), Done);
|
||||
let edge = json!({"body": "Bracket.17", "edge": "F17:face:5|F2:side:l1"});
|
||||
s.execute("mesh.seed.edge", &json!({"edges": [edge], "count": 6})).unwrap();
|
||||
assert_eq!(status(&s, Stage::Mesh), Outdated);
|
||||
let why = s.mesh_outdated().unwrap();
|
||||
assert!(why.starts_with("Seeds changed since this mesh was made — Mesh › "), "{why}");
|
||||
let v = s.execute("model.check", &json!({})).unwrap();
|
||||
assert!(v.to_string().contains("Seeds changed since this mesh was made"), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_check_returns_the_checklist() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+257
-10
@@ -8,11 +8,12 @@
|
||||
//! earlier revision is still shown, marked stale ([`Session::latest_result`]).
|
||||
|
||||
use fieldcraft_explicit::{ExplicitResult, Frame};
|
||||
use fieldcraft_model::Model;
|
||||
use fieldcraft_model::{Model, UnitSystem};
|
||||
use fieldcraft_solver::{FrequencyResult, HeatResult, StaticResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::schema::Dim;
|
||||
use crate::{EngineError, Result, Session, StepResult};
|
||||
|
||||
/// A result by its procedure.
|
||||
@@ -182,6 +183,49 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// What it measures.
|
||||
pub fn quantity(self) -> Quantity {
|
||||
match self {
|
||||
Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length,
|
||||
Field::Velocity => Quantity::Velocity,
|
||||
Field::Temperature => Quantity::Temperature,
|
||||
Field::HeatFlux => Quantity::HeatFlux,
|
||||
Field::ContactGap | Field::ContactStatus => Quantity::None,
|
||||
Field::ContactPressure
|
||||
| Field::ContactShear
|
||||
| Field::VonMises
|
||||
| Field::MaxPrincipal
|
||||
| Field::MidPrincipal
|
||||
| Field::MinPrincipal
|
||||
| Field::StressXx
|
||||
| Field::StressYy
|
||||
| Field::StressZz
|
||||
| Field::StressXy
|
||||
| Field::StressXz
|
||||
| Field::StressYz => Quantity::Stress,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `r` holds it as a mode shape: normalised, with no unit.
|
||||
pub fn is_mode_shape(self, r: &StepResult) -> bool {
|
||||
matches!(r.kind(), Kind::Frequency(_)) && self.quantity() == Quantity::Length
|
||||
}
|
||||
|
||||
/// Its unit for result `r` in `system`: none for a mode shape (it is normalised).
|
||||
pub fn unit(self, r: &StepResult, system: UnitSystem) -> Option<String> {
|
||||
if self.is_mode_shape(r) { None } else { self.quantity().unit(system) }
|
||||
}
|
||||
|
||||
/// Its title over result `r`: the label with its unit (`Von Mises stress [MPa]`), or, for
|
||||
/// a mode shape, saying it is normalised.
|
||||
pub fn title(self, r: &StepResult, system: UnitSystem) -> String {
|
||||
if self.is_mode_shape(r) {
|
||||
let label = self.label().trim_end_matches(" magnitude");
|
||||
return format!("{label} (mode shape, normalised)");
|
||||
}
|
||||
with_unit(self.label(), self.unit(r, system).as_deref())
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<Field> {
|
||||
Field::ALL.into_iter().find(|f| f.id() == id)
|
||||
}
|
||||
@@ -238,6 +282,76 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a number the results show measures: its unit follows the model's unit system
|
||||
/// ([`Quantity::unit`]). The legend, the probe, the reaction sums and the solve summary all name
|
||||
/// their units through it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Quantity {
|
||||
/// No unit (a ratio, a code, a normalised value).
|
||||
None,
|
||||
Length,
|
||||
Stress,
|
||||
Force,
|
||||
Moment,
|
||||
Mass,
|
||||
Time,
|
||||
Velocity,
|
||||
Temperature,
|
||||
/// Energy (work): force × length.
|
||||
Energy,
|
||||
/// Heat flow (power): energy per time.
|
||||
HeatFlow,
|
||||
/// Heat flux: heat flow per area.
|
||||
HeatFlux,
|
||||
}
|
||||
|
||||
impl Quantity {
|
||||
/// Its unit in `system` (`mm`, `MPa`, `N·mm`, `mW`…), or `None` (no unit, or the system is
|
||||
/// unspecified). The model never converts, so this only names the system's own unit; a
|
||||
/// temperature is in whatever scale its values were entered in.
|
||||
pub fn unit(self, system: UnitSystem) -> Option<String> {
|
||||
let dim = match self {
|
||||
Quantity::None => return None,
|
||||
Quantity::Length => Dim::Length,
|
||||
Quantity::Stress => Dim::Stress,
|
||||
Quantity::Force => Dim::Force,
|
||||
Quantity::Moment => Dim::Moment,
|
||||
Quantity::Mass => Dim::Mass,
|
||||
Quantity::Time => Dim::Time,
|
||||
Quantity::Velocity => Dim::Velocity,
|
||||
Quantity::Temperature | Quantity::Energy | Quantity::HeatFlow | Quantity::HeatFlux => {
|
||||
// Named units where the system's force × length (per time) has one.
|
||||
let (energy, power, flux) = match system {
|
||||
UnitSystem::Unspecified => return None,
|
||||
UnitSystem::MKgS => ("J", "W", "W/m²"),
|
||||
UnitSystem::MmTonneS => ("mJ", "mW", "mW/mm²"),
|
||||
UnitSystem::MmKgMs => ("J", "kW", "kW/mm²"),
|
||||
UnitSystem::MmGMs => ("mJ", "W", "W/mm²"),
|
||||
UnitSystem::InLbfS => ("lbf·in", "lbf·in/s", "lbf/(in·s)"),
|
||||
};
|
||||
return Some(
|
||||
match self {
|
||||
Quantity::Energy => energy,
|
||||
Quantity::HeatFlow => power,
|
||||
Quantity::HeatFlux => flux,
|
||||
_ => "°C or K",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
dim.unit(system)
|
||||
}
|
||||
}
|
||||
|
||||
/// `label` with `unit` in brackets when there is one: `Von Mises stress [MPa]`.
|
||||
pub fn with_unit(label: &str, unit: Option<&str>) -> String {
|
||||
match unit {
|
||||
Some(u) => format!("{label} [{u}]"),
|
||||
None => label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most contour bands.
|
||||
pub const MAX_BANDS: u32 = 24;
|
||||
/// Fewest contour bands.
|
||||
@@ -258,6 +372,12 @@ pub struct ResultsView {
|
||||
/// Contours of this field; `None` draws the deformed shape in element colours.
|
||||
pub contour: Option<Field>,
|
||||
pub bands: u32,
|
||||
/// The legend's range, clamped below the field's smallest or above its largest value (each
|
||||
/// `None` follows the field): a singular peak no longer squeezes the field into one band.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub range_min: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub range_max: Option<f64>,
|
||||
/// The mode shown, from 1 (frequency steps).
|
||||
pub mode: usize,
|
||||
/// The field frame shown (explicit steps); `None` is the last.
|
||||
@@ -268,11 +388,12 @@ pub struct ResultsView {
|
||||
}
|
||||
|
||||
impl ResultsView {
|
||||
/// How a step's result is shown first: a static step undeformed with von Mises contours, so
|
||||
/// they are read on the true geometry (`results.deformed` shows the deformed shape, at the
|
||||
/// automatic scale); a frequency step's first mode deformed with displacement contours (a
|
||||
/// mode shape is a deformation); an explicit step at its last frame, deformed to true scale
|
||||
/// (with von Mises contours when it kept stresses); a heat step undeformed.
|
||||
/// How a step's result is shown first: a linear static step undeformed with von Mises
|
||||
/// contours, so they are read on the true geometry (`results.deformed` shows the deformed
|
||||
/// shape, at the automatic scale); a frequency step's first mode deformed with displacement
|
||||
/// contours (a mode shape is a deformation); an explicit step at its last frame and a
|
||||
/// nonlinear static step at its last increment, deformed to true scale ([`true_scale`];
|
||||
/// with von Mises contours when they kept stresses); a heat step undeformed.
|
||||
pub fn for_result(r: &StepResult) -> ResultsView {
|
||||
let heat = matches!(r.kind(), Kind::Heat(_));
|
||||
let contour = if heat {
|
||||
@@ -282,13 +403,15 @@ impl ResultsView {
|
||||
} else {
|
||||
Field::Displacement
|
||||
};
|
||||
let scale = matches!(r.kind(), Kind::Explicit(_)).then_some(1.0);
|
||||
let true_scale = true_scale(r);
|
||||
ResultsView {
|
||||
step: r.step().to_string(),
|
||||
deformed: matches!(r.kind(), Kind::Frequency(_) | Kind::Explicit(_)),
|
||||
scale,
|
||||
deformed: true_scale || matches!(r.kind(), Kind::Frequency(_)),
|
||||
scale: true_scale.then_some(1.0),
|
||||
contour: Some(contour),
|
||||
bands: DEFAULT_BANDS,
|
||||
range_min: None,
|
||||
range_max: None,
|
||||
mode: 1,
|
||||
frame: None,
|
||||
animate: false,
|
||||
@@ -298,6 +421,38 @@ impl ResultsView {
|
||||
pub fn pick(&self) -> Pick {
|
||||
Pick { mode: self.mode, frame: self.frame }
|
||||
}
|
||||
|
||||
/// The contour bands for a field ranging from `min` to `max`, clamped to the view's range.
|
||||
pub fn bands(&self, min: f64, max: f64) -> fieldcraft_render::contour::Bands {
|
||||
fieldcraft_render::contour::Bands::clamped(min, max, self.range_min, self.range_max, self.bands)
|
||||
}
|
||||
}
|
||||
|
||||
/// The automatic deformation scale draws the largest displacement this fraction of the model's
|
||||
/// size.
|
||||
pub const AUTO_FRACTION: f64 = 0.1;
|
||||
|
||||
/// Whether a result's deformation is drawn at true scale even when the scale is automatic: an
|
||||
/// explicit step's and a nonlinear static step's displacements are already large (finite strain,
|
||||
/// contact), and magnifying them draws the part crushed through its neighbours. Linear static,
|
||||
/// frequency and buckling results are magnified.
|
||||
pub fn true_scale(r: &StepResult) -> bool {
|
||||
match r.kind() {
|
||||
Kind::Explicit(_) => true,
|
||||
Kind::Static(s) => s.nonlinear.is_some(),
|
||||
Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The automatic deformation scale of `r` at `pick` on a model `size` across (its bounding box
|
||||
/// diagonal): 1 for a [`true_scale`] result, else the factor that draws the largest
|
||||
/// displacement [`AUTO_FRACTION`] of the size; 0 when nothing moves.
|
||||
pub fn auto_scale(r: &StepResult, pick: Pick, size: f64) -> f64 {
|
||||
if true_scale(r) {
|
||||
return 1.0;
|
||||
}
|
||||
let largest = displacements(r, pick).map_or(0.0, |u| u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max));
|
||||
if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The nodal displacements a result's deformed shape uses: the static displacement, the shape
|
||||
@@ -458,6 +613,32 @@ pub fn range(labels: &[u32], values: &[f64]) -> Option<((u32, f64), (u32, f64))>
|
||||
out
|
||||
}
|
||||
|
||||
/// A fingerprint of what solving step `step` of `model` reads ([`Model::step_inputs`]), or
|
||||
/// `None` when there is no such step.
|
||||
pub(crate) fn inputs_fingerprint(model: &Model, step: &str) -> Option<u64> {
|
||||
use std::hash::Hasher;
|
||||
/// Hashes what is written to it.
|
||||
struct HashWriter(std::hash::DefaultHasher);
|
||||
impl std::io::Write for HashWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.write(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let inputs = model.step_inputs(step)?;
|
||||
let mut h = HashWriter(std::hash::DefaultHasher::new());
|
||||
serde_json::to_writer(&mut h, &inputs).ok()?;
|
||||
Some(h.0.finish())
|
||||
}
|
||||
|
||||
/// The principal values of a stress (xx, yy, zz, xy, xz, yz), largest first.
|
||||
pub fn principal(t: &[f64; 6]) -> [f64; 3] {
|
||||
fieldcraft_fem::principal(t)
|
||||
}
|
||||
|
||||
pub fn magnitude(v: [f64; 3]) -> f64 {
|
||||
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
|
||||
}
|
||||
@@ -481,7 +662,10 @@ impl Session {
|
||||
{
|
||||
let ends =
|
||||
range(r.nodes(), &values).map(|((ln, lv), (hn, hv))| json!({"min": {"node": ln, "value": lv}, "max": {"node": hn, "value": hv}}));
|
||||
out["contour"] = json!({"field": f, "label": f.label(), "range": ends});
|
||||
out["contour"] = json!({"field": f, "label": f.label(), "unit": f.unit(r, self.model.units()), "range": ends});
|
||||
if v.range_min.is_some() || v.range_max.is_some() {
|
||||
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
|
||||
}
|
||||
}
|
||||
match r.kind() {
|
||||
Kind::Frequency(fr) => {
|
||||
@@ -497,6 +681,7 @@ impl Session {
|
||||
out["frames"] = f.frames.iter().map(|f| f.time).collect();
|
||||
}
|
||||
Kind::Heat(h) => {
|
||||
out["heat_flows"] = heat_flows(h);
|
||||
out["frames"] = json!(h.times);
|
||||
if let Ok((t, _)) = heat_frame(h, v.pick()) {
|
||||
out["time"] = json!(t);
|
||||
@@ -519,6 +704,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The heat balance of a heat transfer result: each heat load's flow into the body (`loads`:
|
||||
/// `{name, type, heat}`, negative out of it), the prescribed temperatures' total (`reactions`),
|
||||
/// and `imbalance`, their sum: about 0 in a steady state, the heat stored per time in a
|
||||
/// transient one.
|
||||
pub fn heat_flows(h: &HeatResult) -> Value {
|
||||
let loads: Vec<Value> = h.loads.iter().map(|l| json!({"name": l.name, "type": l.kind, "heat": l.heat})).collect();
|
||||
let applied: f64 = h.loads.iter().map(|l| l.heat).sum();
|
||||
json!({"loads": loads, "reactions": h.reaction_total, "imbalance": applied + h.reaction_total})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,6 +758,58 @@ mod tests {
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", false)]);
|
||||
}
|
||||
|
||||
/// A step's result goes stale only when something it reads changes (#318); undoing the
|
||||
/// change makes it current again.
|
||||
#[test]
|
||||
fn unrelated_edits_keep_results_current() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
s.execute("solve.frequency", &json!({})).unwrap();
|
||||
// A set, a boundary condition and a step that neither solved step uses.
|
||||
s.execute("model.set.create", &json!({"name": "extra", "nodes": [1]})).unwrap();
|
||||
s.execute("model.boundary_condition.create", &json!({"name": "X", "type": "fixed", "target": {"set": "extra"}})).unwrap();
|
||||
s.execute("model.step.create", &json!({"name": "Other", "procedure": {"type": "static"}, "boundary_conditions": ["X"]})).unwrap();
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", true)]);
|
||||
// The load only Bend lists: Bend goes stale, Modes (no loads) stays current.
|
||||
s.execute("model.load.edit", &json!({"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -2]})).unwrap();
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", true)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", true)]);
|
||||
// The clamp's set, which both use.
|
||||
s.execute("model.set.edit", &json!({"name": "root", "nodes": [1, 12]})).unwrap();
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", false)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A step's own definition.
|
||||
s.execute("model.step.edit", &json!({"name": "Modes", "procedure": {"type": "frequency", "modes": 3}, "boundary_conditions": ["clamp"]}))
|
||||
.unwrap();
|
||||
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", true), ("Modes", false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_follow_the_unit_system() {
|
||||
let mut s = Session::default();
|
||||
cantilever(&mut s);
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
s.execute("solve.frequency", &json!({})).unwrap();
|
||||
let mm = UnitSystem::MmTonneS;
|
||||
let (bend, _) = s.latest_result("Bend").unwrap();
|
||||
let (modes, _) = s.latest_result("Modes").unwrap();
|
||||
assert_eq!(Field::VonMises.title(bend, mm), "Von Mises stress [MPa]");
|
||||
assert_eq!(Field::Displacement.title(bend, mm), "Displacement magnitude [mm]");
|
||||
assert_eq!(Field::Displacement.title(modes, mm), "Displacement (mode shape, normalised)");
|
||||
assert_eq!(Field::DisplacementZ.title(modes, mm), "Displacement Z (mode shape, normalised)");
|
||||
assert_eq!(Field::VonMises.title(bend, UnitSystem::Unspecified), "Von Mises stress");
|
||||
assert_eq!(Field::ContactStatus.unit(bend, mm), None);
|
||||
let unit = |q: Quantity, u: UnitSystem| q.unit(u);
|
||||
assert_eq!(unit(Quantity::HeatFlow, mm).as_deref(), Some("mW"));
|
||||
assert_eq!(unit(Quantity::Energy, mm).as_deref(), Some("mJ"));
|
||||
assert_eq!(unit(Quantity::Moment, mm).as_deref(), Some("N·mm"));
|
||||
assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J"));
|
||||
assert_eq!(unit(Quantity::Temperature, UnitSystem::Unspecified), None);
|
||||
assert_eq!(unit(Quantity::None, mm), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_ids_round_trip() {
|
||||
for f in Field::ALL {
|
||||
|
||||
+112
-1
@@ -11,7 +11,7 @@
|
||||
//! explicit dynamic step.
|
||||
//!
|
||||
//! The examples gallery (docs/examples.md), each ready to solve with `solve.run`:
|
||||
//! [`drop_test`], [`rubber_seal`], [`flow_past`], [`heated_bracket`], [`bracket_hex`] and [`crank_arm`]. Their
|
||||
//! [`ibeam`], [`drop_test`], [`rubber_seal`], [`flow_past`], [`heated_bracket`], [`bracket_hex`] and [`crank_arm`]. Their
|
||||
//! geometry is a SolidCraft script run when the sample opens ([`Sample::design`]): a corpus
|
||||
//! recipe (`tests/corpus`) or [`UPRIGHT_BRACKET`].
|
||||
|
||||
@@ -60,6 +60,14 @@ pub const SAMPLES: &[Sample] = &[
|
||||
prepare: None,
|
||||
script: |_| impact(),
|
||||
},
|
||||
Sample {
|
||||
name: "ibeam",
|
||||
label: "I-Beam Cantilever",
|
||||
about: "A 200 mm steel I-beam swept all-hex, clamped at one end with 1 kN on the other through a coupling: static, ready to solve",
|
||||
design: Some(CANTILEVER_I_BEAM),
|
||||
prepare: None,
|
||||
script: |_| ibeam(),
|
||||
},
|
||||
Sample {
|
||||
name: "drop-test",
|
||||
label: "Drop Test",
|
||||
@@ -290,6 +298,36 @@ pub fn impact() -> Value {
|
||||
Value::Array(steps)
|
||||
}
|
||||
|
||||
/// The 200 mm I-beam lying along x, web upright (`tests/corpus/cantilever-i-beam`): 40 × 40 mm,
|
||||
/// 3 mm flanges, a 2 mm web. Body `Beam`; its end faces are `F2:start` (x = 0) and `F2:top`
|
||||
/// (x = 200).
|
||||
pub const CANTILEVER_I_BEAM: &str = include_str!("../../../tests/corpus/cantilever-i-beam/recipe.json");
|
||||
|
||||
/// The I-beam's tip load (N).
|
||||
pub const IBEAM_LOAD: f64 = 1000.0;
|
||||
|
||||
/// [`CANTILEVER_I_BEAM`] swept all-hex (hex8 of 4 mm, incompatible modes) in steel, clamped at
|
||||
/// x = 0 (set `root`), [`IBEAM_LOAD`] down (−z) on a reference point on the beam's axis at the
|
||||
/// free end, kinematically coupled to the end face (set `tip`) so the section stays plane. Step
|
||||
/// `Load` (linear static). The README demo's model (tools/demo-video).
|
||||
pub fn ibeam() -> Value {
|
||||
let face = |f: &str| json!([{"body": "Beam", "face": f}]);
|
||||
json!([
|
||||
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
||||
{"command": "mesh.hex.sweep", "params": {"body": "Beam", "size": 4}},
|
||||
{"command": "model.set.create", "params": {"name": "beam", "geometry": [{"body": "Beam"}]}},
|
||||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}},
|
||||
{"command": "model.section.create", "params": {"name": "Beam", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": {"incompatible_modes": true}}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "geometry": face("F2:start")}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||
{"command": "model.set.create", "params": {"name": "tip", "geometry": face("F2:top")}},
|
||||
{"command": "model.reference_point.create", "params": {"name": "End", "xyz": [200, 0, 20]}},
|
||||
{"command": "model.coupling.create", "params": {"name": "EndPlate", "reference_point": "End", "set": "tip", "kind": "kinematic"}},
|
||||
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"reference_point": "End"}, "vector": [0, 0, -IBEAM_LOAD]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}},
|
||||
])
|
||||
}
|
||||
|
||||
/// An L-bracket standing on its foot, Z up, as a SolidCraft script: a 60 × 40 mm foot and a 50 mm
|
||||
/// upright, both 8 mm thick, with an R6 fillet inside the bend (the corpus `filleted-bracket`'s
|
||||
/// profile without its holes, sketched on XZ so it sweeps along y). Body `Bracket`; its faces
|
||||
@@ -646,6 +684,21 @@ mod tests {
|
||||
assert_eq!(f["modes"].as_array().map(Vec::len), Some(6));
|
||||
}
|
||||
|
||||
/// The I-beam opens swept all-hex and ready; solved, its tip deflection is the beam theory's
|
||||
/// bending plus shear (P L³ / 3 E I + P L / G A_web: 0.149 + 0.035 mm) within 5 %.
|
||||
#[test]
|
||||
fn the_ibeam_bends_as_beam_theory() {
|
||||
let mut s = open("ibeam");
|
||||
assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Hex8));
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let p = s.execute("solve.probe", &json!({"step": "Load", "at": [[200.0, 0.0, 20.0]]})).unwrap();
|
||||
let tip = -p["nodes"][0]["displacement"][2].as_f64().unwrap();
|
||||
let (e, g) = (210000.0, 210000.0 / 2.6);
|
||||
let i = (40.0 * 40.0f64.powi(3) - 38.0 * 34.0f64.powi(3)) / 12.0;
|
||||
let theory = IBEAM_LOAD * 200.0f64.powi(3) / (3.0 * e * i) + IBEAM_LOAD * 200.0 / (g * 2.0 * 37.0);
|
||||
assert!((tip - theory).abs() < 0.05 * theory, "{tip} against {theory}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_impact_runs_and_keeps_frames_with_stresses() {
|
||||
let mut s = Session::default();
|
||||
@@ -655,6 +708,64 @@ mod tests {
|
||||
assert!(r["energy_balance"].as_f64().unwrap() < 0.05, "{r}");
|
||||
}
|
||||
|
||||
/// Hex20 extrudes the crank with its root arcs tangent to the spindle boss (#197), at the
|
||||
/// sizes where it once failed the validity gate with inverted elements and hex8 did not: the
|
||||
/// sample moves the root arcs off tangent, so this builds the tangent variant (root arcs of
|
||||
/// R10 whose centres lie 33 mm from the spindle axis, R23 + R10). The elements at the cusp,
|
||||
/// whose two curved sides meet tangentially, stay valid.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn hex20_extrudes_the_crank_with_tangent_root_arcs() {
|
||||
let mut script = CRANK_ARM.to_string();
|
||||
for (from, to) in [
|
||||
("15.883151316213851, 28.354461805265395", "16.9566899202338, 28.310257274511628"),
|
||||
("15.883151316213851, -28.354461805265395", "16.9566899202338, -28.310257274511628"),
|
||||
("15.471735240951828, 18.362928548901984", "16.545273844971774, 18.31872401814822"),
|
||||
("15.471735240951828, -18.362928548901984", "16.545273844971774, -18.31872401814822"),
|
||||
("13.455063072993756, 18.65372021077189", "11.818299035314467, 19.731391433750527"),
|
||||
("13.455063072993756, -18.65372021077189", "11.818299035314467, -19.731391433750527"),
|
||||
] {
|
||||
assert!(script.contains(from), "{from}");
|
||||
script = script.replace(from, to);
|
||||
}
|
||||
let bytes = fieldcraft_geometry::script_design(&script).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
for size in [2.5, 3.0, 5.0] {
|
||||
let r = s.execute("mesh.hex.generate", &json!({"body": "CrankArm", "size": size, "order": 2, "method": "extrude"})).unwrap();
|
||||
let body = &r["bodies"][0];
|
||||
assert_eq!(body["kind"], "hex20", "{r}");
|
||||
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.0, "{size}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A partition the kernel fails to make is reported as the kernel's failure, never as a plane
|
||||
/// that does not divide the body (#283): x = 148 plainly crosses the crank's half y > 0, whose
|
||||
/// cut fails in the kernel's boolean, and the same plane cuts the half y < 0.
|
||||
#[test]
|
||||
fn a_kernel_failure_to_partition_is_reported_as_such() {
|
||||
use fieldcraft_geometry::Cut;
|
||||
let design = fieldcraft_geometry::open_design(&fieldcraft_geometry::script_design(CRANK_ARM).unwrap()).unwrap();
|
||||
let cut = |name: &str, point: [f64; 3], normal: [f64; 3], bodies: &[&str]| Cut {
|
||||
name: name.into(),
|
||||
point,
|
||||
normal,
|
||||
bodies: bodies.iter().map(|b| b.to_string()).collect(),
|
||||
};
|
||||
let half = cut("Half", [0.0; 3], [0.0, 1.0, 0.0], &[]);
|
||||
let p = design.partitioned(&[half.clone(), cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &[CRANK_HALF])]).unwrap();
|
||||
assert!(p.errors.is_empty(), "{:?}", p.errors);
|
||||
let p = design.partitioned(&[half, cut("X148", [148.0, 0.0, 0.0], [1.0, 0.0, 0.0], &["CrankArm/b"])]).unwrap();
|
||||
// Should the kernel learn to make this cut, the cut is all there is to check.
|
||||
if p.cuts[1].split.is_empty() {
|
||||
let errors: Vec<&str> = p.errors.iter().filter(|e| e.feature == "partition X148").map(|e| e.message.as_str()).collect();
|
||||
assert!(matches!(errors.as_slice(), [m] if m.starts_with("the kernel could not cut `CrankArm/b`")), "{errors:?}");
|
||||
} else {
|
||||
assert_eq!(p.cuts[1].split, ["CrankArm/b"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `file.sample NAME`, checked clean: ready, no errors or warnings.
|
||||
fn open(name: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
|
||||
+209
-2
@@ -74,6 +74,9 @@ pub enum Kind {
|
||||
Ref(Collection),
|
||||
/// A list of such names.
|
||||
RefList(Collection),
|
||||
/// A choice per item: an object from the names of a collection's items to one of these
|
||||
/// strings (value, label); an item left out takes the command's default.
|
||||
RefChoices(Collection, &'static [(&'static str, &'static str)]),
|
||||
/// A nested object.
|
||||
Group(&'static [Field]),
|
||||
/// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value
|
||||
@@ -92,10 +95,44 @@ pub enum Kind {
|
||||
fields: &'static [Field],
|
||||
max: usize,
|
||||
},
|
||||
/// A list of mesh labels, nodes or elements (at most [`MAX_LABELS`]).
|
||||
Labels(LabelOf),
|
||||
/// A list of points `[x, y, z]`, at most `max`.
|
||||
Points {
|
||||
dim: Dim,
|
||||
max: usize,
|
||||
},
|
||||
/// Any of these values, as a list: (the value as JSON text, label), e.g. `("1", "UX")` or
|
||||
/// `("\"stress\"", "Stress")`.
|
||||
Multi(&'static [(&'static str, &'static str)]),
|
||||
/// A number at least `min`, or the string `"auto"`.
|
||||
NumberOrAuto {
|
||||
dim: Dim,
|
||||
min: Option<f64>,
|
||||
},
|
||||
/// Any JSON: what the schema does not describe.
|
||||
Json,
|
||||
}
|
||||
|
||||
/// What a [`Kind::Labels`] list labels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LabelOf {
|
||||
Node,
|
||||
Element,
|
||||
}
|
||||
|
||||
impl LabelOf {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LabelOf::Node => "node",
|
||||
LabelOf::Element => "element",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most labels a [`Kind::Labels`] list holds.
|
||||
pub const MAX_LABELS: usize = 1_000_000;
|
||||
|
||||
/// One variant of [`Kind::Variants`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Variant {
|
||||
@@ -351,7 +388,7 @@ impl Field {
|
||||
/// Its physical dimension, for a number or a vector of numbers.
|
||||
pub fn dim(&self) -> Option<Dim> {
|
||||
match self.kind {
|
||||
Kind::Number { dim, .. } | Kind::Vector { dim, .. } => Some(dim),
|
||||
Kind::Number { dim, .. } | Kind::Vector { dim, .. } | Kind::Points { dim, .. } | Kind::NumberOrAuto { dim, .. } => Some(dim),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -429,6 +466,10 @@ pub const fn reference(key: &'static str, label: &'static str, c: Collection) ->
|
||||
pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field {
|
||||
field(key, label, Kind::RefList(c))
|
||||
}
|
||||
|
||||
pub const fn ref_choices(key: &'static str, label: &'static str, c: Collection, options: &'static [(&'static str, &'static str)]) -> Field {
|
||||
field(key, label, Kind::RefChoices(c, options))
|
||||
}
|
||||
pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
|
||||
field(key, label, Kind::Group(fields))
|
||||
}
|
||||
@@ -444,6 +485,22 @@ pub const fn unit(key: &'static str, label: &'static str) -> Field {
|
||||
pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
|
||||
field(key, label, Kind::Rows { fields, max: MAX_ROWS })
|
||||
}
|
||||
/// A list of node or element labels.
|
||||
pub const fn labels(key: &'static str, label: &'static str, of: LabelOf) -> Field {
|
||||
field(key, label, Kind::Labels(of))
|
||||
}
|
||||
/// A list of points `[x, y, z]`.
|
||||
pub const fn points(key: &'static str, label: &'static str, dim: Dim, max: usize) -> Field {
|
||||
field(key, label, Kind::Points { dim, max })
|
||||
}
|
||||
/// Any of `options` (each a value as JSON text, and a label), as a list.
|
||||
pub const fn multi(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field {
|
||||
field(key, label, Kind::Multi(options))
|
||||
}
|
||||
/// A number at least `min`, or "auto".
|
||||
pub const fn number_or_auto(key: &'static str, label: &'static str, dim: Dim, min: Option<f64>) -> Field {
|
||||
field(key, label, Kind::NumberOrAuto { dim, min })
|
||||
}
|
||||
pub const fn json(key: &'static str, label: &'static str) -> Field {
|
||||
field(key, label, Kind::Json)
|
||||
}
|
||||
@@ -454,6 +511,11 @@ pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [F
|
||||
/// The `name` of a new or edited definition.
|
||||
pub const NAME: Field = text("name", "Name").req().help("A name for it, unique among its kind; other definitions refer to it by this name.");
|
||||
|
||||
/// A [`Kind::Multi`] option's value: its JSON text parsed (text that is not JSON is a string).
|
||||
pub fn multi_value(option: &str) -> Value {
|
||||
serde_json::from_str(option).unwrap_or_else(|_| Value::String(option.to_string()))
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The variant of `tag`, for [`Kind::Variants`].
|
||||
pub fn variant(&self, tag: &str) -> Option<&'static Variant> {
|
||||
@@ -659,8 +721,11 @@ impl Problem {
|
||||
ProblemKind::NotText => "text".into(),
|
||||
ProblemKind::NotABool => "true or false".into(),
|
||||
ProblemKind::NotAChoice => match f.map(|f| f.kind) {
|
||||
Some(Kind::Choice(options)) => format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", ")),
|
||||
Some(Kind::Choice(options) | Kind::RefChoices(_, options)) => {
|
||||
format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")),
|
||||
Some(Kind::Multi(options)) => format!("a list of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", ")),
|
||||
_ => "one of the choices".into(),
|
||||
},
|
||||
ProblemKind::NotAVector { len } => format!("{len} numbers"),
|
||||
@@ -777,6 +842,38 @@ pub fn object_problems(fields: &'static [Field], obj: &Map<String, Value>) -> Ve
|
||||
out
|
||||
}
|
||||
|
||||
/// The top-level keys `params` may hold for these fields: each field's key, and for a flattened
|
||||
/// one-of (a load's `type`) its tag and the fields of the variant `params` picks (of every
|
||||
/// variant when it picks none).
|
||||
pub fn accepted_keys(fields: &[Field], params: &Value) -> Vec<&'static str> {
|
||||
let mut keys = Vec::new();
|
||||
for f in fields {
|
||||
match f.kind {
|
||||
Kind::Variants { tag, variants } if f.key.is_empty() => {
|
||||
keys.push(tag);
|
||||
let picked = params.get(tag).and_then(Value::as_str).and_then(|t| variants.iter().find(|v| v.tag == t));
|
||||
for v in variants.iter().filter(|v| picked.is_none_or(|p| p.tag == v.tag)) {
|
||||
for key in accepted_keys(v.fields, params) {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => keys.push(f.key),
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// The first top-level key of `params` that `fields` do not describe, with the keys they do:
|
||||
/// a misspelt or misplaced parameter is refused rather than silently ignored.
|
||||
pub fn unknown_key(fields: &[Field], params: &Value) -> Option<(String, Vec<&'static str>)> {
|
||||
let obj = params.as_object()?;
|
||||
let accepted = accepted_keys(fields, params);
|
||||
obj.keys().find(|k| !accepted.contains(&k.as_str())).map(|k| (k.chars().take(64).collect(), accepted))
|
||||
}
|
||||
|
||||
/// The path of field `key` inside `prefix` (`elastic/young`).
|
||||
pub fn join(prefix: &str, key: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
@@ -882,6 +979,11 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
|
||||
_ => Some(ProblemKind::NotAVector { len: labels.len() }),
|
||||
},
|
||||
Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then_some(ProblemKind::NotNames),
|
||||
Kind::RefChoices(_, options) => match v.as_object() {
|
||||
Some(o) if o.values().all(|x| x.as_str().is_some_and(|s| options.iter().any(|(opt, _)| *opt == s))) => None,
|
||||
Some(_) => Some(ProblemKind::NotAChoice),
|
||||
None => Some(ProblemKind::NotAnObject),
|
||||
},
|
||||
Kind::Group(fields) => match v.as_object() {
|
||||
Some(o) => {
|
||||
check(fields, o, path, out, depth + 1);
|
||||
@@ -910,6 +1012,29 @@ fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem
|
||||
(!ok).then_some(ProblemKind::NotAChoice)
|
||||
}
|
||||
Kind::Unit | Kind::Json => None,
|
||||
Kind::Labels(_) => match v.as_array() {
|
||||
Some(a) if a.len() <= MAX_LABELS && a.iter().all(|x| x.as_u64().is_some_and(|n| n >= 1 && n <= u64::from(u32::MAX))) => None,
|
||||
_ => Some(ProblemKind::NotAList { max: MAX_LABELS, what: "labels" }),
|
||||
},
|
||||
Kind::Points { max, .. } => match v.as_array() {
|
||||
Some(a)
|
||||
if a.len() <= max
|
||||
&& a.iter().all(|p| p.as_array().is_some_and(|c| c.len() == 3 && c.iter().all(|x| x.as_f64().is_some_and(f64::is_finite)))) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
_ => Some(ProblemKind::NotAList { max, what: "points [x, y, z]" }),
|
||||
},
|
||||
Kind::Multi(options) => match v.as_array() {
|
||||
Some(a) if a.iter().all(|x| options.iter().any(|(o, _)| multi_value(o) == *x)) => None,
|
||||
_ => Some(ProblemKind::NotAChoice),
|
||||
},
|
||||
Kind::NumberOrAuto { min, .. } => match (v, v.as_f64().filter(|x| x.is_finite())) {
|
||||
(Value::String(s), _) if s == "auto" => None,
|
||||
(_, Some(x)) if min.is_none_or(|lo| x >= lo) => None,
|
||||
(_, Some(_)) => Some(ProblemKind::Below(min.unwrap_or(0.0))),
|
||||
_ => Some(ProblemKind::Invalid("a number or \"auto\"".into())),
|
||||
},
|
||||
Kind::Rows { fields, max } => match v.as_array() {
|
||||
Some(a) if a.len() <= max => {
|
||||
for (i, row) in a.iter().enumerate() {
|
||||
@@ -1003,6 +1128,11 @@ fn field_schema(f: &Field, depth: usize) -> Value {
|
||||
}
|
||||
Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}),
|
||||
Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}),
|
||||
Kind::RefChoices(c, options) => json!({
|
||||
"type": "object",
|
||||
"x-references": c.label(),
|
||||
"additionalProperties": {"enum": options.iter().map(|(o, _)| *o).collect::<Vec<_>>()},
|
||||
}),
|
||||
Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1),
|
||||
Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}),
|
||||
Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind {
|
||||
@@ -1011,6 +1141,26 @@ fn field_schema(f: &Field, depth: usize) -> Value {
|
||||
}).collect::<Vec<_>>()}),
|
||||
Kind::Rows { fields, max } if depth <= MAX_DEPTH => json!({"type": "array", "items": object_schema(fields, depth + 1), "maxItems": max}),
|
||||
Kind::Unit => json!({"const": f.key}),
|
||||
Kind::Labels(of) => {
|
||||
json!({"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": u32::MAX}, "maxItems": MAX_LABELS, "x-labels": of.label()})
|
||||
}
|
||||
Kind::Points { dim, max } => {
|
||||
let mut item = json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3});
|
||||
if dim != Dim::None {
|
||||
item["x-dimension"] = json!(format!("{dim:?}").to_lowercase());
|
||||
}
|
||||
json!({"type": "array", "items": item, "maxItems": max})
|
||||
}
|
||||
Kind::Multi(options) => {
|
||||
json!({"type": "array", "items": {"enum": options.iter().map(|(o, _)| multi_value(o)).collect::<Vec<_>>()}, "uniqueItems": true})
|
||||
}
|
||||
Kind::NumberOrAuto { min, .. } => {
|
||||
let mut n = json!({"type": "number"});
|
||||
if let Some(lo) = min {
|
||||
n["minimum"] = json!(lo);
|
||||
}
|
||||
json!({"oneOf": [n, {"const": "auto"}]})
|
||||
}
|
||||
_ => json!({}),
|
||||
};
|
||||
s["title"] = json!(f.label);
|
||||
@@ -1062,6 +1212,40 @@ mod tests {
|
||||
.def("\"force\""),
|
||||
];
|
||||
|
||||
/// The typed lists check their values and describe themselves in JSON Schema (#169).
|
||||
#[test]
|
||||
fn labels_points_multi_and_auto_check() {
|
||||
const F: &[Field] = &[
|
||||
labels("nodes", "Nodes", LabelOf::Node),
|
||||
points("at", "Points", Dim::Length, 2),
|
||||
multi("dofs", "DOFs", &[("1", "UX"), ("2", "UY")]),
|
||||
multi("output", "Output", &[("\"stress\"", "Stress")]),
|
||||
number_or_auto("scale", "Scale", Dim::None, Some(0.0)),
|
||||
];
|
||||
assert!(problems(F, &json!({"nodes": [1, 5], "at": [[0, 0, 1e3]], "dofs": [2], "output": ["stress"], "scale": "auto"})).is_empty());
|
||||
assert!(problems(F, &json!({"scale": 2.5})).is_empty());
|
||||
for bad in [
|
||||
json!({"nodes": [0]}),
|
||||
json!({"nodes": [1.5]}),
|
||||
json!({"nodes": [u64::from(u32::MAX) + 1]}),
|
||||
json!({"at": [[0, 0]]}),
|
||||
json!({"at": [[0, 0, 0], [0, 0, 0], [0, 0, 0]]}),
|
||||
json!({"at": [[0, 0, "x"]]}),
|
||||
json!({"dofs": [3]}),
|
||||
json!({"dofs": ["1"]}),
|
||||
json!({"output": ["strain"]}),
|
||||
json!({"scale": -1}),
|
||||
json!({"scale": "big"}),
|
||||
] {
|
||||
assert_eq!(problems(F, &bad).len(), 1, "{bad}");
|
||||
}
|
||||
let s = json_schema(F);
|
||||
assert_eq!(s["properties"]["dofs"]["items"]["enum"], json!([1, 2]));
|
||||
assert_eq!(s["properties"]["output"]["items"]["enum"], json!(["stress"]));
|
||||
assert_eq!(s["properties"]["at"]["maxItems"], 2);
|
||||
assert_eq!(s["properties"]["scale"]["oneOf"][1]["const"], "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_switch_and_check() {
|
||||
let p = prefill(LOADISH, &json!({}));
|
||||
@@ -1106,4 +1290,27 @@ mod tests {
|
||||
assert_eq!(s["oneOf"][1]["properties"]["type"]["const"], "pressure");
|
||||
assert_eq!(s["oneOf"][1]["required"], json!(["magnitude", "set", "type"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_found_per_variant() {
|
||||
assert_eq!(unknown_key(LOADISH, &json!({"name": "P", "type": "pressure", "magnitude": 1})), None);
|
||||
let (key, accepted) = unknown_key(LOADISH, &json!({"name": "P", "type": "pressure", "magnitud": 1})).unwrap();
|
||||
assert_eq!(key, "magnitud");
|
||||
assert!(accepted.contains(&"magnitude") && accepted.contains(&"name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_refuses_an_unknown_parameter() {
|
||||
for spec in crate::commands() {
|
||||
let mut s = Session::default();
|
||||
let e = s.execute(spec.id, &json!({"bogus_param": 1})).unwrap_err().to_string();
|
||||
// A command that cannot run yet says so first; otherwise the key is named.
|
||||
assert!(e.contains("bogus_param") || s.availability(spec).is_err(), "{}: {e}", spec.id);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
crate::cmd::solve::tests::cantilever(&mut s);
|
||||
s.execute("solve.run", &json!({})).unwrap();
|
||||
let e = s.execute("results.contour", &json!({"field": "displacement", "deformed": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("`deformed`") && e.contains("field"), "{e}");
|
||||
}
|
||||
}
|
||||
+17
-19
@@ -8,15 +8,16 @@
|
||||
//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a
|
||||
//! picture shows the full deformation (a mode at its positive shape). The analysis setup's
|
||||
//! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them,
|
||||
//! muted over a results plot, at their default size on screen once the camera is known.
|
||||
//! muted over a results plot (and only those its step used), at their default size on screen
|
||||
//! once the camera is known.
|
||||
|
||||
use fieldcraft_model::ElementKind;
|
||||
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
|
||||
use fieldcraft_render::contour::{bands_color, split_triangle};
|
||||
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::results::{displacements, field_values, magnitude, range};
|
||||
use crate::results::{auto_scale, displacements, field_values, range};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The colours of a picture.
|
||||
@@ -38,10 +39,15 @@ pub struct Palette {
|
||||
pub no_result: Rgb,
|
||||
/// The contour colour scale, low to high.
|
||||
pub contour: [Rgb; 5],
|
||||
/// Values above a clamped legend's maximum.
|
||||
pub contour_over: Rgb,
|
||||
/// The analysis setup's symbols.
|
||||
pub glyphs: GlyphColors,
|
||||
}
|
||||
|
||||
/// Values above a clamped legend's maximum (both themes): a deep purple apart from the scale's red.
|
||||
pub const CONTOUR_OVER: Rgb = Rgb(0x6e, 0x2c, 0x80);
|
||||
|
||||
impl Palette {
|
||||
/// The desktop app's light theme.
|
||||
pub const LIGHT: Palette = Palette {
|
||||
@@ -66,6 +72,7 @@ impl Palette {
|
||||
],
|
||||
no_result: Rgb(0x9a, 0xa0, 0xaa),
|
||||
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
|
||||
contour_over: CONTOUR_OVER,
|
||||
glyphs: GlyphColors {
|
||||
constraint: Rgb(0x00, 0x8a, 0xa8),
|
||||
load: Rgb(0xd0, 0x24, 0x24),
|
||||
@@ -93,8 +100,6 @@ impl Palette {
|
||||
const EDGE_WIDTH: f64 = 1.0;
|
||||
const GEOMETRY_EDGE_WIDTH: f64 = 1.5;
|
||||
const LINE_ELEMENT_WIDTH: f64 = 3.0;
|
||||
/// The automatic scale draws the largest displacement this fraction of the model's size.
|
||||
const AUTO_FRACTION: f64 = 0.1;
|
||||
|
||||
/// The chordal tolerance geometry is tessellated with for display: a thousandth of its size.
|
||||
fn display_tolerance(b: &Aabb3) -> f64 {
|
||||
@@ -119,7 +124,7 @@ pub struct Shown {
|
||||
|
||||
/// The model's display data with the open design's bodies and the imported surfaces.
|
||||
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
|
||||
let mut d = DisplayMesh::from_model(&s.model);
|
||||
let mut d = DisplayMesh::from_model_hiding(&s.model, &s.hidden_elements());
|
||||
let mut failed = Vec::new();
|
||||
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
|
||||
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
|
||||
@@ -182,9 +187,10 @@ impl Picture {
|
||||
|
||||
fn picture(s: &Session, p: &Palette) -> Picture {
|
||||
let (base, failed) = display(s);
|
||||
let glyphs = s.glyphs();
|
||||
let mut shown = Shown { failed, ..Shown::default() };
|
||||
let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
|
||||
// Over a results plot, the symbols of what its step used.
|
||||
let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step));
|
||||
let Some((view, result)) = plot else {
|
||||
let mut scene = model_scene(&base, p);
|
||||
let bounds = base.bounds;
|
||||
@@ -196,15 +202,7 @@ fn picture(s: &Session, p: &Palette) -> Picture {
|
||||
let pick = view.pick();
|
||||
let nodes = result.nodes();
|
||||
let u = displacements(result, pick).unwrap_or(&[]);
|
||||
let scale = if !view.deformed {
|
||||
0.0
|
||||
} else if let Some(k) = view.scale {
|
||||
k
|
||||
} else {
|
||||
let largest = u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max);
|
||||
let size = base.bounds.diagonal();
|
||||
if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 }
|
||||
};
|
||||
let scale = if !view.deformed { 0.0 } else { view.scale.unwrap_or_else(|| auto_scale(result, pick, base.bounds.diagonal())) };
|
||||
shown.scale = scale;
|
||||
let drawn = base.displaced(|label| {
|
||||
let i = nodes.binary_search(&label).ok()?;
|
||||
@@ -218,12 +216,12 @@ fn picture(s: &Session, p: &Palette) -> Picture {
|
||||
});
|
||||
if let Some((field, min, max, values)) = contour {
|
||||
shown.contour = Some((field.id().to_string(), min, max));
|
||||
let bands = Bands::new(min.1, max.1, view.bands);
|
||||
let bands = view.bands(min.1, max.1);
|
||||
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
|
||||
scene.tris.clear();
|
||||
for tri in &drawn.tris {
|
||||
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
|
||||
let color = band.map_or(p.no_result, |k| band_color(&p.contour, k, bands.count));
|
||||
let color = band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
|
||||
for k in 1..piece.len().saturating_sub(1) {
|
||||
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
|
||||
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
|
||||
@@ -274,7 +272,7 @@ pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette
|
||||
let mut pic = picture(s, p);
|
||||
let mut cam = Camera::default();
|
||||
let aspect = w as f64 / h.max(1) as f64;
|
||||
let unit = |cam: &Camera| cam.length_per_pixel(h as f64).and_then(|l| glyphs::symbol_unit(l, glyphs::DEFAULT_SIZE)).unwrap_or(f64::NAN);
|
||||
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN);
|
||||
// Framed on the model, then again with its symbols at that camera's size, which reach a
|
||||
// little beyond it.
|
||||
cam.orient(&Orientation { distance: None, ..*at });
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[{"command": "solid.box", "params": {"length": 112, "width": 82, "height": 36, "corner": [0, 0, 0], "body_name": "Base"}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 18.0], [112, 0, 18.0], [0, 82, 18.0], [112, 82, 18.0]], "radius": 5}}, {"command": "solid.shell", "params": {"faces": [[56.0, 41.0, 36]], "thickness": 3, "direction": "inside", "body": "Base"}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.hole", "params": {"position": [-1, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [-1, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut1"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [34, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut1", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [34, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [34, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.fillet", "params": {"edges": [[34, -20, 24]], "radius": 2.5, "body": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [34, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable1"}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut2"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [66, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut2", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [66, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [66, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.fillet", "params": {"edges": [[66, -20, 24]], "radius": 2.5, "body": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [66, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable2"}}, {"command": "sketch.create", "params": {"plane": {"origin": [112, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Nut3"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [41.0, 18], "radius": 14, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut3", "profiles": "all", "distance": 5, "operation": "new", "body_name": "Gland3"}}, {"command": "solid.cylinder", "params": {"diameter": 24, "height": 17, "base": [117, 41.0, 18], "axis": [1, 0, 0], "operation": "join", "targets": ["Gland3"]}}, {"command": "solid.hole", "params": {"position": [134, 41.0, 18], "diameter": 14, "depth": 15}}, {"command": "solid.fillet", "params": {"edges": [[134, 41.0, 30]], "radius": 2, "body": "Gland3"}}, {"command": "solid.box", "params": {"length": 110.8, "width": 80.8, "height": 1.6, "corner": [0.6, 0.6, 36], "operation": "new", "body_name": "ORing"}}, {"command": "solid.box", "params": {"length": 107.2, "width": 77.2, "height": 1.6, "corner": [2.4, 2.4, 36], "operation": "cut", "targets": ["ORing"]}}, {"command": "solid.box", "params": {"length": 128, "widthLine truncated
|
||||
@@ -23,6 +23,9 @@
|
||||
//! The run starts from u = 0 and the initial velocities with the half increment
|
||||
//! v^{½} = v^0 + ½Δt^{½} a^0.
|
||||
|
||||
/// Increments between progress reports (each takes a lock).
|
||||
const PROGRESS_EVERY: u64 = 64;
|
||||
|
||||
use fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
|
||||
|
||||
use crate::contact::{Evaluation, Interface};
|
||||
@@ -356,7 +359,12 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let mut v_now = v0;
|
||||
let mut completed = true;
|
||||
let mut v_new = vec![0.0; n];
|
||||
fieldcraft_model::progress::stage("Time", Some(end), "");
|
||||
loop {
|
||||
if increments.is_multiple_of(PROGRESS_EVERY) {
|
||||
fieldcraft_model::progress::count(t);
|
||||
fieldcraft_model::progress::check()?;
|
||||
}
|
||||
if increments >= max_increments {
|
||||
completed = false;
|
||||
warnings.push(format!("stopped at time {t:.6e} after {increments} increments (the cap); the step ends at {end:.6e}"));
|
||||
|
||||
@@ -18,9 +18,9 @@ thiserror = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
solidcraft-engine = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[features]
|
||||
# Build designs from SolidCraft command scripts (`script_design`): the test corpus and tooling.
|
||||
# Off by default; the library itself only opens designs.
|
||||
scripts = ["dep:solidcraft-engine", "dep:serde_json"]
|
||||
scripts = ["dep:solidcraft-engine"]
|
||||
@@ -34,12 +34,58 @@ pub struct Design {
|
||||
pub struct FeatureError {
|
||||
pub feature: String,
|
||||
pub message: String,
|
||||
/// The bodies that may differ from the design as SolidCraft builds it: those the feature
|
||||
/// names, or every body when it names none of them.
|
||||
pub bodies: Vec<String>,
|
||||
}
|
||||
|
||||
impl FeatureError {
|
||||
/// One line for the person opening the design, naming the bodies it may change.
|
||||
pub fn warning(&self) -> String {
|
||||
format!("{} failed to build, so {} may differ from the design: {}", self.feature, quoted(&self.bodies), self.message)
|
||||
}
|
||||
}
|
||||
|
||||
fn quoted(bodies: &[String]) -> String {
|
||||
match bodies {
|
||||
[] => "the design".into(),
|
||||
[b] => format!("body \"{b}\""),
|
||||
_ => format!("bodies {}", bodies.iter().map(|b| format!("\"{b}\"")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies of `bodies` that `feature` names anywhere in its saved form (targets, tools,
|
||||
/// selections); all of them when it names none.
|
||||
fn bodies_of(feature: Option<&solidcraft_doc::Feature>, bodies: &[Shape]) -> Vec<String> {
|
||||
fn strings<'a>(v: &'a serde_json::Value, out: &mut Vec<&'a str>) {
|
||||
match v {
|
||||
serde_json::Value::String(s) => out.push(s),
|
||||
serde_json::Value::Array(a) => a.iter().for_each(|v| strings(v, out)),
|
||||
serde_json::Value::Object(o) => o.values().for_each(|v| strings(v, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let value = feature.and_then(|f| serde_json::to_value(f).ok()).unwrap_or_default();
|
||||
let mut named = Vec::new();
|
||||
strings(&value, &mut named);
|
||||
let hit: Vec<String> = bodies.iter().filter(|b| named.contains(&b.name.as_str())).map(|b| b.name.clone()).collect();
|
||||
if hit.is_empty() { bodies.iter().map(|b| b.name.clone()).collect() } else { hit }
|
||||
}
|
||||
|
||||
impl Design {
|
||||
pub fn body(&self, name: &str) -> Option<&Shape> {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
|
||||
/// The body of the design as opened that `name` is, or that partitions cut it from
|
||||
/// (`Base` for `Base/a/b1`).
|
||||
pub fn source_of(&self, name: &str) -> Option<&str> {
|
||||
self.source
|
||||
.iter()
|
||||
.map(|b| b.name.as_str())
|
||||
.filter(|s| name == *s || name.strip_prefix(s).is_some_and(|rest| rest.starts_with('/')))
|
||||
.max_by_key(|s| s.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a design from its file bytes (a `.solidcraft` container or a JSON design) and rebuild it.
|
||||
@@ -55,12 +101,6 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
m.evaluate(doc);
|
||||
Ok(m)
|
||||
})?;
|
||||
let errors = model
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| !r.skipped)
|
||||
.filter_map(|r| r.error.as_ref().map(|e| FeatureError { feature: r.name.clone(), message: e.clone() }))
|
||||
.collect();
|
||||
// Placed in the world: a component's bodies through every occurrence of it (an instance is a
|
||||
// body of its own, named after its occurrence path), as SolidCraft displays and exports them.
|
||||
let state = guard("place components", || Ok(solidcraft_doc::world_state(doc, &model.state())))?;
|
||||
@@ -72,12 +112,125 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
})?;
|
||||
bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces });
|
||||
}
|
||||
// A feature that failed leaves a different solid than SolidCraft's: each one names the bodies
|
||||
// it would have changed, so opening never hands back a silently different solid.
|
||||
let errors: Vec<FeatureError> = model
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| !r.skipped)
|
||||
.filter_map(|r| {
|
||||
let message = r.error.clone()?;
|
||||
let bodies = bodies_of(doc.features.iter().find(|f| f.id == r.id), &bodies);
|
||||
Some(FeatureError { feature: r.name.clone(), message, bodies })
|
||||
})
|
||||
.collect();
|
||||
// The errors name the bodies as SolidCraft does; they follow the pieces' new names.
|
||||
let renamed = number_split_bodies(&mut bodies);
|
||||
let errors = errors
|
||||
.into_iter()
|
||||
.map(|mut e| {
|
||||
for b in &mut e.bodies {
|
||||
if let Some((_, new)) = renamed.iter().find(|(old, _)| old == b) {
|
||||
*b = new.clone();
|
||||
}
|
||||
}
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new() })
|
||||
}
|
||||
|
||||
/// A body name without the suffixes SolidCraft gives the pieces of a split body: ` (1)` for the
|
||||
/// new piece and `'` for a piece kept under its old name, any number of times
|
||||
/// (`Bracket (1) (1)' (1)` → `Bracket`).
|
||||
fn split_base(name: &str) -> &str {
|
||||
let mut s = name;
|
||||
loop {
|
||||
let t = s.trim_end_matches('\'');
|
||||
let t = match t.strip_suffix(')').and_then(|r| r.rsplit_once(" (")) {
|
||||
Some((head, n)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => head,
|
||||
_ => t,
|
||||
};
|
||||
if t.len() == s.len() || t.is_empty() {
|
||||
return s;
|
||||
}
|
||||
s = t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the pieces of a body split in the design `<body>.1`, `<body>.2`, … by position, instead
|
||||
/// of the suffixes the splits stacked up (`Bracket (1) (1)' (1)`), which say nothing about where
|
||||
/// a piece is. Pieces are ordered by centroid, x first, then y, then z, on a grid of a millionth
|
||||
/// of their spread, so the names follow the geometry, not the order of the design's timeline. A
|
||||
/// group is left as it is when one of the new names is a body's name already. The pieces keep
|
||||
/// the group's places in the list of bodies, in the order of their numbers. Returns each
|
||||
/// renamed body's (old, new) name.
|
||||
fn number_split_bodies(bodies: &mut [Shape]) -> Vec<(String, String)> {
|
||||
let mut renamed = Vec::new();
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
groups.entry(split_base(&b.name).to_string()).or_default().push(i);
|
||||
}
|
||||
for (base, mut members) in groups {
|
||||
if members.len() < 2 || members.iter().all(|&i| bodies.get(i).is_none_or(|b| b.name == base)) {
|
||||
continue;
|
||||
}
|
||||
let names: Vec<String> = (1..=members.len()).map(|k| format!("{base}.{k}")).collect();
|
||||
if bodies.iter().any(|b| names.contains(&b.name)) {
|
||||
continue;
|
||||
}
|
||||
let centroid = |i: usize| bodies.get(i).and_then(|b| b.measure().ok()).map_or([0.0; 3], |m| m.centroid);
|
||||
let centres: Vec<[f64; 3]> = members.iter().map(|&i| centroid(i)).collect();
|
||||
let spread = (0..3)
|
||||
.map(|k| {
|
||||
let (lo, hi) = centres.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c[k]), hi.max(c[k])));
|
||||
hi - lo
|
||||
})
|
||||
.fold(0.0f64, f64::max);
|
||||
let grid = if spread.is_finite() && spread > 0.0 { spread * 1e-6 } else { 1.0 };
|
||||
let key = |j: usize| centres.get(j).map_or([0; 3], |c| c.map(|v| (v / grid).round() as i64));
|
||||
let mut order: Vec<usize> = (0..members.len()).collect();
|
||||
order.sort_by_key(|&j| (key(j), j));
|
||||
// The group keeps its places in the list, now in the order of the numbers.
|
||||
let ordered: Vec<Shape> = order.into_iter().filter_map(|j| members.get(j).and_then(|&i| bodies.get(i)).cloned()).collect();
|
||||
members.sort_unstable();
|
||||
for ((slot, mut shape), name) in members.into_iter().zip(ordered).zip(names) {
|
||||
renamed.push((std::mem::replace(&mut shape.name, name.clone()), name));
|
||||
if let Some(b) = bodies.get_mut(slot) {
|
||||
*b = shape;
|
||||
}
|
||||
}
|
||||
}
|
||||
renamed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_suffixes_are_stripped() {
|
||||
assert_eq!(split_base("Bracket (1) (1)' (1)"), "Bracket");
|
||||
assert_eq!(split_base("Bracket (1)'''"), "Bracket");
|
||||
assert_eq!(split_base("Bracket"), "Bracket");
|
||||
assert_eq!(split_base("Part (a)"), "Part (a)");
|
||||
assert_eq!(split_base("(1)"), "(1)");
|
||||
assert_eq!(split_base("'''"), "'''");
|
||||
}
|
||||
|
||||
#[cfg(feature = "scripts")]
|
||||
/// The corpus bracket, split 29 times in the design, opens as `Bracket.1` … `Bracket.30`,
|
||||
/// numbered by position.
|
||||
#[test]
|
||||
fn pieces_of_a_split_body_are_numbered_by_position() {
|
||||
let script = include_str!("../../../tests/corpus/filleted-bracket-blocks/recipe.json");
|
||||
let d = open_design(&crate::script_design(script).unwrap()).unwrap();
|
||||
let names: Vec<&str> = d.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let expected: Vec<String> = (1..=30).map(|k| format!("Bracket.{k}")).collect();
|
||||
assert_eq!(names, expected.iter().map(String::as_str).collect::<Vec<_>>(), "listed in the order of their numbers");
|
||||
let x = |n: &str| d.body(n).unwrap().measure().unwrap().centroid[0];
|
||||
assert!(x("Bracket.1") <= x("Bracket.30"));
|
||||
}
|
||||
use crate::sample::{self, Plate};
|
||||
|
||||
fn face_areas(shape: &Shape) -> Vec<f64> {
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
//! edge keeps its name through an edit of the design that keeps its faces' names, and the cut
|
||||
//! edges of a partition are named after the `partition:<name>:a|b` faces.
|
||||
//!
|
||||
//! A vertex is named the same way after the faces that meet there (the faces of the edges that
|
||||
//! end at it): `F1:top|F2:side:l1|F2:side:l2`, numbered `~0`, `~1`, … where several vertices
|
||||
//! touch the same faces.
|
||||
//!
|
||||
//! A shape built in code has no face names; its edges are named after the face indices.
|
||||
|
||||
use crate::{Result, Shape, SurfaceMesh};
|
||||
@@ -57,6 +61,17 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// A B-rep vertex of a body: where edges end.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Vertex {
|
||||
/// The persistent name (see the module docs).
|
||||
pub name: String,
|
||||
pub point: [f64; 3],
|
||||
}
|
||||
|
||||
/// Most edge ends looked at for vertices (each against the vertices found so far).
|
||||
const MAX_EDGE_ENDS: usize = 20_000;
|
||||
|
||||
/// The length-weighted centre of a polyline (its first point when it has no length).
|
||||
fn centre(points: &[[f64; 3]]) -> [f64; 3] {
|
||||
let mut sum = [0.0; 3];
|
||||
@@ -137,6 +152,48 @@ impl Shape {
|
||||
names
|
||||
}
|
||||
|
||||
/// The vertices of `mesh` (a tessellation of this shape) with their persistent names: the
|
||||
/// ends of its edges, those within the tessellation's weld distance being one vertex. A
|
||||
/// closed edge with no other edge on it (a full circle) has no vertex. Empty for a shape with
|
||||
/// more than [`MAX_EDGE_ENDS`] edge ends.
|
||||
pub fn vertex_names(&self, mesh: &SurfaceMesh) -> Vec<Vertex> {
|
||||
let ends = mesh.edges.iter().enumerate().flat_map(|(i, e)| [e.first(), e.last()].into_iter().flatten().map(move |p| (i, *p)));
|
||||
if mesh.edges.len().saturating_mul(2) > MAX_EDGE_ENDS {
|
||||
return Vec::new();
|
||||
}
|
||||
let close = (2.0 * mesh.weld_distance).max(1e-9);
|
||||
// Per vertex: its point and the edges that end at it.
|
||||
let mut found: Vec<([f64; 3], Vec<usize>)> = Vec::new();
|
||||
for (edge, p) in ends {
|
||||
match found.iter_mut().find(|(q, _)| distance(*q, p) <= close) {
|
||||
Some((_, edges)) => edges.push(edge),
|
||||
None => found.push((p, vec![edge])),
|
||||
}
|
||||
}
|
||||
// A closed edge alone (its two ends one vertex, no other edge there) has no corner.
|
||||
found.retain(|(_, edges)| edges.windows(2).any(|w| w[0] != w[1]) || edges.len() > 2);
|
||||
let mut names: Vec<String> = found
|
||||
.iter()
|
||||
.map(|(_, edges)| {
|
||||
let mut faces: Vec<u32> = edges.iter().filter_map(|e| mesh.edge_faces.get(*e)).flatten().copied().collect();
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
let mut n: Vec<String> = faces.into_iter().map(|f| self.face_label(f)).collect();
|
||||
n.sort();
|
||||
n.join("|")
|
||||
})
|
||||
.collect();
|
||||
let points: Vec<[f64; 3]> = found.iter().map(|(p, _)| *p).collect();
|
||||
number_repeats(&mut names, &points);
|
||||
names.into_iter().zip(points).map(|(name, point)| Vertex { name, point }).collect()
|
||||
}
|
||||
|
||||
/// The body's vertices with their persistent names.
|
||||
pub fn vertices(&self) -> Result<Vec<Vertex>> {
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
Ok(self.vertex_names(&mesh))
|
||||
}
|
||||
|
||||
/// The body's edges with their persistent names, from a tessellation fine enough to place them.
|
||||
pub fn edges(&self) -> Result<Vec<Edge>> {
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
@@ -204,6 +261,27 @@ mod tests {
|
||||
assert_eq!(n, names(&ea));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_corner_of_the_plate_has_its_own_name_from_its_faces() {
|
||||
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let body = &d.bodies[0];
|
||||
let vertices = body.vertices().unwrap();
|
||||
// The box's 8 corners and the ends of the hole's two seams (2 × 2).
|
||||
assert_eq!(vertices.len(), 12, "{vertices:?}");
|
||||
let mut unique: Vec<&str> = vertices.iter().map(|v| v.name.as_str()).collect();
|
||||
unique.sort_unstable();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), vertices.len(), "{unique:?}");
|
||||
// A box corner is where three faces meet.
|
||||
let corner = vertices.iter().find(|v| v.point == [0.0, 0.0, 0.0]).unwrap();
|
||||
assert_eq!(corner.name.split('|').count(), 3, "{}", corner.name);
|
||||
// The names survive an edit that keeps the faces.
|
||||
let b = open_design(&sample::plate_design(&Plate { width: 70.0, ..Plate::default() }).unwrap()).unwrap();
|
||||
let mut other: Vec<String> = b.bodies[0].vertices().unwrap().into_iter().map(|v| v.name).collect();
|
||||
other.sort_unstable();
|
||||
assert_eq!(other, unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_along_an_edge() {
|
||||
let e = super::Edge { name: "e".into(), faces: vec![], points: vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0]] };
|
||||
|
||||
@@ -25,7 +25,7 @@ pub mod sample;
|
||||
mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
|
||||
pub use edges::{EDGE_TOLERANCE, Edge};
|
||||
pub use edges::{EDGE_TOLERANCE, Edge, Vertex};
|
||||
pub use partition::{Cut, CutReport};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
@@ -215,6 +215,11 @@ impl Shape {
|
||||
Ok(guard("size", || Ok(self.body.size()))?)
|
||||
}
|
||||
|
||||
/// The boundary shells of the body: one for a solid in one piece without voids.
|
||||
pub fn shells(&self) -> Result<usize> {
|
||||
Ok(guard("shells", || Ok(self.body.shell_count()))?)
|
||||
}
|
||||
|
||||
pub fn measure(&self) -> Result<Measure> {
|
||||
let m = guard("measure", || solidcraft_kernel::measure(&self.body))?;
|
||||
Ok(Measure {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Partitions: a design's bodies cut into cells by planes, for meshing cell by cell.
|
||||
//!
|
||||
//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel
|
||||
//! (`split_by_plane`, the operation behind SolidCraft's split body), in memory: the design file is
|
||||
//! never changed. The partitions live in the FieldCraft model and are applied again, in order,
|
||||
//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel (the body's
|
||||
//! intersections with a box on each side of the plane, as SolidCraft's split body does), in
|
||||
//! memory: the design file is never changed. The partitions live in the FieldCraft model and are applied again, in order,
|
||||
//! whenever the design is opened, so an edit of the design followed by a re-open replays them.
|
||||
//!
|
||||
//! Names are FieldCraft's own, so they do not depend on the order of the design's timeline:
|
||||
@@ -52,6 +52,8 @@ pub struct CutReport {
|
||||
pub split: Vec<String>,
|
||||
/// How many cut faces it named.
|
||||
pub faces: usize,
|
||||
/// What may make its cells hard to mesh: the plane lying on a face of a body it cut.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn vec3(p: [f64; 3]) -> Vec3 {
|
||||
@@ -89,8 +91,8 @@ fn plane_of(cut: &Cut) -> Result<(Plane, [f64; 3])> {
|
||||
|
||||
impl Design {
|
||||
/// The design as opened, cut by `cuts` in order (the partitions replace any applied before).
|
||||
/// A cut that names a body the design does not have, or that does not divide a body it
|
||||
/// names, is listed in [`Design::errors`] (feature `partition <name>`), as a feature of the
|
||||
/// A cut that names a body the design does not have, that does not divide a body it names,
|
||||
/// or that the kernel fails to make on any body it applies to, is listed in [`Design::errors`] (feature `partition <name>`), as a feature of the
|
||||
/// design that failed to rebuild; the rest still apply.
|
||||
pub fn partitioned(&self, cuts: &[Cut]) -> Result<Design> {
|
||||
if cuts.len() > MAX_CUTS {
|
||||
@@ -104,10 +106,10 @@ impl Design {
|
||||
let feature = format!("partition {}", cut.name);
|
||||
for b in &cut.bodies {
|
||||
if !bodies.iter().any(|x| &x.name == b) {
|
||||
errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut") });
|
||||
errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] });
|
||||
}
|
||||
}
|
||||
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0 };
|
||||
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() };
|
||||
let mut next = Vec::with_capacity(bodies.len() + 1);
|
||||
for body in bodies {
|
||||
let listed = cut.bodies.iter().any(|b| b == &body.name);
|
||||
@@ -116,17 +118,32 @@ impl Design {
|
||||
continue;
|
||||
}
|
||||
match split(&body, plane, *n, cut)? {
|
||||
Some((cells, faces)) => {
|
||||
Split::Cells(cells, faces, warnings) => {
|
||||
report.split.push(body.name.clone());
|
||||
report.faces += faces;
|
||||
report.warnings.extend(warnings);
|
||||
next.extend(cells);
|
||||
}
|
||||
None => {
|
||||
Split::Misses => {
|
||||
if listed {
|
||||
errors.push(FeatureError { feature: feature.clone(), message: format!("the plane does not divide `{}`", body.name) });
|
||||
errors.push(FeatureError {
|
||||
feature: feature.clone(),
|
||||
message: format!("the plane does not divide `{}`", body.name),
|
||||
bodies: vec![body.name.clone()],
|
||||
});
|
||||
}
|
||||
next.push(body);
|
||||
}
|
||||
// Listed or not: the plane may well divide the body, and a cut that silently
|
||||
// leaves it whole would be mistaken for one that does not reach it (#283).
|
||||
Split::Failed(e) => {
|
||||
errors.push(FeatureError {
|
||||
feature: feature.clone(),
|
||||
message: format!("the kernel could not cut `{}` ({e})", body.name),
|
||||
bodies: vec![body.name.clone()],
|
||||
});
|
||||
next.push(body);
|
||||
}
|
||||
}
|
||||
if next.len() > MAX_CELLS {
|
||||
return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells")));
|
||||
@@ -139,13 +156,85 @@ impl Design {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut one body: its cells, named, and how many cut faces they have; `None` when the plane does
|
||||
/// not divide it.
|
||||
fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Option<(Vec<Shape>, usize)>> {
|
||||
let parts = match guard("partition", || solidcraft_kernel::split_by_plane(&body.body, plane)) {
|
||||
Ok(p) if p.len() >= 2 => p,
|
||||
_ => return Ok(None),
|
||||
/// The parts of `body` on either side of `plane` (only the non-empty ones): its intersections
|
||||
/// with two boxes, one on each side, whose faces in the plane are squares as wide as the body's
|
||||
/// bounding-box diagonal and that reach that far from it. SolidCraft's `split_by_plane` uses boxes
|
||||
/// several times the body's size, and its boolean then puts the cut up to a few micrometres off
|
||||
/// the plane (1.8 µm on a 140 mm body), off the faces it should meet (#384); boxes of the body's own
|
||||
/// size keep the cut on the plane.
|
||||
fn halves(body: &Shape, plane: &Plane) -> solidcraft_kernel::Result<Vec<solidcraft_kernel::Body>> {
|
||||
let bbox = solidcraft_kernel::measure(&body.body)?.bbox;
|
||||
let reach = bbox.diagonal().max(LINEAR_REACH);
|
||||
let c = plane.to_local(bbox.center());
|
||||
let square = solidcraft_geom::Region2 {
|
||||
outer: solidcraft_geom::Loop2::polygon(&[
|
||||
solidcraft_geom::Vec2::new(c.x - reach, c.y - reach),
|
||||
solidcraft_geom::Vec2::new(c.x + reach, c.y - reach),
|
||||
solidcraft_geom::Vec2::new(c.x + reach, c.y + reach),
|
||||
solidcraft_geom::Vec2::new(c.x - reach, c.y + reach),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut out = Vec::with_capacity(2);
|
||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||||
let Some(side) = solidcraft_kernel::extrude(plane, std::slice::from_ref(&square), lo, hi)?.pop() else { continue };
|
||||
out.extend(solidcraft_kernel::boolean(&body.body, &side, solidcraft_kernel::BoolOp::Intersect)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Planar faces of `body` the cut's plane lies on (within [`COINCIDENT`] of the body's size),
|
||||
/// as warnings: the kernel may leave micrometre slivers where a cut runs along a face (#384).
|
||||
fn coincident(body: &Shape, parent: &Faces, cut: &Cut, n: [f64; 3], size: f64) -> Vec<String> {
|
||||
let reach = COINCIDENT * size.max(1.0);
|
||||
let mut out = Vec::new();
|
||||
for (i, surface) in parent.surfaces.iter().enumerate() {
|
||||
let Some(FaceSurface::Plane { normal, point }) = surface else { continue };
|
||||
let along = dot(*normal, n).abs();
|
||||
let distance = dot(sub(*point, cut.point), n).abs();
|
||||
if (along - 1.0).abs() < 1e-9 && distance <= reach {
|
||||
let name = parent.names.get(i).map_or_else(|| i.to_string(), |f| f.name.clone());
|
||||
out.push(format!(
|
||||
"partition `{}`: the plane lies on face `{name}` of `{}` ({distance:.2e} mm from it); a cut along an existing face can leave micrometre slivers that no mesher takes: move the plane off the face (by a fraction of the element size)",
|
||||
cut.name, body.name
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plane within this fraction of a body's size of one of its faces lies on it.
|
||||
const COINCIDENT: f64 = 1e-5;
|
||||
|
||||
/// The least reach of the boxes [`halves`] cuts with (mm), for bodies with no extent.
|
||||
const LINEAR_REACH: f64 = 1.0;
|
||||
|
||||
/// What cutting one body gave.
|
||||
enum Split {
|
||||
/// Its cells, named, how many cut faces they have, and warnings.
|
||||
Cells(Vec<Shape>, usize, Vec<String>),
|
||||
/// The plane does not divide it.
|
||||
Misses,
|
||||
/// The kernel failed to cut it: its message.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Cut one body.
|
||||
fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Split> {
|
||||
let sides = match guard("partition", || halves(body, plane)) {
|
||||
Ok(p) if p.len() >= 2 => p,
|
||||
Ok(_) => return Ok(Split::Misses),
|
||||
Err(e) => return Ok(Split::Failed(e.to_string())),
|
||||
};
|
||||
// Each side may fall apart into pieces that do not touch (#388): every piece is a cell.
|
||||
let mut parts = Vec::with_capacity(sides.len());
|
||||
for side in sides {
|
||||
match guard("partition", || side.lumps()) {
|
||||
Ok(pieces) if !pieces.is_empty() => parts.extend(pieces),
|
||||
Ok(_) => parts.push(side),
|
||||
Err(e) => return Ok(Split::Failed(e.to_string())),
|
||||
}
|
||||
}
|
||||
let size = body.size()?;
|
||||
let tol = (MATCH_TOLERANCE * size).max(1e-4);
|
||||
let parent = Faces::of(body, tol)?;
|
||||
@@ -176,7 +265,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Option<(
|
||||
faces += name_faces(&mut shape, &parent, cut, n, size, tol)?;
|
||||
out.push(shape);
|
||||
}
|
||||
Ok(Some((out, faces)))
|
||||
Ok(Split::Cells(out, faces, coincident(body, &parent, cut, n, size)))
|
||||
}
|
||||
|
||||
/// A body's faces as matching needs them: each face's analytic surface, name and triangles.
|
||||
@@ -404,7 +493,7 @@ mod tests {
|
||||
assert_eq!(p.bodies.len(), 2, "{:?}", p.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let volume: f64 = p.bodies.iter().map(|b| b.measure().unwrap().volume).sum();
|
||||
assert!((volume - whole).abs() < 1e-6 * whole, "{volume} vs {whole}");
|
||||
assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4 }]);
|
||||
assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4, warnings: Vec::new() }]);
|
||||
let mut sides = Vec::new();
|
||||
for b in &p.bodies {
|
||||
let m = b.measure().unwrap();
|
||||
|
||||
@@ -35,7 +35,7 @@ use fieldcraft_model::MAX_COORD;
|
||||
/// flattens the object (relative, so a uniform scale is never refused).
|
||||
const MIN_RELATIVE_DET: f64 = 1e-12;
|
||||
|
||||
const CORE_NS: &[u8] = b"http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
|
||||
const CORE_NS: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
|
||||
/// SolidCraft's face-tag namespace, version 1.
|
||||
pub const FACE_TAGS_NS: &str = "https://github.com/bherbruck/solidcraft/formats/3mf-face-tags/2026/10";
|
||||
|
||||
@@ -58,8 +58,8 @@ fn bad<T>(msg: impl std::fmt::Display) -> Result<T> {
|
||||
|
||||
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
|
||||
let want = name.trim_start_matches('/').to_ascii_lowercase();
|
||||
let Some(idx) =
|
||||
(0..zip.len()).find(|i| zip.name_for_index(*i).is_some_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want))
|
||||
let Some(idx) = (0..zip.len())
|
||||
.find(|i| zip.name_for_index(*i).is_some_and(|n| n.is_ok_and(|n| n.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase() == want)))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -85,7 +85,7 @@ enum Ns {
|
||||
fn ns_of(r: &ResolveResult) -> Ns {
|
||||
match r {
|
||||
ResolveResult::Bound(n) if n.as_ref() == CORE_NS => Ns::Core,
|
||||
ResolveResult::Bound(n) if n.as_ref() == FACE_TAGS_NS.as_bytes() => Ns::FaceTags,
|
||||
ResolveResult::Bound(n) if n.as_ref() == FACE_TAGS_NS => Ns::FaceTags,
|
||||
ResolveResult::Unbound => Ns::Core,
|
||||
_ => Ns::Other,
|
||||
}
|
||||
@@ -93,7 +93,7 @@ fn ns_of(r: &ResolveResult) -> Ns {
|
||||
|
||||
/// An element's attributes as (namespace, local name, value), borrowed from the event (values
|
||||
/// are copied only when they hold entity references). Unprefixed attributes are Core.
|
||||
type Attrs<'a> = Vec<(Ns, &'a [u8], Cow<'a, str>)>;
|
||||
type Attrs<'a> = Vec<(Ns, &'a str, Cow<'a, str>)>;
|
||||
|
||||
fn attrs<'a, R>(rd: &NsReader<R>, e: &'a BytesStart) -> Result<Attrs<'a>> {
|
||||
let mut out = Vec::with_capacity(4);
|
||||
@@ -107,7 +107,7 @@ fn attrs<'a, R>(rd: &NsReader<R>, e: &'a BytesStart) -> Result<Attrs<'a>> {
|
||||
}
|
||||
|
||||
fn get<'s>(a: &'s Attrs, ns: Ns, key: &str) -> Option<&'s str> {
|
||||
a.iter().find(|(n, k, _)| *n == ns && *k == key.as_bytes()).map(|(_, _, v)| v.as_ref())
|
||||
a.iter().find(|(n, k, _)| *n == ns && *k == key).map(|(_, _, v)| v.as_ref())
|
||||
}
|
||||
|
||||
fn int(a: &Attrs, ns: Ns, key: &str, what: &str) -> Result<Option<u32>> {
|
||||
@@ -216,7 +216,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
Event::End(end) => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if ns == Ns::Core
|
||||
&& end.local_name().as_ref() == b"object"
|
||||
&& end.local_name().as_ref() == "object"
|
||||
&& let Some((id, o)) = cur.take()
|
||||
{
|
||||
m.add(id, o)?;
|
||||
@@ -237,13 +237,13 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
}
|
||||
let a = attrs(&rd, &e)?;
|
||||
match e.local_name().as_ref() {
|
||||
b"model" => {
|
||||
"model" => {
|
||||
seen_model = true;
|
||||
if let Some(u) = get(&a, Ns::Core, "unit") {
|
||||
m.unit = unit_mm(u).ok_or_else(|| invalid(format!("unknown unit `{u}`")))?;
|
||||
}
|
||||
}
|
||||
b"object" => {
|
||||
"object" => {
|
||||
if cur.is_some() {
|
||||
return bad("an object inside an object");
|
||||
}
|
||||
@@ -262,7 +262,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
cur = Some((id, o));
|
||||
}
|
||||
}
|
||||
b"vertex" => {
|
||||
"vertex" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("vertex outside an object") };
|
||||
vertices += 1;
|
||||
if vertices > MAX_ELEMENTS {
|
||||
@@ -270,7 +270,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
}
|
||||
o.positions.push([coord(&a, "x")?, coord(&a, "y")?, coord(&a, "z")?]);
|
||||
}
|
||||
b"triangle" => {
|
||||
"triangle" => {
|
||||
let Some((id, o)) = cur.as_mut() else { return bad("triangle outside an object") };
|
||||
triangles += 1;
|
||||
if triangles > MAX_ELEMENTS {
|
||||
@@ -288,11 +288,11 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
}
|
||||
o.triangles.push(t);
|
||||
}
|
||||
b"component" => {
|
||||
"component" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
|
||||
o.components = true;
|
||||
}
|
||||
b"item" => {
|
||||
"item" => {
|
||||
if m.build.len() >= MAX_BUILD_ITEMS {
|
||||
return bad(format!("more than {MAX_BUILD_ITEMS} build items"));
|
||||
}
|
||||
@@ -364,7 +364,7 @@ pub fn read_3mf(bytes: &[u8]) -> Result<Vec<TaggedSurface>> {
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match rd.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) if e.local_name().as_ref() == b"Relationship" => {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) if e.local_name().as_ref() == "Relationship" => {
|
||||
let a = attrs(&rd, &e)?;
|
||||
if get(&a, Ns::Core, "Type").is_some_and(|t| t.ends_with("/3dmodel"))
|
||||
&& let Some(t) = get(&a, Ns::Core, "Target")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Designs saved by SolidCraft, opened by `tests/void_shells.rs` as a person's file would be. Each
|
||||
`<name>.solidcraft` is `solidcraft-cli run <name>.json --out <name>.solidcraft`; the volumes the
|
||||
tests expect are analytic.
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 40,
|
||||
"width": 30,
|
||||
"height": 20,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"body_name": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.shell",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
20,
|
||||
15,
|
||||
20
|
||||
]
|
||||
],
|
||||
"thickness": 2,
|
||||
"direction": "inside",
|
||||
"body": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 40,
|
||||
"width": 30,
|
||||
"height": 2,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"Case"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"diameter": 6,
|
||||
"height": 5,
|
||||
"base": [
|
||||
8,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"Case"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"format": "solidcraft/21",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Case"
|
||||
],
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "20",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Shell1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"face_names": [
|
||||
"F1:+z"
|
||||
],
|
||||
"type": "shell",
|
||||
"faces": [
|
||||
[
|
||||
20.0,
|
||||
15.0,
|
||||
20.0
|
||||
]
|
||||
],
|
||||
"thickness": "2",
|
||||
"body": "Case"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Box2",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5",
|
||||
"d6",
|
||||
"d7"
|
||||
],
|
||||
"participants": [
|
||||
"Case"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
20.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "2",
|
||||
"operation": "join"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cylinder1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d8",
|
||||
"d9"
|
||||
],
|
||||
"participants": [
|
||||
"Case"
|
||||
],
|
||||
"type": "cylinder",
|
||||
"base": [
|
||||
8.0,
|
||||
8.0,
|
||||
2.0
|
||||
],
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"radius": "(6 mm) / 2",
|
||||
"height": "5",
|
||||
"operation": "join"
|
||||
}
|
||||
],
|
||||
"next_id": 5
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 40,
|
||||
"width": 30,
|
||||
"height": 20,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"body_name": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.shell",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
20,
|
||||
15,
|
||||
20
|
||||
]
|
||||
],
|
||||
"thickness": 2,
|
||||
"direction": "inside",
|
||||
"body": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 40,
|
||||
"width": 30,
|
||||
"height": 2,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"Case"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"format": "solidcraft/21",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Case"
|
||||
],
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "20",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Shell1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"face_names": [
|
||||
"F1:+z"
|
||||
],
|
||||
"type": "shell",
|
||||
"faces": [
|
||||
[
|
||||
20.0,
|
||||
15.0,
|
||||
20.0
|
||||
]
|
||||
],
|
||||
"thickness": "2",
|
||||
"body": "Case"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Box2",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5",
|
||||
"d6",
|
||||
"d7"
|
||||
],
|
||||
"participants": [
|
||||
"Case"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
20.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "2",
|
||||
"operation": "join"
|
||||
}
|
||||
],
|
||||
"next_id": 4
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 40,
|
||||
"width": 30,
|
||||
"height": 20,
|
||||
"corner": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"body_name": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.shell",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
20,
|
||||
15,
|
||||
20
|
||||
]
|
||||
],
|
||||
"thickness": 2,
|
||||
"direction": "inside",
|
||||
"body": "Case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.cylinder",
|
||||
"params": {
|
||||
"diameter": 6,
|
||||
"height": 5,
|
||||
"base": [
|
||||
8,
|
||||
8,
|
||||
2
|
||||
],
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"Case"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"format": "solidcraft/21",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Case"
|
||||
],
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "20",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Shell1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"face_names": [
|
||||
"F1:+z"
|
||||
],
|
||||
"type": "shell",
|
||||
"faces": [
|
||||
[
|
||||
20.0,
|
||||
15.0,
|
||||
20.0
|
||||
]
|
||||
],
|
||||
"thickness": "2",
|
||||
"body": "Case"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Cylinder1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5",
|
||||
"d6"
|
||||
],
|
||||
"participants": [
|
||||
"Case"
|
||||
],
|
||||
"type": "cylinder",
|
||||
"base": [
|
||||
8.0,
|
||||
8.0,
|
||||
2.0
|
||||
],
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"radius": "(6 mm) / 2",
|
||||
"height": "5",
|
||||
"operation": "join"
|
||||
}
|
||||
],
|
||||
"next_id": 4
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! A solid with a void shell (a box shelled, then capped) opens with every shell and everything
|
||||
//! joined to them (#364); a feature that fails to rebuild is reported naming the body it changes.
|
||||
|
||||
use fieldcraft_geometry::{Design, open_design};
|
||||
|
||||
const PI: f64 = std::f64::consts::PI;
|
||||
/// The outer box, 40 × 30 × 20 mm, and its cavity with 2 mm walls and floor.
|
||||
const BOX: f64 = 40.0 * 30.0 * 20.0;
|
||||
const CAVITY: f64 = 36.0 * 26.0 * 18.0;
|
||||
const LID: f64 = 40.0 * 30.0 * 2.0;
|
||||
/// A post ⌀6 × 5 mm standing on the floor.
|
||||
const POST: f64 = PI * 9.0 * 5.0;
|
||||
|
||||
fn open(bytes: &[u8]) -> Design {
|
||||
let design = open_design(bytes).unwrap();
|
||||
assert!(design.errors.is_empty(), "{:?}", design.errors);
|
||||
assert_eq!(design.bodies.len(), 1);
|
||||
design
|
||||
}
|
||||
|
||||
fn check(design: &Design, volume: f64, faces: usize) {
|
||||
let body = design.body("Case").unwrap();
|
||||
let m = body.measure().unwrap();
|
||||
assert!((m.volume - volume).abs() < 1e-5 * volume, "{} vs {volume}", m.volume);
|
||||
assert_eq!(m.faces, faces);
|
||||
let mut names: Vec<&str> = body.faces().iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names.len(), faces);
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), faces, "every face has its own name");
|
||||
// The mesh sees the same solid: every face tessellated, the same enclosed volume.
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
let mut tags = mesh.tri_face.clone();
|
||||
tags.sort_unstable();
|
||||
tags.dedup();
|
||||
assert_eq!(tags.len(), faces);
|
||||
assert!((mesh.enclosed_volume() - volume).abs() < 1e-3 * volume, "{} vs {volume}", mesh.enclosed_volume());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_hollow_box_keeps_its_void_shell() {
|
||||
// Six outside faces and six cavity faces.
|
||||
check(&open(include_bytes!("designs/hollow-box.solidcraft")), BOX - CAVITY + LID, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_post_joined_to_the_void_floor_comes_through() {
|
||||
// The hollow box's twelve faces, the post's top and its side (two halves).
|
||||
check(&open(include_bytes!("designs/hollow-box-post.solidcraft")), BOX - CAVITY + LID + POST, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_post_in_an_open_tray_comes_through() {
|
||||
// Five outside faces, the rim, five cavity faces and the post's three.
|
||||
check(&open(include_bytes!("designs/open-tray-post.solidcraft")), BOX - CAVITY + POST, 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feature_that_fails_names_the_body_it_changes() {
|
||||
let text = std::str::from_utf8(include_bytes!("designs/hollow-box-post.solidcraft")).unwrap();
|
||||
assert!(text.contains(r#""height": "5""#));
|
||||
let broken = text.replace(r#""height": "5""#, r#""height": "0""#);
|
||||
let design = open_design(broken.as_bytes()).unwrap();
|
||||
let [error] = design.errors.as_slice() else { panic!("{:?}", design.errors) };
|
||||
assert_eq!(error.feature, "Cylinder1");
|
||||
assert_eq!(error.bodies, ["Case"]);
|
||||
let warning = error.warning();
|
||||
assert!(warning.starts_with(r#"Cylinder1 failed to build, so body "Case" may differ from the design: "#), "{warning}");
|
||||
}
|
||||
@@ -179,8 +179,8 @@ pub struct Geometry {
|
||||
/// Most items a view hides.
|
||||
pub const MAX_HIDDEN: usize = 100_000;
|
||||
|
||||
/// Longest name of a hidden item.
|
||||
const MAX_HIDDEN_NAME: usize = 1024;
|
||||
/// Longest name of a hidden item (a body's name is a geometry name).
|
||||
const MAX_HIDDEN_NAME: usize = fieldcraft_model::geometry::MAX_GEOMETRY_NAME_LEN;
|
||||
|
||||
/// The kinds of items the viewport draws one by one, and so can hide.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -372,7 +372,8 @@ pub fn read(bytes: &[u8]) -> Result<ModelFile> {
|
||||
let names = [MANIFEST, MODEL, MESH, JOURNAL];
|
||||
let mut seen = Vec::with_capacity(zip.len());
|
||||
for name in zip.file_names() {
|
||||
if !names.contains(&name) {
|
||||
let name = name.map_err(|e| Error::Invalid(format!("not a FieldCraft file: {e}")))?;
|
||||
if !names.contains(&name.as_ref()) {
|
||||
return invalid(format!("unexpected part `{}`", name.chars().take(80).collect::<String>()));
|
||||
}
|
||||
if seen.contains(&name) {
|
||||
|
||||
@@ -52,7 +52,7 @@ fn parts(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
|
||||
let mut f = zip.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
(f.name().to_string(), b)
|
||||
(f.name().unwrap().to_string(), b)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -135,8 +135,13 @@ pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResul
|
||||
let mut history_times = Vec::new();
|
||||
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
|
||||
let mut next_frame = 1u64;
|
||||
fieldcraft_model::progress::stage("Fluid steps", Some(total as f64), "");
|
||||
for k in 1..=total {
|
||||
fieldcraft_model::progress::check()?;
|
||||
flow.step()?;
|
||||
if k % 64 == 0 {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
}
|
||||
if k % every == 0 || k == total {
|
||||
history_times.push(k as f64 * setup.time_step);
|
||||
for (h, f) in forces.iter_mut().zip(flow.forces()) {
|
||||
|
||||
@@ -164,9 +164,10 @@ impl Remote {
|
||||
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
// Meshing and solving big models take a while; the app answers a request it
|
||||
// has not finished within 60 s with a timeout of its own.
|
||||
s.set_read_timeout(Some(Duration::from_secs(600))).ok();
|
||||
// Meshing and solving big models take as long as they take: the app answers
|
||||
// a solve or a mesher when its job ends (docs/control-protocol.md, Jobs), and
|
||||
// a closed app closes the connection.
|
||||
s.set_read_timeout(None).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
return Ok(());
|
||||
|
||||
@@ -29,7 +29,7 @@ forces in N); Z is up. A typical analysis: `open` a .solidcraft design (or a .fi
|
||||
.3mf); `inspect_model` for its bodies and their persistent face names; `mesh` (method tet: {body, tolerance}); \
|
||||
then `execute` the model definition commands (model.set.create {name, geometry: [{body, face}]}, \
|
||||
model.material.create, model.section.create, model.boundary_condition.create, model.load.create, model.step.create \
|
||||
— list_commands shows each one's parameters); `check_model`; `solve` (static, frequency, explicit, fluid); \
|
||||
— list_commands shows each one's parameters); `check_model`; `solve` (static, nonlinear, frequency, buckle, explicit, fluid, heat); \
|
||||
`show_results` (field: displacement, von_mises…; its reply has the field's range) and `probe` / `reactions` for \
|
||||
numbers; `screenshot` to look; `save` or `write_deck` (abaqus, nastran, lsdyna). Partitions: geometry.datum makes a \
|
||||
datum plane, geometry.partition cuts the design's bodies into cells by it (cut faces partition:<name>:a|b; kept in \
|
||||
|
||||
@@ -224,6 +224,22 @@ fn screenshot_takes_a_camera_orientation() {
|
||||
assert_eq!(again, turned);
|
||||
}
|
||||
|
||||
/// `probe {at}` reads a structural result at the nodes nearest to points (#105); hostile points
|
||||
/// are refused without a panic.
|
||||
#[test]
|
||||
fn probe_takes_points_at() {
|
||||
let mut s = server();
|
||||
ok(&mut s, "execute", json!({"command": "file.sample", "params": {"name": "cantilever"}}));
|
||||
ok(&mut s, "solve", json!({"analysis": "static", "step": "Load"}));
|
||||
let r = ok(&mut s, "probe", json!({"step": "Load", "at": [[0, 0, 0]]}));
|
||||
let node = &r["nodes"][0];
|
||||
assert!(node["xyz"].is_array() && node["distance"].is_number(), "{r}");
|
||||
for at in [json!([[1e308, "x"]]), json!(vec![json!([0, 0, 0]); 20_000])] {
|
||||
let (text, err) = call(&mut s, "probe", json!({"step": "Load", "at": at}));
|
||||
assert!(err && text.len() < 100_000, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_stops_at_the_first_error() {
|
||||
let mut s = server();
|
||||
|
||||
+17
-8
@@ -30,8 +30,15 @@ const MESHERS: &[(&str, &str, &str)] = &[
|
||||
];
|
||||
|
||||
/// The analyses the `solve` tool runs; one whose command this build lacks is left out.
|
||||
const ANALYSES: &[(&str, &str)] =
|
||||
&[("static", "solve.static"), ("frequency", "solve.frequency"), ("explicit", "solve.explicit"), ("fluid", "solve.fluid"), ("heat", "solve.heat")];
|
||||
const ANALYSES: &[(&str, &str)] = &[
|
||||
("static", "solve.static"),
|
||||
("nonlinear", "solve.nonlinear"),
|
||||
("frequency", "solve.frequency"),
|
||||
("buckle", "solve.buckle"),
|
||||
("explicit", "solve.explicit"),
|
||||
("fluid", "solve.fluid"),
|
||||
("heat", "solve.heat"),
|
||||
];
|
||||
|
||||
const VIEWS: &[&str] = &["iso", "home", "fit", "front", "back", "top", "bottom", "left", "right"];
|
||||
|
||||
@@ -229,7 +236,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Show results",
|
||||
"Choose what the results display shows and read it back: the step, the deformed shape (`deformed`, `scale`: \
|
||||
a factor, 0 for the undeformed shape, or \"auto\"), contours of a nodal field (`field`: displacement, displacement_x/y/z, von_mises, \
|
||||
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24), a frequency step's mode or an \
|
||||
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24; `range` {min?, max?} or \"auto\" clamps the legend, values above max in the over colour), a frequency step's mode or an \
|
||||
explicit step's frame. The reply has the contour's range: its smallest and largest value and their nodes \
|
||||
(field displacement gives the largest displacement). Only `step`: show that step.",
|
||||
obj(
|
||||
@@ -237,6 +244,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"step": string("Solved step (default: the one shown)"),
|
||||
"field": string("Contour field, or \"none\""),
|
||||
"bands": integer("Contour bands, 2 to 24"),
|
||||
"range": {"type": ["object", "string"], "description": "Legend range {\"min\"?, \"max\"?} (each a number or \"auto\"), or \"auto\"; goes with `field`"},
|
||||
"deformed": {"type": "boolean", "description": "Draw the deformed shape"},
|
||||
"scale": {"type": ["number", "string"], "description": "Deformation scale factor (0: undeformed) or \"auto\""},
|
||||
"mode": integer("Mode of a frequency step, from 1"),
|
||||
@@ -251,7 +259,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"probe",
|
||||
"Probe results",
|
||||
"Stored results at nodes (solve.probe): displacement, reaction, stress and von Mises (static), the shape of \
|
||||
"Stored results at nodes, or at the nodes nearest to points `at` (solve.probe): displacement, reaction, stress and von Mises (static), the shape of \
|
||||
mode `mode` (frequency), displacement and velocity in field frame `frame` (explicit) and, with `elements`, \
|
||||
explicit element values; for a fluid step, the flow at `points` and the force histories of `forces`.",
|
||||
obj(
|
||||
@@ -259,6 +267,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"step": string("Solved step"),
|
||||
"nodes": {"type": "array", "items": {"type": "integer"}, "description": "Node labels"},
|
||||
"elements": {"type": "array", "items": {"type": "integer"}, "description": "Element labels (explicit)"},
|
||||
"at": {"type": "array", "items": {"type": "array"}, "description": "Points [x, y, z]; the nearest nodes are probed (structural steps)"},
|
||||
"points": {"type": "array", "items": {"type": "array"}, "description": "Points [x, y, z] (fluid)"},
|
||||
"forces": {"type": "array", "items": {"type": "string"}, "description": "Walls or faces whose force histories to return (fluid)"},
|
||||
"mode": integer("Mode, from 1 (frequency)"),
|
||||
@@ -497,7 +506,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
b.execute(command, params)
|
||||
}
|
||||
"show_results" => show_results(b, a),
|
||||
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
|
||||
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "at", "elements", "points", "forces", "mode", "frame"])),
|
||||
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
|
||||
"select" => {
|
||||
let p = pick(a, &["filter", "box_mode", "mode", "clear", "geometry", "nodes", "elements", "faces", "at", "box"]);
|
||||
@@ -538,9 +547,9 @@ fn show_results(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, St
|
||||
last = Some(b.execute("results.deformed", with_step(p))?);
|
||||
}
|
||||
if a.get("field").is_some_and(|v| !v.is_null()) {
|
||||
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands"])))?);
|
||||
} else if a.get("bands").is_some_and(|v| !v.is_null()) {
|
||||
return Err("show_results: `bands` goes with `field`".into());
|
||||
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands", "range"])))?);
|
||||
} else if a.get("bands").is_some_and(|v| !v.is_null()) || a.get("range").is_some_and(|v| !v.is_null()) {
|
||||
return Err("show_results: `bands` and `range` go with `field`".into());
|
||||
}
|
||||
match last {
|
||||
Some(v) => Ok(v),
|
||||
|
||||
@@ -130,6 +130,119 @@ pub fn hex_mesh_cells(cells: &[Cell], field: &dyn SizeField, opts: &HexOptions)
|
||||
Ok(CellsMesh { mesh, element_cell, blocks, interfaces: shared })
|
||||
}
|
||||
|
||||
/// Mesh `cells` (bodies that together make a part, meeting face to face) into one conforming
|
||||
/// all-hex mesh by sweeping them together, along one direction or about one axis (`opts.method`:
|
||||
/// [`HexMethod::Extrude`], [`HexMethod::Revolve`], or [`HexMethod::Auto`] / [`HexMethod::Swept`]
|
||||
/// for the first that fits).
|
||||
///
|
||||
/// This is many-to-many sweeping ([`super::layered`]) over the cells at once: the walls of every
|
||||
/// cell (its cut faces included) are imprinted into one cross-section and the levels of every cell
|
||||
/// are levels of all, so one interval assignment gives every curve and every level interval one
|
||||
/// count, and each region of each interval is filled by the cell it lies in. Where two cells meet
|
||||
/// they share the curves and layers there, so their elements meet node for node with no seeds;
|
||||
/// a cut that ends on another cut (a T-junction) is a curve ending on a curve like any other.
|
||||
/// Each element belongs to one cell and boundary faces are tagged with that cell's faces. Cells
|
||||
/// that do not all sweep in one frame are [`Error::Unsupported`] with the first reason.
|
||||
pub fn hex_mesh_swept_cells(cells: &[Cell], field: &dyn SizeField, opts: &HexOptions) -> Result<CellsMesh> {
|
||||
let methods: &[HexMethod] = match opts.method {
|
||||
HexMethod::Auto | HexMethod::Swept => &[HexMethod::Extrude, HexMethod::Revolve],
|
||||
HexMethod::Extrude => &[HexMethod::Extrude],
|
||||
HexMethod::Revolve => &[HexMethod::Revolve],
|
||||
m => return Err(Error::Invalid(format!("cells are swept together by extrusion or revolution; method `{}` does not apply", m.name()))),
|
||||
};
|
||||
if !(opts.chordal_tolerance >= 0.0 && opts.chordal_tolerance.is_finite()) {
|
||||
return Err(Error::Invalid(format!("the chordal tolerance must be a non-negative number, got {}", opts.chordal_tolerance)));
|
||||
}
|
||||
if cells.is_empty() || cells.len() > MAX_CELLS {
|
||||
return Err(Error::Invalid(format!("{} cells; 1 to {MAX_CELLS} are meshed together", cells.len())));
|
||||
}
|
||||
if let Some(c) = cells.iter().find(|c| c.surface.shells().len() != 1) {
|
||||
return Err(Error::Unsupported(format!("cell `{}` has {} shells; a cell is one solid without voids", c.name, c.surface.shells().len())));
|
||||
}
|
||||
let surfaces: Vec<&BoundarySurface> = cells.iter().map(|c| c.surface).collect();
|
||||
super::check_estimate(&surfaces, field, opts)?;
|
||||
let breps: Vec<Brep> = cells.iter().map(|c| Brep::new(c.surface, c.faces)).collect::<Result<_>>()?;
|
||||
let trees: Vec<TriangleTree> = cells.iter().map(|c| TriangleTree::new(c.surface)).collect::<Result<_>>()?;
|
||||
// One classification tolerance for all, from their joint extent.
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in cells.iter().flat_map(|c| c.surface.points()) {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
let tol = super::RELATIVE_TOLERANCE * dist(lo, hi);
|
||||
if !(tol > 0.0 && tol.is_finite()) {
|
||||
return Err(Error::Invalid("the cells have no extent".into()));
|
||||
}
|
||||
let ctxs: Vec<Ctx> = breps.iter().zip(&trees).map(|(brep, tree)| Ctx::with_tolerance(brep, tree, field, opts, tol)).collect::<Result<_>>()?;
|
||||
let names: Vec<&str> = cells.iter().map(|c| c.name).collect();
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for &method in methods {
|
||||
let setups = match super::candidate_setups(&ctxs, method) {
|
||||
Ok(s) => s,
|
||||
Err(why) => {
|
||||
reasons.push(format!("{}: {why}", method.name()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut last = None;
|
||||
for setup in setups {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let prepared = match super::layered::prepare(&ctxs, setup) {
|
||||
Ok(p) => p,
|
||||
Err(e @ Error::Limit(_)) => return Err(e),
|
||||
Err(e) => {
|
||||
last = Some(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// As a single body's sweep: on a face mesher's failure, again with finer curves.
|
||||
for refine in 0..=super::MAX_REFINE {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let made = super::layered::mesh(&ctxs, &prepared, refine)
|
||||
.and_then(|(raw, of)| Ok((finish(&Cells { ctxs: &ctxs, of: &of, names: &names }, raw, method)?, of)));
|
||||
match made {
|
||||
Ok((mesh, element_cell)) => {
|
||||
let interfaces = shared_faces(&mesh, &element_cell);
|
||||
return Ok(CellsMesh { mesh, element_cell, blocks: Vec::new(), interfaces });
|
||||
}
|
||||
Err(e @ Error::Limit(_)) => return Err(e),
|
||||
Err(e @ Error::Failed(_)) => last = Some(e),
|
||||
Err(e) => {
|
||||
last = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(e) = last {
|
||||
reasons.push(format!("{}: {}", method.name(), super::plain(&e)));
|
||||
}
|
||||
}
|
||||
Err(Error::Unsupported(format!("the cells do not sweep together ({})", reasons.join("; "))))
|
||||
}
|
||||
|
||||
/// The pairs of cells that share element faces in `mesh`, counted once each.
|
||||
fn shared_faces(mesh: &HexMesh, element_cell: &[u32]) -> usize {
|
||||
let mut owner: std::collections::HashMap<[u32; 4], u32> = std::collections::HashMap::new();
|
||||
let mut pairs = std::collections::BTreeSet::new();
|
||||
for e in 0..mesh.element_count() {
|
||||
let (Some(h), Some(&c)) = (mesh.element(e), element_cell.get(e)) else { continue };
|
||||
for k in 0..6 {
|
||||
let mut key = super::hex_face(k).map(|i| h.get(i).copied().unwrap_or(0));
|
||||
key.sort_unstable();
|
||||
match owner.insert(key, c) {
|
||||
Some(o) if o != c => {
|
||||
pairs.insert((o.min(c), o.max(c)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.len()
|
||||
}
|
||||
|
||||
/// Vertices, edges and faces shared by the cells.
|
||||
struct Topology {
|
||||
/// Shared vertices: where they are.
|
||||
@@ -327,12 +440,17 @@ impl Topology {
|
||||
ctxs.get(c).and_then(|x| x.seed_of(be)).map(|(seed, rev)| (seed, rev == fwd))
|
||||
})
|
||||
.collect();
|
||||
// A biased seed's spacing carries to the unseeded edges parallel to it, through every
|
||||
// cell they bound, oriented along each cell's direction (#257); the counts come from the
|
||||
// edges' own seeds alone.
|
||||
let carried = crate::seeds::carry(&seeds, |s: &&EdgeSeed| s.bias.is_some(), &self.parallel_ties());
|
||||
let first = self.solve(&goals, &seeds)?;
|
||||
// Ends of edges that leave a curved face: the spacing along that face there.
|
||||
let ends = self.graded_ends(ctxs, cubes, &first);
|
||||
let mut graded = goals;
|
||||
let mut divisions = Vec::with_capacity(polylines.len());
|
||||
for (((poly, ends), goal), seed) in polylines.into_iter().zip(ends).zip(graded.iter_mut()).zip(&seeds) {
|
||||
let spacing: Vec<Option<(&EdgeSeed, bool)>> = seeds.iter().zip(carried).map(|(own, carried)| own.or(carried)).collect();
|
||||
for (((poly, ends), goal), seed) in polylines.into_iter().zip(ends).zip(graded.iter_mut()).zip(&spacing) {
|
||||
// A seeded edge keeps the seed's spacing, not a grading.
|
||||
let ends = if seed.is_some() { [None, None] } else { ends };
|
||||
let poly = if ends.iter().any(Option::is_some) {
|
||||
@@ -350,6 +468,28 @@ impl Topology {
|
||||
Ok(divisions)
|
||||
}
|
||||
|
||||
/// The four edges along each direction of every cell, tied pairwise, each traversed from the
|
||||
/// cell's low corner to its high one.
|
||||
fn parallel_ties(&self) -> Vec<[crate::seeds::Use; 2]> {
|
||||
let mut ties = Vec::new();
|
||||
for (groups, ids) in self.cell_edges.iter().zip(&self.corners) {
|
||||
for (d, g) in groups.iter().enumerate() {
|
||||
let uses: Vec<crate::seeds::Use> = g
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(m, &e)| {
|
||||
let (lo, _) = edge_ends(ids, d, m);
|
||||
(e, self.edges.get(e).is_some_and(|x| x.a == lo))
|
||||
})
|
||||
.collect();
|
||||
if let Some((&first, rest)) = uses.split_first() {
|
||||
ties.extend(rest.iter().map(|&u| [first, u]));
|
||||
}
|
||||
}
|
||||
}
|
||||
ties
|
||||
}
|
||||
|
||||
fn solve(&self, goals: &[f64], seeds: &[Option<(&EdgeSeed, bool)>]) -> Result<Vec<usize>> {
|
||||
let mut ia = super::interval::Intervals::default();
|
||||
for (&g, seed) in goals.iter().zip(seeds) {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
//! change in relative error. This module uses that scheme with increments only: start from the
|
||||
//! rounded goals, then repeatedly take the first violated constraint and raise the variable whose
|
||||
//! increase costs least in squared relative error `((x − g)/g)²`, preferring, for parity, variables that
|
||||
//! no equality couples to other faces. Increments only make the counts never coarser than asked
|
||||
//! no equality couples to other faces (until a loop keeps coming back: free curves shared only by
|
||||
//! loops round a point cannot fix their parity together). Increments only make the counts never coarser than asked
|
||||
//! and make termination provable for feasible systems up to the step cap; an infeasible system
|
||||
//! (one that only grows) hits the cap and is reported, never looped on.
|
||||
|
||||
@@ -46,6 +47,10 @@ pub(crate) struct Intervals {
|
||||
pub fixed: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// Repairs of one parity constraint by curves no equality couples, before coupled ones may
|
||||
/// move as well.
|
||||
const PARITY_PATIENCE: usize = 4;
|
||||
|
||||
/// The largest count a variable may reach.
|
||||
const MAX_COUNT: usize = 1 << 20;
|
||||
|
||||
@@ -135,6 +140,8 @@ impl Intervals {
|
||||
let c = x.get(i).copied().unwrap_or(0);
|
||||
error(i, c + 1) - error(i, c)
|
||||
};
|
||||
// How often each parity constraint has been repaired.
|
||||
let mut repaired = vec![0usize; self.multiple.len()];
|
||||
let total: usize = x.iter().sum();
|
||||
let constraints = self.equal.len() + self.multiple.len() + self.at_least.len() + self.exceed.len();
|
||||
let max_steps = total.saturating_mul(8).saturating_add(64 * (n + constraints)).saturating_add(1000);
|
||||
@@ -198,7 +205,7 @@ impl Intervals {
|
||||
}
|
||||
}
|
||||
if raise.is_none() {
|
||||
for (l, m) in &self.multiple {
|
||||
for (k, (l, m)) in self.multiple.iter().enumerate() {
|
||||
if !sum(l, &x).is_multiple_of(*m) {
|
||||
// Only a variable listed a number of times that is not a multiple of the
|
||||
// modulus moves the remainder.
|
||||
@@ -211,7 +218,12 @@ impl Intervals {
|
||||
return Err(self.conflict(l, &format!("a face's loop needs a multiple of {m} divisions")));
|
||||
}
|
||||
raise = Some(moving);
|
||||
parity = true;
|
||||
// Loops whose free curves only feed each other (the triangles round a
|
||||
// point) cannot all be made even by them: once a loop keeps coming back,
|
||||
// its curves coupled to other faces may move too.
|
||||
let r = repaired.get_mut(k).ok_or_else(|| Error::Failed("parity index".into()))?;
|
||||
*r += 1;
|
||||
parity = *r <= PARITY_PATIENCE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -332,6 +344,25 @@ mod tests {
|
||||
assert!(x[b] + x[c] >= 8, "{x:?}");
|
||||
}
|
||||
|
||||
/// Four triangles round a point (a square cut along both diagonals): each loop must be even.
|
||||
/// The half-diagonals are free but every one of them is in two loops, so they cannot fix an odd
|
||||
/// perimeter; a side, coupled to the face beyond, has to move.
|
||||
#[test]
|
||||
fn parity_round_a_point_moves_a_coupled_side() {
|
||||
let mut p = Intervals::default();
|
||||
let sides: Vec<usize> = [7.0, 7.0, 7.0, 8.0].iter().map(|&g| p.var(g, 1)).collect();
|
||||
let halves: Vec<usize> = (0..4).map(|_| p.var(2.0, 1)).collect();
|
||||
let beyond: Vec<usize> = (0..4).map(|_| p.var(7.0, 1)).collect();
|
||||
for k in 0..4 {
|
||||
p.multiple.push((vec![sides[k], halves[k], halves[(k + 1) % 4]], 2));
|
||||
p.equal.push((vec![sides[k]], vec![beyond[k]]));
|
||||
}
|
||||
let x = p.solve().unwrap();
|
||||
for (l, m) in &p.multiple {
|
||||
assert_eq!(l.iter().map(|&i| x[i]).sum::<usize>() % m, 0, "{x:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infeasible_systems_are_errors_not_loops() {
|
||||
let mut p = Intervals::default();
|
||||
|
||||
+203
-102
@@ -37,7 +37,7 @@ use super::brep::{Brep, Face};
|
||||
use super::decompose;
|
||||
use super::geom::{Inside, Surface, add, dist, scale, unit};
|
||||
use super::interval::Intervals;
|
||||
use super::region::Scheme;
|
||||
use super::region::{self, Scheme};
|
||||
use super::section;
|
||||
use super::{Ctx, Raw};
|
||||
use crate::predicates::Sign;
|
||||
@@ -221,8 +221,8 @@ fn role(brep: &Brep, face: &Face, frame: &Frame, tol: f64) -> std::result::Resul
|
||||
}
|
||||
|
||||
/// Candidate extrusion directions: the normals of planar faces and the axes of cylinders, most
|
||||
/// common first.
|
||||
pub(crate) fn translations(brep: &Brep) -> Vec<Frame> {
|
||||
/// common first, over the faces of every body given.
|
||||
pub(crate) fn translations(breps: &[&Brep]) -> Vec<Frame> {
|
||||
let mut dirs: Vec<(P, f64)> = Vec::new();
|
||||
let mut push = |d: P, weight: f64| {
|
||||
let Some(mut d) = unit(d) else { return };
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn translations(brep: &Brep) -> Vec<Frame> {
|
||||
None => dirs.push((d, weight)),
|
||||
}
|
||||
};
|
||||
for f in &brep.faces {
|
||||
for f in breps.iter().flat_map(|b| &b.faces) {
|
||||
if let Some((_, n)) = f.plane {
|
||||
push(n, f.area);
|
||||
} else if let Some(Surface::Cylinder { axis, .. }) = f.surface {
|
||||
@@ -244,76 +244,56 @@ pub(crate) fn translations(brep: &Brep) -> Vec<Frame> {
|
||||
}
|
||||
}
|
||||
dirs.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let o = brep.point(0);
|
||||
let Some(o) = breps.first().map(|b| b.point(0)) else { return Vec::new() };
|
||||
dirs.into_iter().filter_map(|(d, _)| Frame::translate(o, d)).collect()
|
||||
}
|
||||
|
||||
/// Candidate axes of revolution: the axes of the analytic surfaces of revolution.
|
||||
pub(crate) fn rotations(brep: &Brep) -> Vec<Frame> {
|
||||
/// Candidate axes of revolution: the axes of the analytic surfaces of revolution of every body
|
||||
/// given.
|
||||
pub(crate) fn rotations(breps: &[&Brep]) -> Vec<Frame> {
|
||||
let mut axes: Vec<(P, P)> = Vec::new();
|
||||
for f in &brep.faces {
|
||||
let diagonal = breps.iter().map(|b| b.diagonal).fold(0.0, f64::max);
|
||||
for f in breps.iter().flat_map(|b| &b.faces) {
|
||||
let (o, a) = match f.surface {
|
||||
Some(Surface::Cylinder { origin, axis, .. })
|
||||
| Some(Surface::Cone { apex: origin, axis, .. })
|
||||
| Some(Surface::Torus { center: origin, axis, .. }) => (origin, axis),
|
||||
_ => continue,
|
||||
};
|
||||
if !axes.iter().any(|(p, b)| parallel(*b, a) && line_distance(o, *p, *b) <= RELATIVE_AXIS * brep.diagonal) {
|
||||
if !axes.iter().any(|(p, b)| parallel(*b, a) && line_distance(o, *p, *b) <= RELATIVE_AXIS * diagonal) {
|
||||
axes.push((o, a));
|
||||
}
|
||||
}
|
||||
axes.into_iter().filter_map(|(o, a)| Frame::rotate(o, a)).collect()
|
||||
}
|
||||
|
||||
/// The frame's classification of the body: face roles and levels.
|
||||
/// The frame's classification of the bodies swept together: face roles (per body) and levels.
|
||||
pub(crate) struct Setup {
|
||||
frame: Frame,
|
||||
roles: Vec<Role>,
|
||||
roles: Vec<Vec<Role>>,
|
||||
/// Sorted levels (heights or angles in `[0, 2π)`).
|
||||
levels: Vec<f64>,
|
||||
/// Sweep coordinates closer than this are one level.
|
||||
h_tol: f64,
|
||||
}
|
||||
|
||||
/// Classify faces and edges and find the levels; an `Unsupported` error says why the body does
|
||||
/// not sweep in this frame.
|
||||
pub(crate) fn setup(ctx: &Ctx, frame: &Frame) -> Result<Setup> {
|
||||
let brep = ctx.brep;
|
||||
let tol = ctx.tol;
|
||||
let mut roles = Vec::with_capacity(brep.faces.len());
|
||||
for f in &brep.faces {
|
||||
roles.push(role(brep, f, frame, tol).map_err(Error::Unsupported)?);
|
||||
}
|
||||
if frame.periodic() {
|
||||
// A revolved region touching the axis would collapse its elements there.
|
||||
if let Some(p) = brep.surface.points().iter().find(|&&p| frame.coords(p).0[1] <= tol) {
|
||||
return Err(Error::Unsupported(format!("the body reaches the axis of revolution (at {p:?}); revolved hexahedra would collapse there")));
|
||||
}
|
||||
}
|
||||
let r_max = brep.surface.points().iter().map(|&p| frame.coords(p).0[1].abs()).fold(0.0, f64::max);
|
||||
/// Classify faces and edges and find the levels of the bodies (one context each, sharing one
|
||||
/// tolerance) swept together; an `Unsupported` error says why a body does not sweep in this
|
||||
/// frame, naming it when there are several.
|
||||
pub(crate) fn setup(ctxs: &[Ctx], frame: &Frame) -> Result<Setup> {
|
||||
let tol = ctxs.first().map_or(0.0, |c| c.tol);
|
||||
let r_max = ctxs.iter().flat_map(|c| c.brep.surface.points()).map(|&p| frame.coords(p).0[1].abs()).fold(0.0, f64::max);
|
||||
let h_tol = match frame {
|
||||
Frame::Translate { .. } => tol,
|
||||
Frame::Rotate { .. } => tol / r_max.max(tol),
|
||||
};
|
||||
let mut heights = Vec::new();
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
|
||||
let Some(&(q0, h0)) = pts.first() else { continue };
|
||||
let across = pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol);
|
||||
let along = pts.iter().all(|&(q, _)| dist([q[0], q[1], 0.0], [q0[0], q0[1], 0.0]) <= tol);
|
||||
if across {
|
||||
heights.push(h0);
|
||||
} else if !along {
|
||||
let [f0, f1] = edge.faces.map(|f| brep.face_tag(f));
|
||||
return Err(Error::Unsupported(format!("edge {e} between faces {f0} and {f1} runs neither along nor across the sweep")));
|
||||
}
|
||||
}
|
||||
for (f, r) in brep.faces.iter().zip(&roles) {
|
||||
if *r == Role::Cap
|
||||
&& let Some(&v) = f.vertices.first()
|
||||
{
|
||||
heights.push(frame.coords(brep.point(v)).1);
|
||||
}
|
||||
let (mut roles, mut heights) = (Vec::with_capacity(ctxs.len()), Vec::new());
|
||||
for (c, ctx) in ctxs.iter().enumerate() {
|
||||
let named = |e: Error| match e {
|
||||
Error::Unsupported(m) if ctxs.len() > 1 => Error::Unsupported(format!("cell {c}: {m}")),
|
||||
e => e,
|
||||
};
|
||||
roles.push(classify(ctx, frame, tol, &mut heights).map_err(named)?);
|
||||
}
|
||||
heights.sort_by(f64::total_cmp);
|
||||
let mut levels: Vec<f64> = Vec::new();
|
||||
@@ -336,6 +316,42 @@ pub(crate) fn setup(ctx: &Ctx, frame: &Frame) -> Result<Setup> {
|
||||
Ok(Setup { frame: *frame, roles, levels, h_tol })
|
||||
}
|
||||
|
||||
/// One body's face roles in the frame; the heights of its edges across the sweep and of its caps
|
||||
/// are added to `heights`.
|
||||
fn classify(ctx: &Ctx, frame: &Frame, tol: f64, heights: &mut Vec<f64>) -> Result<Vec<Role>> {
|
||||
let brep = ctx.brep;
|
||||
let mut roles = Vec::with_capacity(brep.faces.len());
|
||||
for f in &brep.faces {
|
||||
roles.push(role(brep, f, frame, tol).map_err(Error::Unsupported)?);
|
||||
}
|
||||
if frame.periodic() {
|
||||
// A revolved region touching the axis would collapse its elements there.
|
||||
if let Some(p) = brep.surface.points().iter().find(|&&p| frame.coords(p).0[1] <= tol) {
|
||||
return Err(Error::Unsupported(format!("the body reaches the axis of revolution (at {p:?}); revolved hexahedra would collapse there")));
|
||||
}
|
||||
}
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
|
||||
let Some(&(q0, h0)) = pts.first() else { continue };
|
||||
let across = pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol);
|
||||
let along = pts.iter().all(|&(q, _)| dist([q[0], q[1], 0.0], [q0[0], q0[1], 0.0]) <= tol);
|
||||
if across {
|
||||
heights.push(h0);
|
||||
} else if !along {
|
||||
let [f0, f1] = edge.faces.map(|f| brep.face_tag(f));
|
||||
return Err(Error::Unsupported(format!("edge {e} between faces {f0} and {f1} runs neither along nor across the sweep")));
|
||||
}
|
||||
}
|
||||
for (f, r) in brep.faces.iter().zip(&roles) {
|
||||
if *r == Role::Cap
|
||||
&& let Some(&v) = f.vertices.first()
|
||||
{
|
||||
heights.push(frame.coords(brep.point(v)).1);
|
||||
}
|
||||
}
|
||||
Ok(roles)
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
/// The number of levels the frame sweeps through (for ordering candidates).
|
||||
pub(crate) fn level_count(&self) -> usize {
|
||||
@@ -356,50 +372,74 @@ impl SizeField for PlaneField<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cross-section of a frame the body sweeps in: the arrangement (cut into blocks),
|
||||
/// the level intervals and which of them each region fills. It does not depend on the element
|
||||
/// size, so refinement retries reuse it.
|
||||
/// The cross-section of a frame the bodies sweep in: the arrangement (cut into blocks),
|
||||
/// the level intervals and which body fills each region in each of them. It does not depend on
|
||||
/// the element size, so refinement retries reuse it.
|
||||
pub(crate) struct Prepared {
|
||||
setup: Setup,
|
||||
arr: Arrangement,
|
||||
intervals: Vec<(f64, f64)>,
|
||||
filled: Vec<Vec<bool>>,
|
||||
filled: Vec<Vec<Option<u32>>>,
|
||||
used: Vec<bool>,
|
||||
/// The first arrangement tag of each body's faces (curve tags number the faces of all bodies
|
||||
/// one after the other).
|
||||
offsets: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Lay out the cross-section of `setup`'s frame.
|
||||
pub(crate) fn prepare(ctx: &Ctx, setup: Setup) -> Result<Prepared> {
|
||||
let brep = ctx.brep;
|
||||
impl Prepared {
|
||||
/// The body and face of an arrangement tag.
|
||||
fn face(&self, ctxs: &[Ctx], tag: usize) -> Option<(usize, usize)> {
|
||||
let c = self.offsets.iter().rposition(|&o| o <= tag)?;
|
||||
let f = tag - self.offsets.get(c)?;
|
||||
ctxs.get(c).filter(|ctx| f < ctx.brep.faces.len()).map(|_| (c, f))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the cross-section of `setup`'s frame, for the bodies of `ctxs` (those `setup` was
|
||||
/// made for) swept together: the walls of every body are imprinted on each other, so where two
|
||||
/// bodies meet on a wall they get the same curves, and each region of each level interval is
|
||||
/// filled by the body it lies in.
|
||||
pub(crate) fn prepare(ctxs: &[Ctx], setup: Setup) -> Result<Prepared> {
|
||||
let ctx = ctxs.first().ok_or_else(|| Error::Failed("no body to sweep".into()))?;
|
||||
let tol = ctx.tol;
|
||||
let frame = &setup.frame;
|
||||
let (roles, levels) = (&setup.roles, &setup.levels);
|
||||
// Lower outlines of the walls, and every vertex.
|
||||
let levels = &setup.levels;
|
||||
// Lower outlines of the walls, and every vertex, of every body.
|
||||
let mut polylines: Vec<(Vec<Q>, usize)> = Vec::new();
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
|
||||
let Some(&(_, h0)) = pts.first() else { continue };
|
||||
if !pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol) {
|
||||
continue;
|
||||
}
|
||||
for &f in &edge.faces {
|
||||
if roles.get(f) != Some(&Role::Wall) {
|
||||
let mut vertices: Vec<Q> = Vec::new();
|
||||
let mut offsets = Vec::with_capacity(ctxs.len());
|
||||
let mut offset = 0;
|
||||
for (c, body) in ctxs.iter().enumerate() {
|
||||
let brep = body.brep;
|
||||
let roles = setup.roles.get(c).map(Vec::as_slice).unwrap_or(&[]);
|
||||
offsets.push(offset);
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let pts: Vec<(Q, f64)> = edge.points.iter().map(|&v| frame.coords(brep.point(v))).collect();
|
||||
let Some(&(_, h0)) = pts.first() else { continue };
|
||||
if !pts.iter().all(|&(q, h)| frame.span(q, frame.dh(h, h0)) <= tol) {
|
||||
continue;
|
||||
}
|
||||
// The wall's triangle on the edge's first segment: is its third vertex above?
|
||||
let Some(c) = brep.third_vertex(e, f) else { continue };
|
||||
let (qc, hc) = frame.coords(brep.point(c));
|
||||
if frame.dh(hc, h0) > 0.0 && frame.span(qc, frame.dh(hc, h0)) > tol {
|
||||
polylines.push((pts.iter().map(|x| x.0).collect(), f));
|
||||
for &f in &edge.faces {
|
||||
if roles.get(f) != Some(&Role::Wall) {
|
||||
continue;
|
||||
}
|
||||
// The wall's triangle on the edge's first segment: is its third vertex above?
|
||||
let Some(v) = brep.third_vertex(e, f) else { continue };
|
||||
let (qc, hc) = frame.coords(brep.point(v));
|
||||
if frame.dh(hc, h0) > 0.0 && frame.span(qc, frame.dh(hc, h0)) > tol {
|
||||
polylines.push((pts.iter().map(|x| x.0).collect(), offset + f));
|
||||
}
|
||||
}
|
||||
}
|
||||
vertices.extend(
|
||||
brep.edges
|
||||
.iter()
|
||||
.flat_map(|e| [e.first(), e.last()])
|
||||
.chain(brep.is_vertex.iter().enumerate().filter(|(_, v)| **v).map(|(i, _)| i as u32))
|
||||
.map(|v| frame.coords(brep.point(v)).0),
|
||||
);
|
||||
offset += brep.faces.len();
|
||||
}
|
||||
let vertices: Vec<Q> = brep
|
||||
.edges
|
||||
.iter()
|
||||
.flat_map(|e| [e.first(), e.last()])
|
||||
.chain(brep.is_vertex.iter().enumerate().filter(|(_, v)| **v).map(|(i, _)| i as u32))
|
||||
.map(|v| frame.coords(brep.point(v)).0)
|
||||
.collect();
|
||||
let coincide = 2.0 * ctx.opts.chordal_tolerance.max(0.0) + 4.0 * tol;
|
||||
// Level intervals; a revolution's last one wraps round to the first level.
|
||||
let (Some(&first_level), Some(rest)) = (levels.first(), levels.get(1..)) else {
|
||||
@@ -407,7 +447,7 @@ pub(crate) fn prepare(ctx: &Ctx, setup: Setup) -> Result<Prepared> {
|
||||
};
|
||||
let ends: Vec<f64> = if frame.periodic() { rest.iter().copied().chain(std::iter::once(first_level + TAU)).collect() } else { rest.to_vec() };
|
||||
let intervals: Vec<(f64, f64)> = levels.iter().copied().zip(ends).collect();
|
||||
let mut inside = Inside::new(brep.surface);
|
||||
let mut inside = Occupants::new(ctxs, tol);
|
||||
let mut arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
|
||||
let (mut filled, mut used) = occupancy(&arr, frame, &intervals, &mut inside)?;
|
||||
// Cut the filled regions into blocks the structured schemes fit, and lay the arrangement out
|
||||
@@ -415,20 +455,21 @@ pub(crate) fn prepare(ctx: &Ctx, setup: Setup) -> Result<Prepared> {
|
||||
let h_ref = levels.first().copied().unwrap_or(0.0);
|
||||
let plane = PlaneField { field: ctx.field, frame: *frame, h: h_ref };
|
||||
let settings = decompose::Settings { field: &plane, tol, opts: &ctx.opts.quad };
|
||||
let locked = seeded_curves(ctx, frame, h_ref, &intervals, setup.h_tol, &arr);
|
||||
let locked = seeded_curves(ctxs, frame, h_ref, &intervals, setup.h_tol, &arr);
|
||||
let cut_lines = decompose::cuts(&arr, &used, &locked, &settings)?;
|
||||
if !cut_lines.is_empty() {
|
||||
polylines.extend(cut_lines.into_iter().map(|c| (c, decompose::CUT)));
|
||||
arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
|
||||
(filled, used) = occupancy(&arr, frame, &intervals, &mut inside)?;
|
||||
}
|
||||
Ok(Prepared { setup, arr, intervals, filled, used })
|
||||
Ok(Prepared { setup, arr, intervals, filled, used, offsets })
|
||||
}
|
||||
|
||||
/// Mesh the prepared cross-section, with its curves `2^refine` times finer than the size asks.
|
||||
pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
let brep = ctx.brep;
|
||||
let Prepared { setup, arr, intervals, filled, used } = prepared;
|
||||
/// Mesh the prepared cross-section, with its curves `2^refine` times finer than the size asks;
|
||||
/// with the body (index into `ctxs`) of each hexahedron.
|
||||
pub(crate) fn mesh(ctxs: &[Ctx], prepared: &Prepared, refine: u32) -> Result<(Raw, Vec<u32>)> {
|
||||
let ctx = ctxs.first().ok_or_else(|| Error::Failed("no body to sweep".into()))?;
|
||||
let Prepared { setup, arr, intervals, filled, used, .. } = prepared;
|
||||
let (frame, levels) = (&setup.frame, &setup.levels);
|
||||
let h_ref = levels.first().copied().unwrap_or(0.0);
|
||||
// Interval assignment over the curves of filled regions, and the layer counts.
|
||||
@@ -446,13 +487,24 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
ia.var(goal * fine, 1);
|
||||
}
|
||||
let schemes = section::schemes(arr, used, &mut ia);
|
||||
let seeds = Seeds::of(ctx, frame, h_ref, intervals, setup.h_tol, &(0..arr.curves.len()).map(curve3d).collect::<Vec<_>>())?;
|
||||
let seeds = Seeds::of(ctxs, frame, h_ref, intervals, setup.h_tol, &(0..arr.curves.len()).map(curve3d).collect::<Vec<_>>())?;
|
||||
for (c, seed) in seeds.curves.iter().enumerate() {
|
||||
if let Some((seed, _)) = seed {
|
||||
ia.seed(c, seed);
|
||||
}
|
||||
}
|
||||
let counts = ia.solve()?;
|
||||
// A biased seed's spacing carries across the cross-section's mapped regions to the opposite
|
||||
// curves without seeds of their own (#257).
|
||||
let ties: Vec<[crate::seeds::Use; 2]> = arr
|
||||
.regions
|
||||
.iter()
|
||||
.zip(&schemes)
|
||||
.filter_map(|(region, &scheme)| region.loops.first().map(|lp| region::opposite_ties(lp, scheme)))
|
||||
.flatten()
|
||||
.collect();
|
||||
let carried = crate::seeds::carry(&seeds.curves, |s: &&EdgeSeed| s.bias.is_some(), &ties);
|
||||
let spacing: Vec<Option<(&EdgeSeed, bool)>> = seeds.curves.iter().zip(carried).map(|(own, carried)| own.or(carried)).collect();
|
||||
let r_max = arr.points.iter().map(|q| q[1].abs()).fold(0.0, f64::max);
|
||||
let mut layers = Vec::with_capacity(intervals.len());
|
||||
for &(lo, hi) in intervals.iter() {
|
||||
@@ -475,14 +527,19 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
}
|
||||
// The cross-section's nodes (curve interiors projected onto analytic walls) and quadrilaterals.
|
||||
let place = |c: usize, n: usize| -> Result<Vec<Q>> {
|
||||
let pts3 = match seeds.curves.get(c).copied().flatten() {
|
||||
let pts3 = match spacing.get(c).copied().flatten() {
|
||||
Some((seed, rev)) => crate::seeds::place(&curve3d(c), &seed.fractions(n, rev))?,
|
||||
None => divide_polyline(ctx.field, &curve3d(c), n)?,
|
||||
};
|
||||
let wall = arr
|
||||
.curves
|
||||
.get(c)
|
||||
.and_then(|curve| curve.tags.iter().find_map(|&f| brep.faces.get(f).and_then(|x| x.surface)))
|
||||
.and_then(|curve| {
|
||||
curve.tags.iter().find_map(|&t| {
|
||||
let (c, f) = prepared.face(ctxs, t)?;
|
||||
ctxs.get(c)?.brep.faces.get(f)?.surface
|
||||
})
|
||||
})
|
||||
.filter(|s| !matches!(s, Surface::Plane { .. }));
|
||||
Ok(pts3.iter().map(|p| frame.coords(wall.and_then(|s| s.project(*p)).unwrap_or(*p)).0).collect())
|
||||
};
|
||||
@@ -512,7 +569,7 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
let mut count = 0usize;
|
||||
for (r, quads) in region_quads.iter().enumerate() {
|
||||
for (k, f) in filled.get(r).map(Vec::as_slice).unwrap_or(&[]).iter().enumerate() {
|
||||
if *f {
|
||||
if f.is_some() {
|
||||
count = count.saturating_add(quads.len().saturating_mul(layers.get(k).copied().unwrap_or(0)));
|
||||
}
|
||||
}
|
||||
@@ -532,25 +589,26 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
})
|
||||
};
|
||||
let mut hexes: Vec<[u32; 8]> = Vec::with_capacity(count);
|
||||
let mut cells: Vec<u32> = Vec::with_capacity(count);
|
||||
for (r, quads) in region_quads.iter().enumerate() {
|
||||
for (k, f) in filled.get(r).map(Vec::as_slice).unwrap_or(&[]).iter().enumerate() {
|
||||
if !*f {
|
||||
continue;
|
||||
}
|
||||
let Some(cell) = *f else { continue };
|
||||
let (s, n) = (first_layer.get(k).copied().unwrap_or(0), layers.get(k).copied().unwrap_or(0));
|
||||
for q in quads {
|
||||
for j in s..s + n {
|
||||
let b = q.map(|v| node(v, j, &mut nodes));
|
||||
let t = q.map(|v| node(v, j + 1, &mut nodes));
|
||||
hexes.push([b[0], b[1], b[2], b[3], t[0], t[1], t[2], t[3]]);
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let filled_regions = used.iter().filter(|&&u| u).count();
|
||||
let structured = schemes.iter().zip(used.iter()).filter(|(s, u)| **u && **s != Scheme::Free).count();
|
||||
let bodies = if ctxs.len() > 1 { format!(" ({} cells together)", ctxs.len()) } else { String::new() };
|
||||
let description = format!(
|
||||
"{}: {} level{}, {} region{} ({} structured, {} free), {} layer{}",
|
||||
"{}{bodies}: {} level{}, {} region{} ({} structured, {} free), {} layer{}",
|
||||
frame.describe(),
|
||||
levels.len(),
|
||||
if levels.len() == 1 { "" } else { "s" },
|
||||
@@ -561,7 +619,7 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
|
||||
total_layers.saturating_sub(usize::from(!frame.periodic())),
|
||||
if total_layers == 2 { "" } else { "s" },
|
||||
);
|
||||
Ok(Raw { nodes, hexes, description })
|
||||
Ok((Raw { nodes, hexes, description }, cells))
|
||||
}
|
||||
|
||||
/// The edge seeds as a layered mesh takes them: on the cross-section's curves (seeds of edges
|
||||
@@ -622,10 +680,19 @@ fn split_over(nodes: &[f64], bounds: &[f64]) -> Option<Vec<Vec<f64>>> {
|
||||
}
|
||||
|
||||
impl<'s> Seeds<'s> {
|
||||
fn of(ctx: &'s Ctx, frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, curves: &[Vec<P>]) -> Result<Seeds<'s>> {
|
||||
fn of(ctxs: &'s [Ctx], frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, curves: &[Vec<P>]) -> Result<Seeds<'s>> {
|
||||
let mut out = Seeds { curves: vec![None; curves.len()], layers: vec![None; intervals.len()] };
|
||||
for ctx in ctxs {
|
||||
out.add(ctx, frame, h_ref, intervals, h_tol, curves)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Add the seeds on the edges of one body.
|
||||
fn add(&mut self, ctx: &'s Ctx, frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, curves: &[Vec<P>]) -> Result<()> {
|
||||
let brep = ctx.brep;
|
||||
let tol = ctx.tol;
|
||||
let mut out = Seeds { curves: vec![None; curves.len()], layers: vec![None; intervals.len()] };
|
||||
let out = self;
|
||||
let stol = super::seed_tolerance(ctx.opts, tol);
|
||||
let conflict = |a: &EdgeSeed, b: &EdgeSeed, why: &str| {
|
||||
Error::Invalid(format!(
|
||||
@@ -699,26 +766,60 @@ impl<'s> Seeds<'s> {
|
||||
*slot = Some(LayerSeed { seed, fractions });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which level intervals each region of the arrangement fills (inside test at the region's
|
||||
/// centre, mid-interval), and which regions fill any.
|
||||
fn occupancy(arr: &Arrangement, frame: &Frame, intervals: &[(f64, f64)], inside: &mut Inside) -> Result<(Vec<Vec<bool>>, Vec<bool>)> {
|
||||
/// The bodies swept together, for inside tests: each body's test and bounding box.
|
||||
struct Occupants<'a> {
|
||||
bodies: Vec<(Inside<'a>, P, P)>,
|
||||
}
|
||||
|
||||
impl<'a> Occupants<'a> {
|
||||
fn new(ctxs: &[Ctx<'a>], tol: f64) -> Self {
|
||||
let bodies = ctxs
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in c.brep.surface.points() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k] - tol);
|
||||
hi[k] = hi[k].max(p[k] + tol);
|
||||
}
|
||||
}
|
||||
(Inside::new(c.brep.surface), lo, hi)
|
||||
})
|
||||
.collect();
|
||||
Occupants { bodies }
|
||||
}
|
||||
|
||||
/// The first body `x` lies in.
|
||||
fn body_at(&mut self, x: P) -> Result<Option<u32>> {
|
||||
for (i, (inside, lo, hi)) in self.bodies.iter_mut().enumerate() {
|
||||
if (0..3).all(|k| x[k] >= lo[k] && x[k] <= hi[k]) && inside.contains(x)? {
|
||||
return Ok(u32::try_from(i).ok());
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which body fills each region of the arrangement in each level interval (inside tests at the
|
||||
/// region's centre, mid-interval), and which regions any body fills.
|
||||
fn occupancy(arr: &Arrangement, frame: &Frame, intervals: &[(f64, f64)], inside: &mut Occupants) -> Result<(Vec<Vec<Option<u32>>>, Vec<bool>)> {
|
||||
if arr.regions.is_empty() {
|
||||
return Err(Error::Unsupported("the projected outline encloses no region".into()));
|
||||
}
|
||||
let mut filled: Vec<Vec<bool>> = Vec::with_capacity(arr.regions.len());
|
||||
let mut filled: Vec<Vec<Option<u32>>> = Vec::with_capacity(arr.regions.len());
|
||||
for region in &arr.regions {
|
||||
let c = interior_point(arr, region)?;
|
||||
let mut row = Vec::with_capacity(intervals.len());
|
||||
for &(lo, hi) in intervals {
|
||||
row.push(inside.contains(frame.point(c, 0.5 * (lo + hi)))?);
|
||||
row.push(inside.body_at(frame.point(c, 0.5 * (lo + hi)))?);
|
||||
}
|
||||
filled.push(row);
|
||||
}
|
||||
let used: Vec<bool> = filled.iter().map(|r| r.iter().any(|&x| x)).collect();
|
||||
let used: Vec<bool> = filled.iter().map(|r| r.iter().any(Option::is_some)).collect();
|
||||
if !used.iter().any(|&u| u) {
|
||||
return Err(Error::Unsupported("no region of the cross-section lies inside the body".into()));
|
||||
}
|
||||
@@ -726,10 +827,10 @@ fn occupancy(arr: &Arrangement, frame: &Frame, intervals: &[(f64, f64)], inside:
|
||||
}
|
||||
|
||||
/// Which curves of `arr` carry an edge seed (a cut may not end inside them).
|
||||
fn seeded_curves(ctx: &Ctx, frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, arr: &Arrangement) -> Vec<bool> {
|
||||
fn seeded_curves(ctxs: &[Ctx], frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, arr: &Arrangement) -> Vec<bool> {
|
||||
let curves: Vec<Vec<P>> =
|
||||
arr.curves.iter().map(|cv| cv.points.iter().map(|&p| frame.point(arr.points.get(p).copied().unwrap_or([0.0; 2]), h_ref)).collect()).collect();
|
||||
match Seeds::of(ctx, frame, h_ref, intervals, h_tol, &curves) {
|
||||
match Seeds::of(ctxs, frame, h_ref, intervals, h_tol, &curves) {
|
||||
Ok(seeds) => seeds.curves.iter().map(Option::is_some).collect(),
|
||||
Err(_) => vec![false; arr.curves.len()],
|
||||
}
|
||||
|
||||
@@ -171,9 +171,13 @@ pub(crate) fn mesh(ctx: &Ctx) -> Result<Raw> {
|
||||
if ni * nj * nk > ctx.opts.max_elements {
|
||||
return Err(Error::Limit(format!("the mesh would have {} hexahedra; the limit is {}", ni * nj * nk, ctx.opts.max_elements)));
|
||||
}
|
||||
// A biased seed's spacing carries to the unseeded edges parallel to it: the four edges of a
|
||||
// direction, all low corner to high.
|
||||
let ties: Vec<[(usize, bool); 2]> = groups.iter().flat_map(|g| g.iter().skip(1).map(move |&u| [g[0], u])).collect();
|
||||
let carried = ctx.carried(&ties);
|
||||
// Edge node positions, low corner to high.
|
||||
let edge_pts = |(e, fwd): (usize, bool), n: usize| -> Result<Vec<P>> {
|
||||
let pts = ctx.divide_edge(e, fwd, n)?;
|
||||
let pts = ctx.divide_edge(e, fwd, n, &carried)?;
|
||||
let faces = brep.edges.get(e).map(|x| x.faces.to_vec()).unwrap_or_default();
|
||||
let last = pts.len().saturating_sub(1);
|
||||
Ok(pts.iter().enumerate().map(|(k, q)| if k == 0 || k == last { *q } else { project_to_faces(ctx, &faces, *q) }).collect())
|
||||
|
||||
+39
-13
@@ -22,7 +22,9 @@
|
||||
//! **Partitioned parts.** [`hex_mesh_cells`] ([`cells`]) meshes a part split into cells that
|
||||
//! meet face to face (SolidCraft split bodies) as one conforming mesh: every cell a mapped block,
|
||||
//! interval assignment over all cells together, each shared face meshed once and its nodes
|
||||
//! merged by identity.
|
||||
//! merged by identity. [`hex_mesh_swept_cells`] sweeps such cells together instead: one
|
||||
//! cross-section imprinted with every cell's walls, one interval assignment and one set of
|
||||
//! layers, so cells that share a sweep conform across their cut faces.
|
||||
//!
|
||||
//! **Common steps.** Interval assignment ([`interval`]) picks the division counts of shared curves
|
||||
//! together. Boundary nodes on analytic faces are placed on the exact surface; quadratic hexahedra
|
||||
@@ -51,7 +53,7 @@ mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use cells::{Cell, CellsMesh, GROWTH, MAX_CELLS, hex_mesh_cells};
|
||||
pub use cells::{Cell, CellsMesh, GROWTH, MAX_CELLS, hex_mesh_cells, hex_mesh_swept_cells};
|
||||
pub use geom::Surface;
|
||||
|
||||
use crate::seeds::EdgeSeed;
|
||||
@@ -197,7 +199,17 @@ pub(crate) struct Ctx<'a> {
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
pub(crate) fn new(brep: &'a Brep<'a>, tree: &'a TriangleTree<'a>, field: &'a dyn SizeField, opts: &'a HexOptions) -> Result<Ctx<'a>> {
|
||||
let tol = RELATIVE_TOLERANCE * brep.diagonal;
|
||||
Ctx::with_tolerance(brep, tree, field, opts, RELATIVE_TOLERANCE * brep.diagonal)
|
||||
}
|
||||
|
||||
/// A context with the classification tolerance `tol` (bodies meshed together share one).
|
||||
pub(crate) fn with_tolerance(
|
||||
brep: &'a Brep<'a>,
|
||||
tree: &'a TriangleTree<'a>,
|
||||
field: &'a dyn SizeField,
|
||||
opts: &'a HexOptions,
|
||||
tol: f64,
|
||||
) -> Result<Ctx<'a>> {
|
||||
let curves: Vec<Vec<P>> = (0..brep.edges.len()).map(|e| brep.edge_points(e, true)).collect();
|
||||
let edge_seeds = crate::seeds::match_curves(&opts.seeds, &curves, seed_tolerance(opts, tol))?;
|
||||
Ok(Ctx { brep, tree, field, opts, tol, edge_seeds })
|
||||
@@ -219,14 +231,24 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// `n + 1` points along B-rep edge `e` (traversed forwards or not): at the seed's spacing
|
||||
/// when it has one, else evenly in the size field.
|
||||
pub(crate) fn divide_edge(&self, e: usize, forward: bool, n: usize) -> Result<Vec<P>> {
|
||||
/// when it has one, or the spacing `carried` to it from a biased seed on an edge it is tied
|
||||
/// to ([`Ctx::carried`]), else evenly in the size field.
|
||||
pub(crate) fn divide_edge(&self, e: usize, forward: bool, n: usize, carried: &[Option<(usize, bool)>]) -> Result<Vec<P>> {
|
||||
let pts = self.brep.edge_points(e, forward);
|
||||
match self.seed_of(e) {
|
||||
let carried = carried.get(e).copied().flatten().and_then(|(i, rev)| Some((self.opts.seeds.get(i)?, rev)));
|
||||
match self.seed_of(e).or(carried) {
|
||||
Some((seed, rev)) => crate::seeds::place(&pts, &seed.fractions(n, rev == forward)),
|
||||
None => crate::size::divide_polyline(self.field, &pts, n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The seeds carried along `ties` (uses of B-rep edges divided alike) from biased seeds to
|
||||
/// unseeded edges ([`crate::seeds::carry`]), by index into the seeds.
|
||||
pub(crate) fn carried(&self, ties: &[[crate::seeds::Use; 2]]) -> Vec<Option<(usize, bool)>> {
|
||||
let mut own = self.edge_seeds.clone();
|
||||
own.resize(self.brep.edges.len(), None);
|
||||
crate::seeds::carry(&own, |&i| self.opts.seeds.get(i).is_some_and(|s| s.bias.is_some()), ties)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart a seed's polyline and a mesher's curve of the same edge may lie: both are
|
||||
@@ -246,7 +268,7 @@ pub(crate) struct Raw {
|
||||
const MAX_REFINE: u32 = 2;
|
||||
|
||||
/// Classification tolerance relative to the bounding-box diagonal.
|
||||
const RELATIVE_TOLERANCE: f64 = 1e-6;
|
||||
pub(crate) const RELATIVE_TOLERANCE: f64 = 1e-6;
|
||||
|
||||
/// Mesh the solid bounded by `surface` with hexahedra. `faces[tag]` is the analytic surface of
|
||||
/// B-rep face `tag` when known (exact node placement; free-form faces use the triangles);
|
||||
@@ -276,10 +298,11 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
|
||||
};
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
for &method in methods {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let mut last = None;
|
||||
match method {
|
||||
HexMethod::Extrude | HexMethod::Revolve => {
|
||||
let setups = match candidate_setups(&ctx, method) {
|
||||
let setups = match candidate_setups(std::slice::from_ref(&ctx), method) {
|
||||
Ok(s) => s,
|
||||
Err(why) => {
|
||||
reasons.push(format!("{}: {why}", method.name()));
|
||||
@@ -287,8 +310,9 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
|
||||
}
|
||||
};
|
||||
for setup in setups {
|
||||
fieldcraft_model::progress::check()?;
|
||||
// The cross-section is laid out once per frame; refinement retries reuse it.
|
||||
let prepared = match layered::prepare(&ctx, setup) {
|
||||
let prepared = match layered::prepare(std::slice::from_ref(&ctx), setup) {
|
||||
Ok(p) => p,
|
||||
Err(e @ Error::Limit(_)) => return Err(e),
|
||||
Err(e) => {
|
||||
@@ -296,7 +320,7 @@ pub fn hex_mesh(surface: &BoundarySurface, faces: &[Option<Surface>], field: &dy
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match with_refinement(&ctx, method, |refine| layered::mesh(&ctx, &prepared, refine)) {
|
||||
match with_refinement(&ctx, method, |refine| layered::mesh(std::slice::from_ref(&ctx), &prepared, refine).map(|m| m.0)) {
|
||||
Ok(mesh) => return Ok(mesh),
|
||||
Err(e @ Error::Limit(_)) => return Err(e),
|
||||
Err(e) => last = Some(e),
|
||||
@@ -339,12 +363,13 @@ fn check_estimate(surfaces: &[&BoundarySurface], field: &dyn SizeField, opts: &H
|
||||
}
|
||||
|
||||
/// The frames a layered method can try, fewest levels first, or the first reason none fits.
|
||||
fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<layered::Setup>, String> {
|
||||
let frames = if method == HexMethod::Extrude { layered::translations(ctx.brep) } else { layered::rotations(ctx.brep) };
|
||||
pub(crate) fn candidate_setups(ctxs: &[Ctx], method: HexMethod) -> std::result::Result<Vec<layered::Setup>, String> {
|
||||
let breps: Vec<&Brep> = ctxs.iter().map(|c| c.brep).collect();
|
||||
let frames = if method == HexMethod::Extrude { layered::translations(&breps) } else { layered::rotations(&breps) };
|
||||
let mut fitting: Vec<(usize, usize, layered::Setup)> = Vec::new();
|
||||
let mut first_reason = None;
|
||||
for (k, f) in frames.into_iter().enumerate() {
|
||||
match layered::setup(ctx, &f) {
|
||||
match layered::setup(ctxs, &f) {
|
||||
Ok(s) => fitting.push((s.level_count(), k, s)),
|
||||
Err(e) => {
|
||||
first_reason.get_or_insert_with(|| plain(&e));
|
||||
@@ -364,6 +389,7 @@ fn candidate_setups(ctx: &Ctx, method: HexMethod) -> std::result::Result<Vec<lay
|
||||
fn with_refinement(ctx: &Ctx, method: HexMethod, run: impl Fn(u32) -> Result<Raw>) -> Result<HexMesh> {
|
||||
let mut last = Error::Failed("no attempt".into());
|
||||
for refine in 0..=MAX_REFINE {
|
||||
fieldcraft_model::progress::check()?;
|
||||
match run(refine).and_then(|raw| finish(&Cells::one(ctx), raw, method)) {
|
||||
Ok(mesh) => return Ok(mesh),
|
||||
Err(e @ Error::Failed(_)) => last = e,
|
||||
|
||||
@@ -109,13 +109,28 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
||||
|
||||
/// The curve variables of each side of a loop between its corners (positions in increasing
|
||||
/// order); the last side wraps round to the first corner.
|
||||
fn loop_sides<const N: usize>(lp: &[usize], c: [usize; N]) -> [Vec<usize>; N] {
|
||||
fn loop_sides<T: Copy, const N: usize>(lp: &[T], c: [usize; N]) -> [Vec<T>; N] {
|
||||
std::array::from_fn(|k| {
|
||||
let (a, b) = (c[k], c.get(k + 1).copied().unwrap_or(c[0] + lp.len()));
|
||||
(a..b).filter_map(|i| lp.get(i % lp.len()).copied()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The opposite sides of a mapped loop that are one curve each, as uses traversed the same way
|
||||
/// (the loop runs along one side and back along the other), for carrying a biased seed's
|
||||
/// spacing across ([`crate::seeds::carry`]).
|
||||
pub(crate) fn opposite_ties(lp: &[(usize, bool)], scheme: Scheme) -> Vec<[crate::seeds::Use; 2]> {
|
||||
let Scheme::Mapped(c) = scheme else { return Vec::new() };
|
||||
let [s0, s1, s2, s3] = loop_sides(lp, c);
|
||||
[(s0, s2), (s1, s3)]
|
||||
.into_iter()
|
||||
.filter_map(|(a, b)| match (a.as_slice(), b.as_slice()) {
|
||||
([a], [(b, fwd)]) => Some([*a, (*b, !fwd)]),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add the scheme's constraints on the loops' curve variables.
|
||||
pub(crate) fn constrain(ia: &mut Intervals, loops: &[Vec<usize>], scheme: Scheme) {
|
||||
match scheme {
|
||||
|
||||
@@ -251,13 +251,34 @@ fn build(ctx: &Ctx, topo: &Topology, refine: u32) -> Result<Raw> {
|
||||
let loop_vars: Vec<Vec<usize>> = source.loops.iter().map(|lp| vars(lp)).collect();
|
||||
region::constrain(&mut ia, &loop_vars, scheme);
|
||||
let counts = ia.solve()?;
|
||||
// A biased seed along the sweep carries its layers to every side edge of its stage: a
|
||||
// linking face's right side runs up as its left side runs down, and the first side of each
|
||||
// stage runs up on every ring.
|
||||
let mut ties: Vec<[(usize, bool); 2]> = Vec::new();
|
||||
for stages in &topo.rings {
|
||||
for ring in stages {
|
||||
for l in ring {
|
||||
ties.push([l.right, (l.left.0, !l.left.1)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(first) = topo.rings.first() {
|
||||
for other in topo.rings.iter().skip(1).filter(|o| o.len() == first.len()) {
|
||||
for (a, b) in first.iter().zip(other) {
|
||||
if let (Some(a), Some(b)) = (a.first(), b.first()) {
|
||||
ties.push([a.right, b.right]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let carried = ctx.carried(&ties);
|
||||
// Edge nodes: B-rep vertices once, interiors on both faces of the edge.
|
||||
let mut nodes = Nodes { xyz: Vec::new() };
|
||||
let mut vertex_node: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
let mut edge_nodes: Vec<Vec<u32>> = Vec::with_capacity(brep.edges.len());
|
||||
for (e, edge) in brep.edges.iter().enumerate() {
|
||||
let n = counts.get(e).copied().unwrap_or(1);
|
||||
let pts = ctx.divide_edge(e, true, n)?;
|
||||
let pts = ctx.divide_edge(e, true, n, &carried)?;
|
||||
let last = pts.len().saturating_sub(1);
|
||||
let mut ids = Vec::with_capacity(pts.len());
|
||||
for (k, p) in pts.iter().enumerate() {
|
||||
|
||||
@@ -618,3 +618,43 @@ fn a_seed_on_a_shared_edge_holds_in_every_cell() {
|
||||
// Small at both ends, symmetric.
|
||||
assert!((z[1] - z[0] - (z[6] - z[5])).abs() < 1e-9 && z[3] - z[2] > z[1] - z[0], "{z:?}");
|
||||
}
|
||||
|
||||
/// A biased seed on one edge carries its spacing to the edges interval assignment ties to it
|
||||
/// (#257): the parallel edges of a mapped block, the opposite curves of an extruded
|
||||
/// cross-section's mapped region, the side edges of a sweep's linking faces, and the parallel
|
||||
/// edges of every cell they reach. The grid stays a grid: every node lies on one of the seed's
|
||||
/// planes.
|
||||
#[test]
|
||||
fn a_biased_seed_grades_the_edges_tied_to_it() {
|
||||
use fieldcraft_model::{Bias, BiasToward, SeedConstraint};
|
||||
let s = prism(&[[0.0, 0.0], [4.0, 0.0], [4.0, 2.0], [0.0, 2.0]], 0.0, 3.0);
|
||||
let bias = Some(Bias { ratio: 3.0, toward: BiasToward::Start });
|
||||
let all = |_: [f64; 3]| true;
|
||||
// Along x on the front bottom edge, small at x = 4 (the seed runs from 4 to 0).
|
||||
let x_seed = seed("x", vec![[4.0, 0.0, 0.0], [0.0, 0.0, 0.0]], 6, bias, SeedConstraint::Exact);
|
||||
for method in [HexMethod::Mapped, HexMethod::Extrude] {
|
||||
let m = hex_mesh(&s, &[], &UniformSize(1.0), &HexOptions { method, seeds: vec![x_seed.clone()], ..HexOptions::default() }).unwrap();
|
||||
let x = along(&m, 0, all);
|
||||
assert_eq!(x.len(), 7, "{method:?} {x:?}");
|
||||
assert!(x[6] - x[5] < x[1] - x[0], "{method:?} {x:?}");
|
||||
}
|
||||
// Through the thickness at x = y = 0, small at z = 0: the block mapped, and an L swept
|
||||
// (up z, the one way it sweeps).
|
||||
let l = prism(&[[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 6.0], [0.0, 6.0]], 0.0, 3.0);
|
||||
let z_seed = seed("z", vec![[0.0, 0.0, 0.0], [0.0, 0.0, 3.0]], 6, bias, SeedConstraint::Exact);
|
||||
for (body, method) in [(&s, HexMethod::Mapped), (&l, HexMethod::Sweep)] {
|
||||
let m = hex_mesh(body, &[], &UniformSize(1.0), &HexOptions { method, seeds: vec![z_seed.clone()], ..HexOptions::default() }).unwrap();
|
||||
let z = along(&m, 2, all);
|
||||
assert_eq!(z.len(), 7, "{method:?} {z:?}");
|
||||
assert!(z[1] - z[0] < z[6] - z[5], "{method:?} {z:?}");
|
||||
}
|
||||
// Two cells: a seed on the far side of the first carries through their interface to the
|
||||
// far side of the second.
|
||||
let pairs = boxes(&[([0.0, 2.0], [0.0, 1.0]), ([2.0, 3.0], [0.0, 1.0])], 1.0);
|
||||
let none = vec![Vec::new(); 2];
|
||||
let opts = HexOptions { seeds: vec![seed("z", vec![[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]], 6, bias, SeedConstraint::Exact)], ..HexOptions::default() };
|
||||
let m = hex_mesh_cells(&cells_of(&pairs, &none), &UniformSize(0.5), &opts).unwrap();
|
||||
let z = along(&m.mesh, 2, all);
|
||||
assert_eq!(z.len(), 7, "{z:?}");
|
||||
assert!(z[1] - z[0] < z[6] - z[5], "{z:?}");
|
||||
}
|
||||
+12
-2
@@ -100,13 +100,23 @@ pub const MAX_MERGE_NODES: usize = 50_000_000;
|
||||
/// becomes a survivor (the nearest wins, the lowest label on a tie). Deterministic: the labels
|
||||
/// decide, never the order of a hash map.
|
||||
pub fn coincident_nodes(model: &Model, candidates: Option<&BTreeSet<u32>>, tol: f64) -> Result<BTreeMap<u32, u32>> {
|
||||
coincident_nodes_from(model, candidates, tol, false)
|
||||
}
|
||||
|
||||
/// [`coincident_nodes`], taking the nodes in descending label order when `highest` (the highest
|
||||
/// label of a group survives, and wins a tie).
|
||||
pub fn coincident_nodes_from(model: &Model, candidates: Option<&BTreeSet<u32>>, tol: f64, highest: bool) -> Result<BTreeMap<u32, u32>> {
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return Err(Error::Invalid(format!("the merge distance must be a positive number, not {tol}")));
|
||||
}
|
||||
let nodes: Vec<(u32, P)> = match candidates {
|
||||
let mut nodes: Vec<(u32, P)> = match candidates {
|
||||
Some(c) => c.iter().filter_map(|&l| model.node(l).map(|n| (l, n.xyz))).collect(),
|
||||
None => model.nodes().iter().map(|(&l, n)| (l, n.xyz)).collect(),
|
||||
};
|
||||
if highest {
|
||||
nodes.reverse();
|
||||
}
|
||||
let first = |a: u32, b: u32| if highest { b.cmp(&a) } else { a.cmp(&b) };
|
||||
if nodes.len() > MAX_MERGE_NODES {
|
||||
return Err(Error::Invalid(format!("{} nodes to merge; at most {MAX_MERGE_NODES}", nodes.len())));
|
||||
}
|
||||
@@ -118,7 +128,7 @@ pub fn coincident_nodes(model: &Model, candidates: Option<&BTreeSet<u32>>, tol:
|
||||
.near(p)
|
||||
.map(|(l, q)| (dot(sub(q, p), sub(q, p)), l))
|
||||
.filter(|(d2, _)| *d2 <= tol * tol)
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0).then(first(a.1, b.1)))
|
||||
.map(|(_, l)| map.get(&l).copied().unwrap_or(l));
|
||||
match near {
|
||||
Some(survivor) => {
|
||||
|
||||
@@ -13,10 +13,12 @@ pub mod delaunay;
|
||||
pub mod hex;
|
||||
pub mod join;
|
||||
pub mod mirror;
|
||||
pub mod nodes;
|
||||
pub mod octree;
|
||||
pub mod planar;
|
||||
pub mod quad;
|
||||
pub mod quality;
|
||||
pub mod relax;
|
||||
pub mod seeds;
|
||||
pub mod size;
|
||||
pub mod surface;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Node tools on a model's mesh: merging coincident nodes ([`merge_plan`]), finding what was
|
||||
//! not merged or does not join ([`duplicates`]), and a node order of small bandwidth for
|
||||
//! renumbering ([`reverse_cuthill_mckee`]).
|
||||
//!
|
||||
//! The bandwidth order is reverse Cuthill–McKee: E. Cuthill, J. McKee, *Reducing the bandwidth
|
||||
//! of sparse symmetric matrices*, Proc. 24th ACM National Conference, 1969, pp. 157–172, with
|
||||
//! the reversal of A. George (1971) and each component started from a pseudo-peripheral node
|
||||
//! found by repeated breadth-first level structures (A. George, J. W. H. Liu, *An implementation
|
||||
//! of a pseudoperipheral node finder*, ACM TOMS 5(3):284–295, 1979).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use fieldcraft_model::{ElementFace, ElementKind, Model};
|
||||
|
||||
use crate::Result;
|
||||
use crate::join::coincident_nodes;
|
||||
use crate::vec3::dist;
|
||||
|
||||
/// Which node of a coincident group survives a merge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Keep {
|
||||
#[default]
|
||||
Lowest,
|
||||
Highest,
|
||||
}
|
||||
|
||||
/// A merge that was not made: it would put two nodes of `element` on one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Refused {
|
||||
pub node: u32,
|
||||
pub survivor: u32,
|
||||
pub element: u32,
|
||||
}
|
||||
|
||||
/// The nodes to merge, each into its survivor, and the merges left out.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct MergePlan {
|
||||
pub map: BTreeMap<u32, u32>,
|
||||
pub refused: Vec<Refused>,
|
||||
}
|
||||
|
||||
/// Which nodes among `candidates` (every node when `None`) merge within `tol`, the survivor of
|
||||
/// each group chosen by `keep`. A merge that would put two nodes of one element together (the
|
||||
/// element would collapse) is left out and listed, so the rest still merge; the plan is then
|
||||
/// safe for [`Model::merge_nodes`].
|
||||
pub fn merge_plan(model: &Model, candidates: Option<&BTreeSet<u32>>, tol: f64, keep: Keep) -> Result<MergePlan> {
|
||||
let mut map = crate::join::coincident_nodes_from(model, candidates, tol, keep == Keep::Highest)?;
|
||||
let mut refused = Vec::new();
|
||||
// Drop merges that would collapse an element until none does (each pass removes at least
|
||||
// one, so this ends).
|
||||
loop {
|
||||
let mut drop: Vec<Refused> = Vec::new();
|
||||
for (&label, e) in model.elements() {
|
||||
let mut seen: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for &n in &e.nodes {
|
||||
let to = map.get(&n).copied().unwrap_or(n);
|
||||
if let Some(&first) = seen.get(&to) {
|
||||
// `n` and `first` would both become `to`: keep whichever of them is `to`.
|
||||
let node = if map.contains_key(&n) { n } else { first };
|
||||
drop.push(Refused { node, survivor: to, element: label });
|
||||
} else {
|
||||
seen.insert(to, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if drop.is_empty() {
|
||||
break;
|
||||
}
|
||||
for r in drop {
|
||||
if map.remove(&r.node).is_some() {
|
||||
refused.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
refused.sort_by_key(|r| (r.node, r.element));
|
||||
Ok(MergePlan { map, refused })
|
||||
}
|
||||
|
||||
/// What [`duplicates`] found.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Duplicates {
|
||||
/// Nodes within the distance of an earlier one: (node, the one it lies on, distance).
|
||||
pub coincident: Vec<(u32, u32, f64)>,
|
||||
/// Groups of elements on the same nodes.
|
||||
pub elements: Vec<Vec<u32>>,
|
||||
/// Faces of solid elements that no other element shares (the skin, cracks included).
|
||||
pub free_faces: usize,
|
||||
/// Free faces lying on another free face within the distance: cracks between parts that
|
||||
/// touch without sharing nodes.
|
||||
pub cracks: Vec<ElementFace>,
|
||||
/// Edges of the skin (free faces of solids, and 2D and shell elements) on only one of its
|
||||
/// facets: the border of an open surface or a crack.
|
||||
pub free_edges: Vec<[u32; 2]>,
|
||||
}
|
||||
|
||||
/// Coincident nodes not merged, duplicate elements, and free faces and edges.
|
||||
pub fn duplicates(model: &Model, tol: f64) -> Result<Duplicates> {
|
||||
let xyz = |l: u32| model.node(l).map(|n| n.xyz);
|
||||
let coincident: Vec<(u32, u32, f64)> =
|
||||
coincident_nodes(model, None, tol)?.into_iter().map(|(a, b)| (a, b, xyz(a).zip(xyz(b)).map_or(0.0, |(p, q)| dist(p, q)))).collect();
|
||||
// Elements on the same node set.
|
||||
let mut by_nodes: BTreeMap<Vec<u32>, Vec<u32>> = BTreeMap::new();
|
||||
for (&label, e) in model.elements() {
|
||||
let mut k = e.nodes.clone();
|
||||
k.sort_unstable();
|
||||
by_nodes.entry(k).or_default().push(label);
|
||||
}
|
||||
let elements: Vec<Vec<u32>> = by_nodes.into_values().filter(|g| g.len() > 1).collect();
|
||||
// Free faces of solids, by sorted corners.
|
||||
let mut faces: BTreeMap<Vec<u32>, (usize, ElementFace)> = BTreeMap::new();
|
||||
let mut facets: Vec<Vec<u32>> = Vec::new();
|
||||
for (&label, e) in model.elements() {
|
||||
match e.kind.dimension() {
|
||||
3 => {
|
||||
for (k, local) in e.kind.faces().iter().enumerate() {
|
||||
let corners = corner_cycle(e.kind, &e.nodes, local);
|
||||
let mut key = corners.clone();
|
||||
key.sort_unstable();
|
||||
let face = ElementFace { element: label, face: u8::try_from(k + 1).unwrap_or(u8::MAX) };
|
||||
faces.entry(key).and_modify(|x| x.0 += 1).or_insert((1, face));
|
||||
}
|
||||
}
|
||||
2 => facets.push(corner_cycle(e.kind, &e.nodes, &(0..e.kind.corner_count()).collect::<Vec<_>>())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let free: Vec<(Vec<u32>, ElementFace)> = faces.into_iter().filter(|(_, (n, _))| *n == 1).map(|(k, (_, f))| (k, f)).collect();
|
||||
// Cracks: a free face whose corners lie on another free face's corners.
|
||||
let mut cracks = Vec::new();
|
||||
if !coincident.is_empty() {
|
||||
let survivor: BTreeMap<u32, u32> = coincident.iter().map(|&(a, b, _)| (a, b)).collect();
|
||||
let mut by_position: BTreeMap<Vec<u32>, Vec<ElementFace>> = BTreeMap::new();
|
||||
for (k, f) in &free {
|
||||
let mut key: Vec<u32> = k.iter().map(|n| survivor.get(n).copied().unwrap_or(*n)).collect();
|
||||
key.sort_unstable();
|
||||
by_position.entry(key).or_default().push(*f);
|
||||
}
|
||||
cracks = by_position.into_values().filter(|g| g.len() > 1).flatten().collect();
|
||||
cracks.sort_unstable();
|
||||
}
|
||||
for (_, f) in &free {
|
||||
if let Some(e) = model.element(f.element)
|
||||
&& let Some(local) = e.kind.faces().get(usize::from(f.face) - 1)
|
||||
{
|
||||
facets.push(corner_cycle(e.kind, &e.nodes, local));
|
||||
}
|
||||
}
|
||||
let mut edge_count: BTreeMap<[u32; 2], usize> = BTreeMap::new();
|
||||
for cycle in &facets {
|
||||
for (i, &a) in cycle.iter().enumerate() {
|
||||
let b = cycle.get((i + 1) % cycle.len()).copied().unwrap_or(a);
|
||||
*edge_count.entry([a.min(b), a.max(b)]).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let free_edges = edge_count.into_iter().filter(|(_, n)| *n == 1).map(|(e, _)| e).collect();
|
||||
Ok(Duplicates { coincident, elements, free_faces: free.len(), cracks, free_edges })
|
||||
}
|
||||
|
||||
/// The corner nodes of a face (local node numbers `local`, corners first) in order round it.
|
||||
fn corner_cycle(kind: ElementKind, nodes: &[u32], local: &[usize]) -> Vec<u32> {
|
||||
let corners = kind.corner_count();
|
||||
local.iter().filter(|&&i| i < corners).filter_map(|&i| nodes.get(i).copied()).collect()
|
||||
}
|
||||
|
||||
/// The nodes in reverse Cuthill–McKee order: each connected part of the mesh from a
|
||||
/// pseudo-peripheral node, breadth first with neighbours by increasing degree (then label), the
|
||||
/// whole order reversed. Nodes no element uses come last, by label.
|
||||
pub fn reverse_cuthill_mckee(model: &Model) -> Vec<u32> {
|
||||
let labels: Vec<u32> = model.nodes().keys().copied().collect();
|
||||
let index: BTreeMap<u32, usize> = labels.iter().enumerate().map(|(i, &l)| (l, i)).collect();
|
||||
let mut adj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); labels.len()];
|
||||
for e in model.elements().values() {
|
||||
let ids: Vec<usize> = e.nodes.iter().filter_map(|n| index.get(n).copied()).collect();
|
||||
for &a in &ids {
|
||||
for &b in &ids {
|
||||
if a != b
|
||||
&& let Some(s) = adj.get_mut(a)
|
||||
{
|
||||
s.insert(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let degree = |v: usize| adj.get(v).map_or(0, BTreeSet::len);
|
||||
let mut placed = vec![false; labels.len()];
|
||||
let mut order: Vec<usize> = Vec::with_capacity(labels.len());
|
||||
for start in 0..labels.len() {
|
||||
if placed.get(start).copied().unwrap_or(true) || degree(start) == 0 {
|
||||
continue;
|
||||
}
|
||||
let root = pseudo_peripheral(&adj, start);
|
||||
let mut queue = VecDeque::from([root]);
|
||||
if let Some(p) = placed.get_mut(root) {
|
||||
*p = true;
|
||||
}
|
||||
while let Some(v) = queue.pop_front() {
|
||||
order.push(v);
|
||||
let mut next: Vec<usize> =
|
||||
adj.get(v).map(|s| s.iter().copied().filter(|&w| !placed.get(w).copied().unwrap_or(true)).collect()).unwrap_or_default();
|
||||
next.sort_by_key(|&w| (degree(w), w));
|
||||
for w in next {
|
||||
if let Some(p) = placed.get_mut(w) {
|
||||
*p = true;
|
||||
}
|
||||
queue.push_back(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
order.reverse();
|
||||
let mut out: Vec<u32> = order.into_iter().filter_map(|i| labels.get(i).copied()).collect();
|
||||
out.extend(labels.iter().enumerate().filter(|(i, _)| !placed.get(*i).copied().unwrap_or(false)).map(|(_, &l)| l));
|
||||
out
|
||||
}
|
||||
|
||||
/// A node of `start`'s component far from the others: repeatedly the lowest-degree node of the
|
||||
/// last level of a breadth-first level structure, while the structure gets deeper.
|
||||
fn pseudo_peripheral(adj: &[BTreeSet<usize>], start: usize) -> usize {
|
||||
let levels = |root: usize| -> Vec<Vec<usize>> {
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let mut out = vec![vec![root]];
|
||||
while let Some(last) = out.last() {
|
||||
let mut next: Vec<usize> = Vec::new();
|
||||
for &v in last {
|
||||
for &w in adj.get(v).into_iter().flatten() {
|
||||
if seen.insert(w) {
|
||||
next.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
};
|
||||
let degree = |v: usize| adj.get(v).map_or(0, BTreeSet::len);
|
||||
let mut root = start;
|
||||
let mut depth = levels(root).len();
|
||||
// Each round strictly deepens the structure, so at most as many rounds as nodes.
|
||||
for _ in 0..adj.len() {
|
||||
let Some(far) = levels(root).last().and_then(|l| l.iter().copied().min_by_key(|&v| (degree(v), v))) else { break };
|
||||
let d = levels(far).len();
|
||||
if d <= depth {
|
||||
break;
|
||||
}
|
||||
(root, depth) = (far, d);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// The mesh's bandwidth with node labels `label(old)`: the largest label difference between two
|
||||
/// nodes of one element.
|
||||
pub fn bandwidth(model: &Model, label: &dyn Fn(u32) -> u32) -> u32 {
|
||||
model
|
||||
.elements()
|
||||
.values()
|
||||
.map(|e| {
|
||||
let ls = e.nodes.iter().map(|&n| label(n));
|
||||
let (lo, hi) = ls.fold((u32::MAX, 0), |(lo, hi), l| (lo.min(l), hi.max(l)));
|
||||
hi.saturating_sub(lo)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Hex8 unit cubes along x, each with its own nodes (cube `k` from x = k to k + 1), so the
|
||||
/// four nodes of each joint coincide.
|
||||
fn cubes(n: u32) -> Model {
|
||||
let mut m = Model::default();
|
||||
let mut label = 0;
|
||||
for e in 0..n {
|
||||
let first = label + 1;
|
||||
for [x, y, z] in [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]] {
|
||||
label += 1;
|
||||
m.set_node(label, [f64::from(e) + x, y, z]).unwrap();
|
||||
}
|
||||
m.set_element(e + 1, ElementKind::Hex8, (first..first + 8).collect()).unwrap();
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_keeps_the_lowest_or_highest_and_leaves_out_collapses() {
|
||||
let m = cubes(2);
|
||||
let low = merge_plan(&m, None, 1e-9, Keep::Lowest).unwrap();
|
||||
assert_eq!(low.map, [(9, 2), (12, 3), (13, 6), (16, 7)].into());
|
||||
let high = merge_plan(&m, None, 1e-9, Keep::Highest).unwrap();
|
||||
assert_eq!(high.map, [(2, 9), (3, 12), (6, 13), (7, 16)].into());
|
||||
assert!(low.refused.is_empty() && high.refused.is_empty());
|
||||
// A third element on nodes 2 and 9 together: their merge would collapse it.
|
||||
let mut odd = m.clone();
|
||||
odd.set_node(17, [5.0, 5.0, 5.0]).unwrap();
|
||||
odd.set_element(3, ElementKind::Rod2, vec![2, 9]).unwrap();
|
||||
let plan = merge_plan(&odd, None, 1e-9, Keep::Lowest).unwrap();
|
||||
assert_eq!(plan.refused, vec![Refused { node: 9, survivor: 2, element: 3 }]);
|
||||
assert_eq!(plan.map.len(), 3);
|
||||
odd.merge_nodes(&plan.map).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_find_unmerged_nodes_repeated_elements_and_cracks() {
|
||||
let mut m = cubes(2);
|
||||
m.set_element(3, ElementKind::Hex8, (1..=8).collect()).unwrap();
|
||||
let d = duplicates(&m, 1e-9).unwrap();
|
||||
assert_eq!(d.coincident.len(), 4);
|
||||
assert_eq!(d.elements, vec![vec![1, 3]]);
|
||||
// Cubes 1 and 3 share every face; cube 2's six are free, and its face on x = 1 lies on
|
||||
// cube 1's (now shared with cube 3, so not free): no crack. Closed skin: no free edge.
|
||||
assert_eq!(d.free_faces, 6);
|
||||
assert!(d.cracks.is_empty() && d.free_edges.is_empty(), "{d:?}");
|
||||
let d = duplicates(&cubes(2), 1e-9).unwrap();
|
||||
assert_eq!(d.free_faces, 12);
|
||||
assert_eq!(d.cracks, vec![ElementFace { element: 1, face: 4 }, ElementFace { element: 2, face: 6 }]);
|
||||
// A lone quad: its four sides are free edges.
|
||||
let mut q = Model::default();
|
||||
for (l, p) in [(1, [0., 0., 0.]), (2, [1., 0., 0.]), (3, [1., 1., 0.]), (4, [0., 1., 0.])] {
|
||||
q.set_node(l, p).unwrap();
|
||||
}
|
||||
q.set_element(1, ElementKind::Quad4, vec![1, 2, 3, 4]).unwrap();
|
||||
assert_eq!(duplicates(&q, 1e-9).unwrap().free_edges.len(), 4);
|
||||
}
|
||||
|
||||
/// Reverse Cuthill–McKee brings the bandwidth of a strip whose labels were scattered back
|
||||
/// down to what its connectivity needs.
|
||||
#[test]
|
||||
fn reverse_cuthill_mckee_narrows_the_bandwidth() {
|
||||
let mut m = cubes(6);
|
||||
let merged = merge_plan(&m, None, 1e-9, Keep::Lowest).unwrap();
|
||||
m.merge_nodes(&merged.map).unwrap();
|
||||
// Scatter the labels: node l becomes (l · 7919) mod 1009 + 1.
|
||||
let scatter: BTreeMap<u32, u32> = m.nodes().keys().map(|&l| (l, (l * 7919) % 1009 + 1)).collect();
|
||||
m.renumber(&scatter, &BTreeMap::new()).unwrap();
|
||||
let before = bandwidth(&m, &|l| l);
|
||||
let order = reverse_cuthill_mckee(&m);
|
||||
assert_eq!(order.len(), m.nodes().len());
|
||||
let new: BTreeMap<u32, u32> = order.iter().enumerate().map(|(i, &l)| (l, i as u32 + 1)).collect();
|
||||
let after = bandwidth(&m, &|l| new.get(&l).copied().unwrap_or(l));
|
||||
// A strip of cubes: each element spans two layers of four nodes.
|
||||
assert!(after <= 7 && after < before, "{before} → {after}");
|
||||
// An unused node goes last.
|
||||
m.set_node(5000, [9.0, 9.0, 9.0]).unwrap();
|
||||
assert_eq!(reverse_cuthill_mckee(&m).last(), Some(&5000));
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options
|
||||
let mut hints: Vec<P> = Vec::new();
|
||||
let mut retries: Vec<String> = Vec::new();
|
||||
for attempt in 0..options.attempts {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let cap = if attempt == 0 {
|
||||
options.max_elements
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Node relaxation: moving a few nodes of a finished mesh to fix its worst elements, the nodes on
|
||||
//! the geometry staying on it (`mesh.node.relax`).
|
||||
//!
|
||||
//! Each node to move carries a weight in `(0, 1]` (the strength, times a falloff that blends the
|
||||
//! edited zone into the mesh around it). A sweep visits the nodes in label order and tries, for
|
||||
//! each, two moves:
|
||||
//!
|
||||
//! 1. **Smart Laplacian.** The weighted step toward the centroid of the corner nodes it shares an
|
||||
//! element edge with, kept only when the worst element around the node does not get worse and
|
||||
//! no valid element becomes invalid; otherwise the step is halved (three times at most).
|
||||
//! L. Freitag, *On combining Laplacian and optimization-based mesh smoothing techniques*, AMD
|
||||
//! 220, 1997 (the quality-guarded Laplacian); S. Canann, J. Tristano, M. Staten, *An approach
|
||||
//! to combined Laplacian and optimization-based smoothing for triangular, quadrilateral, and
|
||||
//! quad-dominant meshes*, IMR 1998 (smart Laplacian: a move is accepted only if the local
|
||||
//! quality does not drop).
|
||||
//! 2. **Optimization.** A step up the gradient of the worst element's quality around the node
|
||||
//! (central differences), with a backtracking line search, kept only when the worst quality
|
||||
//! rises: the max–min formulation of L. Freitag, C. Ollivier-Gooch, *Tetrahedral mesh
|
||||
//! improvement using swapping and smoothing*, IJNME 40, 1997, and P. Knupp, *Achieving finite
|
||||
//! element mesh quality via optimization of the Jacobian matrix norm and associated
|
||||
//! quantities*, IJNME 48, 2000, restricted to the active (worst) element.
|
||||
//!
|
||||
//! An element's quality is its scaled Jacobian (a triangle's: its smallest angle over 60°), from
|
||||
//! [`crate::quality::element_quality`]; a solid is valid when its certified Jacobian check is,
|
||||
//! a shell when its normal keeps its side. So no move inverts an element, and none makes the
|
||||
//! worst element around a node worse.
|
||||
//!
|
||||
//! **Constraints** ([`Freedom`]): an interior node moves freely; a node on a geometry face moves
|
||||
//! in the face's parameters and is evaluated back on it ([`Constraints::slide_face`]); a node on a
|
||||
//! geometry edge slides along it ([`Constraints::slide_curve`]); a fixed node (a geometry vertex,
|
||||
//! or a skin node whose geometry is unknown) does not move. Mid-side nodes of quadratic elements
|
||||
//! follow their edge's corners, keeping their offset from the edge's midpoint (the curvature),
|
||||
//! and are put back on their face or edge.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_model::{ElementKind, Model, RelaxationCell};
|
||||
|
||||
use crate::quality::{self, Metric, Validity};
|
||||
use crate::{Error, Result};
|
||||
|
||||
type P = [f64; 3];
|
||||
|
||||
/// Most sweeps over the nodes.
|
||||
pub const MAX_ITERATIONS: usize = 100;
|
||||
/// Most nodes one relaxation moves.
|
||||
pub const MAX_NODES: usize = 200_000;
|
||||
/// Elements listed as the worst of the region.
|
||||
pub const WORST_LISTED: usize = 5;
|
||||
|
||||
/// How a node may move.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Freedom {
|
||||
/// Anywhere (an interior node).
|
||||
Free,
|
||||
/// Not at all.
|
||||
Fixed,
|
||||
/// On the face with this id (the caller's).
|
||||
Face(u32),
|
||||
/// Along the curve with this id (the caller's).
|
||||
Curve(u32),
|
||||
}
|
||||
|
||||
/// Where nodes may go: the caller's faces and curves.
|
||||
pub trait Constraints {
|
||||
/// How `node` may move; `None` when the caller does not know the node's geometry: it then
|
||||
/// moves freely inside the mesh and is held on its skin.
|
||||
fn freedom(&self, node: u32) -> Option<Freedom>;
|
||||
/// `p` (on face `face`) moved by `d` in the face's parameters; `None` refuses the move.
|
||||
fn slide_face(&self, face: u32, p: P, d: P) -> Option<P>;
|
||||
/// `p` (on curve `curve`) moved by `d` along the curve; `None` refuses the move.
|
||||
fn slide_curve(&self, curve: u32, p: P, d: P) -> Option<P>;
|
||||
}
|
||||
|
||||
/// No geometry: interior nodes move, every node on the skin is held.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NoGeometry;
|
||||
|
||||
impl Constraints for NoGeometry {
|
||||
fn freedom(&self, _: u32) -> Option<Freedom> {
|
||||
None
|
||||
}
|
||||
fn slide_face(&self, _: u32, _: P, _: P) -> Option<P> {
|
||||
None
|
||||
}
|
||||
fn slide_curve(&self, _: u32, _: P, _: P) -> Option<P> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// How the weight falls from the centre of a sphere of influence to its radius.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Falloff {
|
||||
/// `(1 − t²)²` of the relative distance `t`: 1 at the centre, 0 with a zero slope at the
|
||||
/// radius, so the edited zone blends into the mesh around it.
|
||||
#[default]
|
||||
Smooth,
|
||||
/// `1 − t`.
|
||||
Linear,
|
||||
/// The full strength everywhere inside.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Falloff {
|
||||
pub fn from_name(name: &str) -> Option<Falloff> {
|
||||
match name {
|
||||
"smooth" => Some(Falloff::Smooth),
|
||||
"linear" => Some(Falloff::Linear),
|
||||
"none" => Some(Falloff::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The weight at relative distance `t` in `[0, 1]`.
|
||||
pub fn weight(self, t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
match self {
|
||||
Falloff::Smooth => (1.0 - t * t).powi(2),
|
||||
Falloff::Linear => 1.0 - t,
|
||||
Falloff::None => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The quality of the elements around the moved nodes.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct RegionQuality {
|
||||
pub elements: usize,
|
||||
/// Smallest element quality (scaled Jacobian; a triangle's smallest angle over 60°).
|
||||
pub min_quality: f64,
|
||||
/// Largest ratio of an element's longest to its shortest edge.
|
||||
pub max_edge_ratio: f64,
|
||||
/// Elements whose Jacobian (or a shell's normal) is not valid.
|
||||
pub invalid: usize,
|
||||
/// The worst elements and their quality, worst first.
|
||||
pub worst: Vec<(u32, f64)>,
|
||||
}
|
||||
|
||||
/// What [`relax`] did.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Relaxed {
|
||||
/// The new position of every node that moved (corners and the mid-side nodes following them).
|
||||
pub positions: BTreeMap<u32, P>,
|
||||
/// Corner nodes that moved.
|
||||
pub moved: usize,
|
||||
/// Largest distance a node moved.
|
||||
pub max_displacement: f64,
|
||||
pub before: RegionQuality,
|
||||
pub after: RegionQuality,
|
||||
/// Sweeps made (fewer than asked when a sweep moved nothing).
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
/// The corner nodes of measured elements (solids and shells) inside the sphere, weighted by
|
||||
/// `strength` × the falloff; only nodes of `only` when given.
|
||||
pub fn sphere_weights(
|
||||
model: &Model,
|
||||
center: P,
|
||||
radius: f64,
|
||||
strength: f64,
|
||||
falloff: Falloff,
|
||||
only: Option<&BTreeSet<u32>>,
|
||||
) -> Result<BTreeMap<u32, f64>> {
|
||||
if !(radius > 0.0 && radius.is_finite()) || !center.iter().all(|c| c.is_finite()) {
|
||||
return Err(Error::Invalid("the sphere needs a finite centre and a positive radius".into()));
|
||||
}
|
||||
check_strength(strength)?;
|
||||
let mut out = BTreeMap::new();
|
||||
for n in corner_nodes(model) {
|
||||
if only.is_some_and(|o| !o.contains(&n)) {
|
||||
continue;
|
||||
}
|
||||
let Some(node) = model.node(n) else { continue };
|
||||
let t = dist(node.xyz, center) / radius;
|
||||
if t < 1.0 {
|
||||
let w = strength * falloff.weight(t);
|
||||
if w > 0.0 {
|
||||
out.insert(n, w);
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {MAX_NODES} nodes inside the sphere; give a smaller radius")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The corner nodes among `nodes` (nodes of measured elements that are a corner of one), each
|
||||
/// weighted `strength`.
|
||||
pub fn uniform_weights(model: &Model, nodes: &BTreeSet<u32>, strength: f64) -> Result<BTreeMap<u32, f64>> {
|
||||
check_strength(strength)?;
|
||||
let corners = corner_nodes(model);
|
||||
let out: BTreeMap<u32, f64> = nodes.iter().filter(|n| corners.contains(n)).map(|&n| (n, strength)).collect();
|
||||
if out.len() > MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {MAX_NODES} nodes to relax")));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check_strength(strength: f64) -> Result<()> {
|
||||
if strength > 0.0 && strength <= 1.0 { Ok(()) } else { Err(Error::Invalid(format!("strength must be in (0, 1], got {strength}"))) }
|
||||
}
|
||||
|
||||
/// Corner nodes of the solid and shell elements.
|
||||
fn corner_nodes(model: &Model) -> BTreeSet<u32> {
|
||||
let mut out = BTreeSet::new();
|
||||
for e in model.elements().values().filter(|e| quality::is_measured(e.kind)) {
|
||||
out.extend(e.nodes.iter().take(e.kind.corner_count()).copied());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nodes on the skin of the mesh: on a solid face no other solid shares, or on a shell.
|
||||
fn skin_nodes(model: &Model, near: &BTreeSet<u32>) -> BTreeSet<u32> {
|
||||
let mut faces: BTreeMap<Vec<u32>, (usize, Vec<u32>)> = BTreeMap::new();
|
||||
let mut skin = BTreeSet::new();
|
||||
for e in model.elements().values() {
|
||||
if !e.nodes.iter().any(|n| near.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
match e.kind.dimension() {
|
||||
3 => {
|
||||
for f in e.kind.faces() {
|
||||
let nodes: Vec<u32> = f.iter().filter_map(|&i| e.nodes.get(i).copied()).collect();
|
||||
let mut key: Vec<u32> = nodes.iter().take(corner_len(f.len())).copied().collect();
|
||||
key.sort_unstable();
|
||||
faces.entry(key).or_insert((0, nodes)).0 += 1;
|
||||
}
|
||||
}
|
||||
2 => skin.extend(e.nodes.iter().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (count, nodes) in faces.into_values() {
|
||||
if count == 1 {
|
||||
skin.extend(nodes);
|
||||
}
|
||||
}
|
||||
skin
|
||||
}
|
||||
|
||||
/// Corners of a face with `n` nodes (3, 4 linear; 6, 8 quadratic).
|
||||
fn corner_len(n: usize) -> usize {
|
||||
match n {
|
||||
6 => 3,
|
||||
8 => 4,
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(a: P, b: P) -> P {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: P, b: P) -> P {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn scale(a: P, s: f64) -> P {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: P, b: P) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: P, b: P) -> P {
|
||||
[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 dist(a: P, b: P) -> f64 {
|
||||
dot(sub(a, b), sub(a, b)).sqrt()
|
||||
}
|
||||
|
||||
/// One element of the region.
|
||||
struct Elem {
|
||||
label: u32,
|
||||
kind: ElementKind,
|
||||
nodes: Vec<u32>,
|
||||
/// A shell's normal as it was: its side, which a move must keep.
|
||||
normal: Option<P>,
|
||||
}
|
||||
|
||||
/// The shell normal from its corners (`None` for solids).
|
||||
fn shell_normal(kind: ElementKind, x: &[P]) -> Option<P> {
|
||||
if kind.dimension() != 2 {
|
||||
return None;
|
||||
}
|
||||
let c = |i: usize| x.get(i).copied().unwrap_or([0.0; 3]);
|
||||
Some(match kind.corner_count() {
|
||||
3 => cross(sub(c(1), c(0)), sub(c(2), c(0))),
|
||||
_ => cross(sub(c(2), c(0)), sub(c(3), c(1))),
|
||||
})
|
||||
}
|
||||
|
||||
/// An element's quality and whether it is valid.
|
||||
fn measure(e: &Elem, x: &[P]) -> (f64, bool) {
|
||||
let Ok(q) = quality::element_quality(e.kind, x) else { return (-1.0, false) };
|
||||
let score = q.get(Metric::ScaledJacobian).or_else(|| q.get(Metric::MinAngle).map(|a| a / 60.0)).unwrap_or(-1.0);
|
||||
let valid = match (q.validity(), e.normal) {
|
||||
(Some(v), _) => v == Validity::Valid,
|
||||
(None, Some(n0)) => shell_normal(e.kind, x).is_some_and(|n| dot(n, n0) > 0.0) && q.size > 0.0,
|
||||
(None, None) => q.size > 0.0,
|
||||
};
|
||||
(score, valid)
|
||||
}
|
||||
|
||||
/// Longest over shortest corner edge.
|
||||
fn edge_ratio(kind: ElementKind, x: &[P]) -> f64 {
|
||||
let (mut lo, mut hi) = (f64::INFINITY, 0.0f64);
|
||||
for e in kind.edges() {
|
||||
if let (Some(&a), Some(&b)) = (e.first().and_then(|&i| x.get(i)), e.get(1).and_then(|&i| x.get(i))) {
|
||||
let l = dist(a, b);
|
||||
lo = lo.min(l);
|
||||
hi = hi.max(l);
|
||||
}
|
||||
}
|
||||
if lo > 0.0 { hi / lo } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
/// The working state: positions, the region's elements and who depends on whom.
|
||||
struct State<'a> {
|
||||
pos: BTreeMap<u32, P>,
|
||||
elems: Vec<Elem>,
|
||||
/// Corner node → its elements (indices into `elems`).
|
||||
around: BTreeMap<u32, Vec<usize>>,
|
||||
/// Corner node → corner nodes it shares an element edge with.
|
||||
neighbours: BTreeMap<u32, BTreeSet<u32>>,
|
||||
/// Mid-side node → its edge's corners and its offset from their midpoint.
|
||||
mids: BTreeMap<u32, (u32, u32, P)>,
|
||||
/// Corner node → the mid-side nodes on its edges.
|
||||
mids_of: BTreeMap<u32, Vec<u32>>,
|
||||
skin: BTreeSet<u32>,
|
||||
geometry: &'a dyn Constraints,
|
||||
}
|
||||
|
||||
impl State<'_> {
|
||||
fn at(&self, n: u32) -> P {
|
||||
self.pos.get(&n).copied().unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
fn coords(&self, e: &Elem) -> Vec<P> {
|
||||
e.nodes.iter().map(|&n| self.at(n)).collect()
|
||||
}
|
||||
|
||||
/// How `n` may move: a skin node the caller left free is held.
|
||||
fn freedom(&self, n: u32) -> Freedom {
|
||||
match self.geometry.freedom(n) {
|
||||
Some(f) => f,
|
||||
None if self.skin.contains(&n) => Freedom::Fixed,
|
||||
None => Freedom::Free,
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` moved by `d` under `freedom`.
|
||||
fn moved(&self, freedom: Freedom, p: P, d: P) -> Option<P> {
|
||||
let q = match freedom {
|
||||
Freedom::Fixed => return None,
|
||||
Freedom::Free => add(p, d),
|
||||
Freedom::Face(f) => self.geometry.slide_face(f, p, d)?,
|
||||
Freedom::Curve(c) => self.geometry.slide_curve(c, p, d)?,
|
||||
};
|
||||
q.iter().all(|x| x.is_finite() && x.abs() <= fieldcraft_model::MAX_COORD).then_some(q)
|
||||
}
|
||||
|
||||
/// The worst quality around `n` and the validity of each element there.
|
||||
fn local(&self, n: u32) -> (f64, Vec<bool>) {
|
||||
let mut worst = f64::INFINITY;
|
||||
let mut valid = Vec::new();
|
||||
for &i in self.around.get(&n).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
let Some(e) = self.elems.get(i) else { continue };
|
||||
let (q, v) = measure(e, &self.coords(e));
|
||||
worst = worst.min(q);
|
||||
valid.push(v);
|
||||
}
|
||||
(worst, valid)
|
||||
}
|
||||
|
||||
/// The worst element around `n`: its index and quality.
|
||||
fn worst_around(&self, n: u32) -> Option<(usize, f64)> {
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for &i in self.around.get(&n)? {
|
||||
let Some(e) = self.elems.get(i) else { continue };
|
||||
let (q, _) = measure(e, &self.coords(e));
|
||||
if best.is_none_or(|(_, b)| q < b) {
|
||||
best = Some((i, q));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Put `n` at `p` with its mid-side nodes following; returns what to restore.
|
||||
fn place(&mut self, n: u32, p: P) -> Vec<(u32, P)> {
|
||||
let mut undo = vec![(n, self.at(n))];
|
||||
self.pos.insert(n, p);
|
||||
for m in self.mids_of.get(&n).cloned().unwrap_or_default() {
|
||||
let Some(&(a, b, bulge)) = self.mids.get(&m) else { continue };
|
||||
let old = self.at(m);
|
||||
let guess = add(scale(add(self.at(a), self.at(b)), 0.5), bulge);
|
||||
let q = match self.freedom(m) {
|
||||
Freedom::Free => Some(guess),
|
||||
Freedom::Fixed => None,
|
||||
f => self.moved(f, old, sub(guess, old)),
|
||||
};
|
||||
if let Some(q) = q {
|
||||
undo.push((m, old));
|
||||
self.pos.insert(m, q);
|
||||
}
|
||||
}
|
||||
undo
|
||||
}
|
||||
|
||||
fn restore(&mut self, undo: Vec<(u32, P)>) {
|
||||
for (n, p) in undo.into_iter().rev() {
|
||||
self.pos.insert(n, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try moving `n` by `d` (under its freedom): kept when no valid element around it becomes
|
||||
/// invalid and its worst quality does not drop (rises by more than `gain`).
|
||||
fn attempt(&mut self, n: u32, freedom: Freedom, d: P, before: &(f64, Vec<bool>), gain: f64) -> bool {
|
||||
let p = self.at(n);
|
||||
let Some(q) = self.moved(freedom, p, d) else { return false };
|
||||
if dist(p, q) == 0.0 {
|
||||
return false;
|
||||
}
|
||||
let undo = self.place(n, q);
|
||||
let (worst, valid) = self.local(n);
|
||||
let ok = worst >= before.0 + gain && before.1.iter().zip(&valid).all(|(was, is)| !was || *is);
|
||||
if !ok {
|
||||
self.restore(undo);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// The mean length of the element edges at `n`.
|
||||
fn local_length(&self, n: u32) -> f64 {
|
||||
let p = self.at(n);
|
||||
let nb = self.neighbours.get(&n);
|
||||
let (sum, count) = nb.into_iter().flatten().fold((0.0, 0usize), |(s, c), &m| (s + dist(p, self.at(m)), c + 1));
|
||||
if count > 0 { sum / count as f64 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// One relaxation of `n` with weight `w`; whether it moved.
|
||||
fn relax_node(&mut self, n: u32, w: f64) -> bool {
|
||||
let freedom = self.freedom(n);
|
||||
if freedom == Freedom::Fixed {
|
||||
return false;
|
||||
}
|
||||
let p = self.at(n);
|
||||
let mut moved = false;
|
||||
// 1. Smart Laplacian.
|
||||
let nb: Vec<u32> = self.neighbours.get(&n).map(|s| s.iter().copied().collect()).unwrap_or_default();
|
||||
if !nb.is_empty() {
|
||||
let centroid = scale(nb.iter().fold([0.0; 3], |s, &m| add(s, self.at(m))), 1.0 / nb.len() as f64);
|
||||
let before = self.local(n);
|
||||
let mut d = scale(sub(centroid, p), w);
|
||||
for _ in 0..4 {
|
||||
if self.attempt(n, freedom, d, &before, 0.0) {
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
d = scale(d, 0.5);
|
||||
}
|
||||
}
|
||||
// 2. Optimization of the worst element around the node.
|
||||
let h = self.local_length(n);
|
||||
if !(h > 0.0) {
|
||||
return moved;
|
||||
}
|
||||
let Some((worst, q0)) = self.worst_around(n) else { return moved };
|
||||
let Some(e) = self.elems.get(worst) else { return moved };
|
||||
let slot = e.nodes.iter().position(|&m| m == n);
|
||||
let Some(slot) = slot else { return moved };
|
||||
let mut x = self.coords(e);
|
||||
let here = self.at(n);
|
||||
let eps = 1e-4 * h;
|
||||
let mut grad = [0.0; 3];
|
||||
for k in 0..3 {
|
||||
let mut f = [0.0; 2];
|
||||
for (j, sign) in [1.0, -1.0].into_iter().enumerate() {
|
||||
if let Some(c) = x.get_mut(slot) {
|
||||
c[k] = here[k] + sign * eps;
|
||||
}
|
||||
f[j] = measure(e, &x).0;
|
||||
}
|
||||
if let Some(c) = x.get_mut(slot) {
|
||||
c[k] = here[k];
|
||||
}
|
||||
grad[k] = (f[0] - f[1]) / (2.0 * eps);
|
||||
}
|
||||
let g = dot(grad, grad).sqrt();
|
||||
if !(g > 0.0 && g.is_finite()) || q0 >= 1.0 - 1e-9 {
|
||||
return moved;
|
||||
}
|
||||
let before = self.local(n);
|
||||
let mut step = 0.25 * w * h;
|
||||
for _ in 0..5 {
|
||||
if self.attempt(n, freedom, scale(grad, step / g), &before, 1e-9) {
|
||||
return true;
|
||||
}
|
||||
step *= 0.5;
|
||||
}
|
||||
moved
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality over the region.
|
||||
fn region_quality(s: &State<'_>) -> RegionQuality {
|
||||
let mut r = RegionQuality { elements: s.elems.len(), min_quality: f64::INFINITY, max_edge_ratio: 0.0, invalid: 0, worst: Vec::new() };
|
||||
let mut all = Vec::with_capacity(s.elems.len());
|
||||
for e in &s.elems {
|
||||
let x = s.coords(e);
|
||||
let (q, valid) = measure(e, &x);
|
||||
r.min_quality = r.min_quality.min(q);
|
||||
r.max_edge_ratio = r.max_edge_ratio.max(edge_ratio(e.kind, &x));
|
||||
r.invalid += usize::from(!valid);
|
||||
all.push((e.label, q));
|
||||
}
|
||||
all.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
all.truncate(WORST_LISTED);
|
||||
r.worst = all;
|
||||
if r.elements == 0 {
|
||||
r.min_quality = 0.0;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Relax the nodes of `weights` (node → weight in `(0, 1]`) for `iterations` sweeps, nodes
|
||||
/// moving as `geometry` allows. The model is not changed: the result has the new positions.
|
||||
pub fn relax(model: &Model, weights: &BTreeMap<u32, f64>, iterations: usize, geometry: &dyn Constraints) -> Result<Relaxed> {
|
||||
if iterations == 0 || iterations > MAX_ITERATIONS {
|
||||
return Err(Error::Invalid(format!("iterations must be 1 to {MAX_ITERATIONS}, got {iterations}")));
|
||||
}
|
||||
if weights.len() > MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {MAX_NODES} nodes to relax")));
|
||||
}
|
||||
if let Some((n, w)) = weights.iter().find(|(_, w)| !(**w > 0.0 && **w <= 1.0)) {
|
||||
return Err(Error::Invalid(format!("node {n} has weight {w}; weights are in (0, 1]")));
|
||||
}
|
||||
let movers: BTreeSet<u32> = weights.keys().copied().collect();
|
||||
let mut state = State {
|
||||
pos: BTreeMap::new(),
|
||||
elems: Vec::new(),
|
||||
around: BTreeMap::new(),
|
||||
neighbours: BTreeMap::new(),
|
||||
mids: BTreeMap::new(),
|
||||
mids_of: BTreeMap::new(),
|
||||
skin: skin_nodes(model, &movers),
|
||||
geometry,
|
||||
};
|
||||
for (&label, e) in model.elements() {
|
||||
if !quality::is_measured(e.kind) || e.nodes.len() != e.kind.node_count() {
|
||||
continue;
|
||||
}
|
||||
let corners = e.kind.corner_count();
|
||||
if !e.nodes.iter().take(corners).any(|n| movers.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
let mut x = Vec::with_capacity(e.nodes.len());
|
||||
for &n in &e.nodes {
|
||||
let p = model.node(n).map(|n| n.xyz).ok_or_else(|| Error::Invalid(format!("element {label} refers to missing node {n}")))?;
|
||||
state.pos.insert(n, p);
|
||||
x.push(p);
|
||||
}
|
||||
let index = state.elems.len();
|
||||
for &n in e.nodes.iter().take(corners) {
|
||||
if movers.contains(&n) {
|
||||
state.around.entry(n).or_default().push(index);
|
||||
}
|
||||
}
|
||||
for edge in e.kind.edges() {
|
||||
let (Some(&a), Some(&b)) = (edge.first().and_then(|&i| e.nodes.get(i)), edge.get(1).and_then(|&i| e.nodes.get(i))) else { continue };
|
||||
for (u, v) in [(a, b), (b, a)] {
|
||||
if movers.contains(&u) {
|
||||
state.neighbours.entry(u).or_default().insert(v);
|
||||
}
|
||||
}
|
||||
if let Some(&m) = edge.get(2).and_then(|&i| e.nodes.get(i)) {
|
||||
let (pa, pb, pm) = (x.get(edge[0]).copied(), x.get(edge[1]).copied(), x.get(edge[2]).copied());
|
||||
if let (Some(pa), Some(pb), Some(pm)) = (pa, pb, pm) {
|
||||
state.mids.entry(m).or_insert((a, b, sub(pm, scale(add(pa, pb), 0.5))));
|
||||
for c in [a, b] {
|
||||
let list = state.mids_of.entry(c).or_default();
|
||||
if !list.contains(&m) {
|
||||
list.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.elems.push(Elem { label, kind: e.kind, normal: shell_normal(e.kind, &x), nodes: e.nodes.clone() });
|
||||
}
|
||||
let original = state.pos.clone();
|
||||
let before = region_quality(&state);
|
||||
let mut done = 0;
|
||||
for _ in 0..iterations {
|
||||
done += 1;
|
||||
let mut any = false;
|
||||
for (&n, &w) in weights {
|
||||
if state.around.contains_key(&n) {
|
||||
any |= state.relax_node(n, w);
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let after = region_quality(&state);
|
||||
let mut out = Relaxed { before, after, iterations: done, ..Default::default() };
|
||||
for (n, p) in &state.pos {
|
||||
let Some(o) = original.get(n) else { continue };
|
||||
let d = dist(*o, *p);
|
||||
if d > 0.0 {
|
||||
out.max_displacement = out.max_displacement.max(d);
|
||||
out.moved += usize::from(movers.contains(n));
|
||||
out.positions.insert(*n, *p);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Relaxation fields ----
|
||||
|
||||
/// The linear kind of a quadratic element's corners.
|
||||
fn linear(kind: ElementKind) -> Option<ElementKind> {
|
||||
Some(match kind {
|
||||
ElementKind::Tri3 | ElementKind::Tri6 => ElementKind::Tri3,
|
||||
ElementKind::Quad4 | ElementKind::Quad8 => ElementKind::Quad4,
|
||||
ElementKind::Tet4 | ElementKind::Tet10 => ElementKind::Tet4,
|
||||
ElementKind::Wedge6 => ElementKind::Wedge6,
|
||||
ElementKind::Hex8 | ElementKind::Hex20 => ElementKind::Hex8,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The relaxation as a field ([`fieldcraft_model::Relaxation`]'s cells): every element with a
|
||||
/// moved node, its corners as they were in `model` and as `relaxed` left them.
|
||||
pub fn field_cells(model: &Model, relaxed: &Relaxed) -> Vec<RelaxationCell> {
|
||||
let mut cells = Vec::new();
|
||||
for e in model.elements().values() {
|
||||
let Some(kind) = linear(e.kind) else { continue };
|
||||
if !e.nodes.iter().any(|n| relaxed.positions.contains_key(n)) {
|
||||
continue;
|
||||
}
|
||||
let corners: Vec<u32> = e.nodes.iter().take(kind.node_count()).copied().collect();
|
||||
let at: Vec<P> = corners.iter().filter_map(|n| model.node(*n).map(|x| x.xyz)).collect();
|
||||
if at.len() != corners.len() {
|
||||
continue;
|
||||
}
|
||||
let moved = corners.iter().zip(&at).map(|(n, a)| relaxed.positions.get(n).copied().unwrap_or(*a)).collect();
|
||||
cells.push(RelaxationCell { kind, at, moved });
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// Linear shape functions of a cell at parametric `xi`, and the domain's dimension.
|
||||
fn shape(kind: ElementKind, xi: P) -> (Vec<f64>, usize) {
|
||||
let [u, v, w] = xi;
|
||||
match kind {
|
||||
ElementKind::Tri3 => (vec![1.0 - u - v, u, v], 2),
|
||||
ElementKind::Quad4 => (vec![(1.0 - u) * (1.0 - v), u * (1.0 - v), u * v, (1.0 - u) * v], 2),
|
||||
ElementKind::Tet4 => (vec![1.0 - u - v - w, u, v, w], 3),
|
||||
ElementKind::Wedge6 => {
|
||||
let t = [1.0 - u - v, u, v];
|
||||
(t.iter().map(|a| a * (1.0 - w)).chain(t.iter().map(|a| a * w)).collect(), 3)
|
||||
}
|
||||
_ => {
|
||||
let b = [(1.0 - u) * (1.0 - v), u * (1.0 - v), u * v, (1.0 - u) * v];
|
||||
(b.iter().map(|a| a * (1.0 - w)).chain(b.iter().map(|a| a * w)).collect(), 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far `xi` lies outside the cell's parametric domain (0 inside).
|
||||
fn outside(kind: ElementKind, xi: P) -> f64 {
|
||||
let [u, v, w] = xi;
|
||||
let box01 = |x: f64| (-x).max(x - 1.0).max(0.0);
|
||||
match kind {
|
||||
ElementKind::Tri3 => (-u).max(-v).max(u + v - 1.0).max(0.0),
|
||||
ElementKind::Tet4 => (-u).max(-v).max(-w).max(u + v + w - 1.0).max(0.0),
|
||||
ElementKind::Quad4 => box01(u).max(box01(v)),
|
||||
ElementKind::Wedge6 => (-u).max(-v).max(u + v - 1.0).max(0.0).max(box01(w)),
|
||||
_ => box01(u).max(box01(v)).max(box01(w)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest point of the parametric domain to `xi`.
|
||||
fn clamp_domain(kind: ElementKind, xi: P) -> P {
|
||||
let c = |x: f64| x.clamp(0.0, 1.0);
|
||||
let simplex = |u: f64, v: f64, w: f64| {
|
||||
let (u, v, w) = (u.max(0.0), v.max(0.0), w.max(0.0));
|
||||
let s = u + v + w;
|
||||
if s > 1.0 { (u / s, v / s, w / s) } else { (u, v, w) }
|
||||
};
|
||||
match kind {
|
||||
ElementKind::Tri3 => {
|
||||
let (u, v, _) = simplex(xi[0], xi[1], 0.0);
|
||||
[u, v, 0.0]
|
||||
}
|
||||
ElementKind::Tet4 => {
|
||||
let (u, v, w) = simplex(xi[0], xi[1], xi[2]);
|
||||
[u, v, w]
|
||||
}
|
||||
ElementKind::Wedge6 => {
|
||||
let (u, v, _) = simplex(xi[0], xi[1], 0.0);
|
||||
[u, v, c(xi[2])]
|
||||
}
|
||||
ElementKind::Quad4 => [c(xi[0]), c(xi[1]), 0.0],
|
||||
_ => [c(xi[0]), c(xi[1]), c(xi[2])],
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(n: &[f64], x: &[P]) -> P {
|
||||
n.iter().zip(x).fold([0.0; 3], |s, (w, p)| add(s, scale(*p, *w)))
|
||||
}
|
||||
|
||||
/// The parametric coordinates of `x` in the cell (Gauss–Newton on the linear map; for a shell,
|
||||
/// of the nearest point of its surface), and the distance left.
|
||||
fn locate(c: &RelaxationCell, x: P) -> (P, f64) {
|
||||
let (_, dim) = shape(c.kind, [0.0; 3]);
|
||||
let mut xi = match c.kind {
|
||||
ElementKind::Tri3 | ElementKind::Tet4 => [0.25; 3],
|
||||
ElementKind::Wedge6 => [1.0 / 3.0, 1.0 / 3.0, 0.5],
|
||||
_ => [0.5; 3],
|
||||
};
|
||||
if dim == 2 {
|
||||
xi[2] = 0.0;
|
||||
}
|
||||
let map = |xi: P| combine(&shape(c.kind, xi).0, &c.at);
|
||||
for _ in 0..20 {
|
||||
let r = sub(x, map(xi));
|
||||
// The Jacobian's columns (the map is at most trilinear: a unit-step difference of the
|
||||
// two sides of `xi` is exact along each axis).
|
||||
let h = 1e-3;
|
||||
let mut j = [[0.0; 3]; 3];
|
||||
for (k, col) in j.iter_mut().enumerate().take(dim) {
|
||||
let (mut a, mut b) = (xi, xi);
|
||||
a[k] += h;
|
||||
b[k] -= h;
|
||||
*col = scale(sub(map(a), map(b)), 0.5 / h);
|
||||
}
|
||||
// Normal equations JᵀJ δ = Jᵀ r.
|
||||
let mut m = [[0.0; 3]; 3];
|
||||
let mut rhs = [0.0; 3];
|
||||
for a in 0..dim {
|
||||
rhs[a] = dot(j[a], r);
|
||||
for b in 0..dim {
|
||||
m[a][b] = dot(j[a], j[b]);
|
||||
}
|
||||
}
|
||||
if dim == 2 {
|
||||
m[2][2] = 1.0;
|
||||
}
|
||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
|
||||
if !(det.abs() > 1e-300) || !det.is_finite() {
|
||||
break;
|
||||
}
|
||||
let solve = |col: usize| {
|
||||
let mut t = m;
|
||||
for row in 0..3 {
|
||||
t[row][col] = rhs[row];
|
||||
}
|
||||
(t[0][0] * (t[1][1] * t[2][2] - t[1][2] * t[2][1]) - t[0][1] * (t[1][0] * t[2][2] - t[1][2] * t[2][0])
|
||||
+ t[0][2] * (t[1][0] * t[2][1] - t[1][1] * t[2][0]))
|
||||
/ det
|
||||
};
|
||||
let delta = [solve(0), solve(1), if dim == 3 { solve(2) } else { 0.0 }];
|
||||
xi = add(xi, delta);
|
||||
if !xi.iter().all(|v| v.is_finite()) || dot(delta, delta) < 1e-28 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(xi, dist(x, map(clamp_domain(c.kind, xi))))
|
||||
}
|
||||
|
||||
/// The cell's size: its longest corner-to-corner span.
|
||||
fn cell_size(c: &RelaxationCell) -> f64 {
|
||||
let mut s = 0.0f64;
|
||||
for a in &c.at {
|
||||
for b in &c.at {
|
||||
s = s.max(dist(*a, *b));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parametric slack accepted around a cell (a node of a finer mesh on a curved face lies off the
|
||||
/// coarse cell's flat side by its chord error).
|
||||
const SLACK: f64 = 0.25;
|
||||
|
||||
/// The field's displacement at `x`: from the cell holding it (or the nearest within the slack),
|
||||
/// with that cell's shape functions; `None` outside the field.
|
||||
pub fn field_displacement(cells: &[RelaxationCell], x: P) -> Option<P> {
|
||||
let mut best: Option<(f64, &RelaxationCell, P)> = None;
|
||||
for c in cells {
|
||||
let size = cell_size(c);
|
||||
let lo = c.at.iter().fold([f64::INFINITY; 3], |m, p| [m[0].min(p[0]), m[1].min(p[1]), m[2].min(p[2])]);
|
||||
let hi = c.at.iter().fold([f64::NEG_INFINITY; 3], |m, p| [m[0].max(p[0]), m[1].max(p[1]), m[2].max(p[2])]);
|
||||
let pad = SLACK * size;
|
||||
if (0..3).any(|k| x[k] < lo[k] - pad || x[k] > hi[k] + pad) {
|
||||
continue;
|
||||
}
|
||||
let (xi, gap) = locate(c, x);
|
||||
let badness = outside(c.kind, xi).max(gap / size.max(f64::MIN_POSITIVE));
|
||||
if badness <= SLACK && best.is_none_or(|(b, _, _)| badness < b) {
|
||||
best = Some((badness, c, xi));
|
||||
}
|
||||
}
|
||||
let (_, c, xi) = best?;
|
||||
let n = shape(c.kind, clamp_domain(c.kind, xi)).0;
|
||||
let d: Vec<P> = c.moved.iter().zip(&c.at).map(|(m, a)| sub(*m, *a)).collect();
|
||||
Some(combine(&n, &d))
|
||||
}
|
||||
|
||||
/// Apply a relaxation field to the mesh: each node of `nodes` (default every node of a measured
|
||||
/// element) inside the field moves by the interpolated displacement as `geometry` allows (on its
|
||||
/// face or edge); a move that would make a valid element invalid is undone; then one guarded
|
||||
/// smoothing sweep (strength `polish`, 0 for none) evens out what the interpolation left. The
|
||||
/// model is not changed.
|
||||
pub fn apply_field(
|
||||
model: &Model,
|
||||
cells: &[RelaxationCell],
|
||||
nodes: Option<&BTreeSet<u32>>,
|
||||
geometry: &dyn Constraints,
|
||||
polish: f64,
|
||||
) -> Result<Relaxed> {
|
||||
let candidates: BTreeSet<u32> = match nodes {
|
||||
Some(n) => n.clone(),
|
||||
None => model.elements().values().filter(|e| quality::is_measured(e.kind)).flat_map(|e| e.nodes.iter().copied()).collect(),
|
||||
};
|
||||
if candidates.len() > fieldcraft_model::MAX_NODES {
|
||||
return Err(Error::Limit("too many nodes".into()));
|
||||
}
|
||||
let skin = skin_nodes(model, &candidates);
|
||||
let mut work = model.clone();
|
||||
let mut moved: BTreeMap<u32, P> = BTreeMap::new();
|
||||
for &n in &candidates {
|
||||
let Some(x) = model.node(n).map(|p| p.xyz) else { continue };
|
||||
let Some(d) = field_displacement(cells, x) else { continue };
|
||||
if !(dot(d, d) > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let freedom = match geometry.freedom(n) {
|
||||
Some(f) => f,
|
||||
None if skin.contains(&n) => Freedom::Fixed,
|
||||
None => Freedom::Free,
|
||||
};
|
||||
let q = match freedom {
|
||||
Freedom::Fixed => None,
|
||||
Freedom::Free => Some(add(x, d)),
|
||||
Freedom::Face(f) => geometry.slide_face(f, x, d),
|
||||
Freedom::Curve(c) => geometry.slide_curve(c, x, d),
|
||||
};
|
||||
if let Some(q) = q.filter(|q| q.iter().all(|v| v.is_finite() && v.abs() <= fieldcraft_model::MAX_COORD)) {
|
||||
work.set_node(n, q)?;
|
||||
moved.insert(n, q);
|
||||
}
|
||||
}
|
||||
// Undo the moves around any element they would invalidate.
|
||||
let touched: Vec<(u32, ElementKind, Vec<u32>)> = model
|
||||
.elements()
|
||||
.iter()
|
||||
.filter(|(_, e)| quality::is_measured(e.kind) && e.nodes.iter().any(|n| moved.contains_key(n)))
|
||||
.map(|(l, e)| (*l, e.kind, e.nodes.clone()))
|
||||
.collect();
|
||||
let at = |m: &Model, nodes: &[u32]| -> Vec<P> { nodes.iter().map(|n| m.node(*n).map_or([0.0; 3], |p| p.xyz)).collect() };
|
||||
for _ in 0..10 {
|
||||
let mut reverted = false;
|
||||
for (label, kind, nodes) in &touched {
|
||||
let x0 = at(model, nodes);
|
||||
let e = Elem { label: *label, kind: *kind, nodes: nodes.clone(), normal: shell_normal(*kind, &x0) };
|
||||
if measure(&e, &x0).1 && !measure(&e, &at(&work, nodes)).1 {
|
||||
for n in nodes {
|
||||
if moved.remove(n).is_some()
|
||||
&& let Some(p) = model.node(*n)
|
||||
{
|
||||
work.set_node(*n, p.xyz)?;
|
||||
reverted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reverted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut out = Relaxed::default();
|
||||
let corners = corner_nodes(model);
|
||||
if polish > 0.0 {
|
||||
let weights = uniform_weights(&work, &moved.keys().copied().filter(|n| corners.contains(n)).collect(), polish.min(1.0))?;
|
||||
if !weights.is_empty() {
|
||||
let r = relax(&work, &weights, 1, geometry)?;
|
||||
for (n, p) in &r.positions {
|
||||
work.set_node(*n, *p)?;
|
||||
moved.insert(*n, *p);
|
||||
}
|
||||
out.after = r.after;
|
||||
}
|
||||
}
|
||||
for (n, p) in moved {
|
||||
let Some(o) = model.node(n) else { continue };
|
||||
let d = dist(o.xyz, p);
|
||||
if d > 0.0 {
|
||||
out.max_displacement = out.max_displacement.max(d);
|
||||
out.moved += usize::from(corners.contains(&n));
|
||||
out.positions.insert(n, p);
|
||||
}
|
||||
}
|
||||
out.iterations = 1;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,344 @@
|
||||
use super::*;
|
||||
use crate::tet::geometry::AnalyticSurface;
|
||||
|
||||
const R_IN: f64 = 1.0;
|
||||
const R_OUT: f64 = 2.0;
|
||||
const HEIGHT: f64 = 1.0;
|
||||
|
||||
/// A quarter annulus (r 1..2, θ 0..90°, z 0..1) on its analytic faces: 0 the outer cylinder, 1
|
||||
/// the inner, 2 and 3 the planes z = 0 and z = 1, 4 and 5 the planes θ = 0 and θ = 90°.
|
||||
struct Annulus;
|
||||
|
||||
impl Annulus {
|
||||
fn surface(f: u32) -> AnalyticSurface {
|
||||
match f {
|
||||
0 => AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: R_OUT },
|
||||
1 => AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: R_IN },
|
||||
2 => AnalyticSurface::Plane { normal: [0.0, 0.0, -1.0], point: [0.0; 3] },
|
||||
3 => AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, HEIGHT] },
|
||||
4 => AnalyticSurface::Plane { normal: [0.0, -1.0, 0.0], point: [0.0; 3] },
|
||||
_ => AnalyticSurface::Plane { normal: [-1.0, 0.0, 0.0], point: [0.0; 3] },
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces a point lies on.
|
||||
fn faces(p: P) -> Vec<u32> {
|
||||
let r = (p[0] * p[0] + p[1] * p[1]).sqrt();
|
||||
let tol = 1e-9;
|
||||
let mut f = Vec::new();
|
||||
for (i, on) in
|
||||
[(r - R_OUT).abs() < tol, (r - R_IN).abs() < tol, p[2].abs() < tol, (p[2] - HEIGHT).abs() < tol, p[1].abs() < tol, p[0].abs() < tol]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if on {
|
||||
f.push(i as u32);
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn on(p: P, faces: &[u32]) -> bool {
|
||||
faces.iter().all(|&f| Annulus::surface(f).project(p).is_some_and(|q| dist(p, q) < 1e-9))
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves are encoded as `a * 8 + b`.
|
||||
struct Geo {
|
||||
model: Model,
|
||||
}
|
||||
|
||||
impl Constraints for Geo {
|
||||
fn freedom(&self, node: u32) -> Option<Freedom> {
|
||||
let p = self.model.node(node)?.xyz;
|
||||
let f = Annulus::faces(p);
|
||||
Some(match f.as_slice() {
|
||||
[] => Freedom::Free,
|
||||
[a] => Freedom::Face(*a),
|
||||
[a, b] => Freedom::Curve(a * 8 + b),
|
||||
_ => Freedom::Fixed,
|
||||
})
|
||||
}
|
||||
fn slide_face(&self, face: u32, p: P, d: P) -> Option<P> {
|
||||
Annulus::surface(face).slide(p, d)
|
||||
}
|
||||
fn slide_curve(&self, curve: u32, p: P, d: P) -> Option<P> {
|
||||
let (a, b) = (Annulus::surface(curve / 8), Annulus::surface(curve % 8));
|
||||
let mut q = add(p, d);
|
||||
for _ in 0..100 {
|
||||
q = b.project(a.project(q)?)?;
|
||||
}
|
||||
Some(q)
|
||||
}
|
||||
}
|
||||
|
||||
fn polar(r: f64, t: f64, z: f64) -> P {
|
||||
let (s, c) = crate::tet::geometry::sin_cos(t);
|
||||
[r * c, r * s, z]
|
||||
}
|
||||
|
||||
/// A hex8 mesh of the annulus with `n` divisions each way; node label `1 + i + (n+1)(j + (n+1)k)`.
|
||||
fn hex_annulus(n: usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
let id = |i: usize, j: usize, k: usize| (1 + i + (n + 1) * (j + (n + 1) * k)) as u32;
|
||||
for k in 0..=n {
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let p = polar(
|
||||
R_IN + (R_OUT - R_IN) * i as f64 / n as f64,
|
||||
std::f64::consts::FRAC_PI_2 * j as f64 / n as f64,
|
||||
HEIGHT * k as f64 / n as f64,
|
||||
);
|
||||
m.set_node(id(i, j, k), p).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut label = 1;
|
||||
for k in 0..n {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let nodes = vec![
|
||||
id(i, j, k),
|
||||
id(i + 1, j, k),
|
||||
id(i + 1, j + 1, k),
|
||||
id(i, j + 1, k),
|
||||
id(i, j, k + 1),
|
||||
id(i + 1, j, k + 1),
|
||||
id(i + 1, j + 1, k + 1),
|
||||
id(i, j + 1, k + 1),
|
||||
];
|
||||
m.set_element(label, ElementKind::Hex8, nodes).unwrap();
|
||||
label += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Move some nodes near `centre`, keeping each on its faces.
|
||||
fn perturb(m: &mut Model, centre: P, radius: f64, amount: f64) -> BTreeSet<u32> {
|
||||
let geo = Geo { model: m.clone() };
|
||||
let mut moved = BTreeSet::new();
|
||||
let labels: Vec<u32> = m.nodes().keys().copied().collect();
|
||||
for (k, n) in labels.into_iter().enumerate() {
|
||||
let p = m.node(n).unwrap().xyz;
|
||||
if dist(p, centre) > radius {
|
||||
continue;
|
||||
}
|
||||
let d = [amount * ((k * 7 % 5) as f64 - 2.0) / 2.0, amount * ((k * 3 % 5) as f64 - 2.0) / 2.0, amount * ((k * 11 % 5) as f64 - 2.0) / 2.0];
|
||||
let q = match geo.freedom(n).unwrap() {
|
||||
Freedom::Free => Some(add(p, d)),
|
||||
Freedom::Face(f) => geo.slide_face(f, p, d),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(q) = q {
|
||||
m.set_node(n, q).unwrap();
|
||||
moved.insert(n);
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
fn region_min(m: &Model, labels: &[u32]) -> (f64, usize) {
|
||||
let mut worst = f64::INFINITY;
|
||||
let mut invalid = 0;
|
||||
for l in labels {
|
||||
let e = m.element(*l).unwrap();
|
||||
let x: Vec<P> = e.nodes.iter().map(|n| m.node(*n).unwrap().xyz).collect();
|
||||
let el = Elem { label: *l, kind: e.kind, nodes: e.nodes.clone(), normal: None };
|
||||
let (q, v) = measure(&el, &x);
|
||||
worst = worst.min(q);
|
||||
invalid += usize::from(!v);
|
||||
}
|
||||
(worst, invalid)
|
||||
}
|
||||
|
||||
fn applied(m: &Model, r: &Relaxed) -> Model {
|
||||
let mut out = m.clone();
|
||||
for (n, p) in &r.positions {
|
||||
out.set_node(*n, *p).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const CENTRE: P = [1.414, 1.414, 0.5];
|
||||
|
||||
/// The issue's test: a perturbed hex mesh of a cylindrical part relaxes inside the sphere — every
|
||||
/// surface node on its face, quality up, nothing inverted, nothing outside the sphere moved.
|
||||
#[test]
|
||||
fn hex_spikes_relax_with_surface_nodes_on_the_cylinder() {
|
||||
let mut m = hex_annulus(6);
|
||||
let original = m.clone();
|
||||
perturb(&mut m, CENTRE, 0.5, 0.08);
|
||||
let elements: Vec<u32> = m.elements().keys().copied().collect();
|
||||
let (before, _) = region_min(&m, &elements);
|
||||
let weights = sphere_weights(&m, CENTRE, 0.7, 1.0, Falloff::Smooth, None).unwrap();
|
||||
let geo = Geo { model: original.clone() };
|
||||
let r = relax(&m, &weights, 10, &geo).unwrap();
|
||||
assert!(r.moved > 0 && r.after.min_quality > r.before.min_quality, "{:?} → {:?}", r.before, r.after);
|
||||
assert_eq!(r.after.invalid, 0);
|
||||
let out = applied(&m, &r);
|
||||
let (after, invalid) = region_min(&out, &elements);
|
||||
assert!(after > before + 0.02 && invalid == 0, "{before} → {after}");
|
||||
for (n, p) in &r.positions {
|
||||
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.7, "node {n} outside the sphere moved");
|
||||
let faces = Annulus::faces(original.node(*n).unwrap().xyz);
|
||||
assert!(Annulus::on(*p, &faces), "node {n} left its faces {faces:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// tet10 with curved mid-side nodes on the cylinder: after relaxing, corner and mid-side surface
|
||||
/// nodes are still on the surface and no element is invalid.
|
||||
#[test]
|
||||
fn tet10_mid_side_nodes_follow_on_the_geometry() {
|
||||
let hex = hex_annulus(4);
|
||||
let mut m = Model::default();
|
||||
for (l, n) in hex.nodes() {
|
||||
m.set_node(*l, n.xyz).unwrap();
|
||||
}
|
||||
// Six tetrahedra per hexahedron around its 1-7 diagonal (Kuhn), consistent across faces.
|
||||
const KUHN: [[usize; 4]; 6] = [[0, 1, 2, 6], [0, 2, 3, 6], [0, 3, 7, 6], [0, 7, 4, 6], [0, 4, 5, 6], [0, 5, 1, 6]];
|
||||
let mut mids: BTreeMap<(u32, u32), u32> = BTreeMap::new();
|
||||
let mut next = 100_000;
|
||||
let mut label = 1;
|
||||
for e in hex.elements().values() {
|
||||
for t in KUHN {
|
||||
let mut c: Vec<u32> = t.iter().map(|&i| e.nodes[i]).collect();
|
||||
let x: Vec<P> = c.iter().map(|n| m.node(*n).unwrap().xyz).collect();
|
||||
if dot(cross(sub(x[1], x[0]), sub(x[2], x[0])), sub(x[3], x[0])) < 0.0 {
|
||||
c.swap(1, 2);
|
||||
}
|
||||
let mut nodes = c.clone();
|
||||
for [a, b] in [[0, 1], [1, 2], [2, 0], [0, 3], [1, 3], [2, 3]] {
|
||||
let key = (c[a].min(c[b]), c[a].max(c[b]));
|
||||
let mid = *mids.entry(key).or_insert_with(|| {
|
||||
let (pa, pb) = (m.node(key.0).unwrap().xyz, m.node(key.1).unwrap().xyz);
|
||||
let mut p = scale(add(pa, pb), 0.5);
|
||||
let shared: Vec<u32> = Annulus::faces(pa).into_iter().filter(|f| Annulus::faces(pb).contains(f)).collect();
|
||||
if let [f] = shared.as_slice() {
|
||||
p = Annulus::surface(*f).project(p).unwrap();
|
||||
}
|
||||
next += 1;
|
||||
m.set_node(next, p).unwrap();
|
||||
next
|
||||
});
|
||||
nodes.push(mid);
|
||||
}
|
||||
m.set_element(label, ElementKind::Tet10, nodes).unwrap();
|
||||
label += 1;
|
||||
}
|
||||
}
|
||||
let original = m.clone();
|
||||
// Perturb the corners only, then put the mid-side nodes at their edges' (curved) middles.
|
||||
let corners = corner_nodes(&m);
|
||||
perturb(&mut m, CENTRE, 0.6, 0.06);
|
||||
for (&(a, b), &mid) in &mids {
|
||||
let p = scale(add(m.node(a).unwrap().xyz, m.node(b).unwrap().xyz), 0.5);
|
||||
let shared: Vec<u32> =
|
||||
Annulus::faces(original.node(a).unwrap().xyz).into_iter().filter(|f| Annulus::faces(original.node(b).unwrap().xyz).contains(f)).collect();
|
||||
let p = if let [f] = shared.as_slice() { Annulus::surface(*f).project(p).unwrap() } else { p };
|
||||
m.set_node(mid, p).unwrap();
|
||||
}
|
||||
let _ = corners;
|
||||
let weights = sphere_weights(&m, CENTRE, 0.8, 1.0, Falloff::Smooth, None).unwrap();
|
||||
let r = relax(&m, &weights, 8, &Geo { model: original.clone() }).unwrap();
|
||||
assert!(r.after.min_quality >= r.before.min_quality && r.after.invalid == 0, "{:?} → {:?}", r.before, r.after);
|
||||
assert!(r.after.min_quality > r.before.min_quality + 0.01, "{:?} → {:?}", r.before, r.after);
|
||||
let mut mid_on_surface = 0;
|
||||
for (n, p) in &r.positions {
|
||||
let faces = Annulus::faces(original.node(*n).unwrap().xyz);
|
||||
assert!(Annulus::on(*p, &faces), "node {n} left {faces:?}");
|
||||
mid_on_surface += usize::from(*n > 100_000 && faces.contains(&0));
|
||||
}
|
||||
assert!(mid_on_surface > 0, "some curved mid-side nodes moved on the cylinder");
|
||||
}
|
||||
|
||||
/// A cut between two cells is not geometry: its shared nodes move freely, and both cells keep
|
||||
/// them (conforming).
|
||||
#[test]
|
||||
fn partition_cut_nodes_move_freely_and_stay_shared() {
|
||||
let mut m = hex_annulus(6);
|
||||
let original = m.clone();
|
||||
perturb(&mut m, CENTRE, 0.5, 0.08);
|
||||
// The cut: the plane θ = 45° through the middle row of nodes (j = 3) — reported free.
|
||||
struct Cut(Geo);
|
||||
impl Constraints for Cut {
|
||||
fn freedom(&self, node: u32) -> Option<Freedom> {
|
||||
let f = self.0.freedom(node)?;
|
||||
Some(if f == Freedom::Free { Freedom::Free } else { f })
|
||||
}
|
||||
fn slide_face(&self, f: u32, p: P, d: P) -> Option<P> {
|
||||
self.0.slide_face(f, p, d)
|
||||
}
|
||||
fn slide_curve(&self, c: u32, p: P, d: P) -> Option<P> {
|
||||
self.0.slide_curve(c, p, d)
|
||||
}
|
||||
}
|
||||
let weights = sphere_weights(&m, CENTRE, 0.7, 1.0, Falloff::Smooth, None).unwrap();
|
||||
let r = relax(&m, &weights, 10, &Cut(Geo { model: original.clone() })).unwrap();
|
||||
let on_cut = |p: P| (p[0] - p[1]).abs() < 1e-9;
|
||||
let cut_moved = r.positions.iter().filter(|(n, p)| on_cut(original.node(**n).unwrap().xyz) && !on_cut(**p)).count();
|
||||
assert!(cut_moved > 0, "cut nodes are free to leave the cut plane");
|
||||
assert_eq!(r.after.invalid, 0);
|
||||
}
|
||||
|
||||
/// The issue's field test: relax a coarse mesh, mesh 2× finer, apply the field: the fine mesh
|
||||
/// takes the relaxed shape (its nodes where the relaxed coarse mesh interpolates them) and
|
||||
/// nothing inverts.
|
||||
#[test]
|
||||
fn a_relaxation_field_carries_over_to_a_finer_mesh() {
|
||||
let mut coarse = hex_annulus(4);
|
||||
let original = coarse.clone();
|
||||
// A deliberate interior shape change: pull the interior nodes toward the centre.
|
||||
for (n, p) in original.nodes() {
|
||||
if Annulus::faces(p.xyz).is_empty() && dist(p.xyz, CENTRE) < 0.6 {
|
||||
coarse.set_node(*n, add(p.xyz, [0.03, -0.02, 0.04])).unwrap();
|
||||
}
|
||||
}
|
||||
let weights = sphere_weights(&coarse, CENTRE, 0.9, 1.0, Falloff::Smooth, None).unwrap();
|
||||
let r = relax(&coarse, &weights, 5, &Geo { model: coarse.clone() }).unwrap();
|
||||
// The field is the change from the original coarse mesh to the relaxed one.
|
||||
let relaxed = applied(&coarse, &r);
|
||||
let total = Relaxed {
|
||||
positions: relaxed.nodes().iter().filter(|(n, p)| original.node(**n).unwrap().xyz != p.xyz).map(|(n, p)| (*n, p.xyz)).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let cells = field_cells(&original, &total);
|
||||
assert!(!cells.is_empty());
|
||||
let fine = hex_annulus(8);
|
||||
let out = apply_field(&fine, &cells, None, &Geo { model: fine.clone() }, 0.0).unwrap();
|
||||
assert!(out.moved > 0);
|
||||
let result = applied(&fine, &out);
|
||||
let elements: Vec<u32> = fine.elements().keys().copied().collect();
|
||||
assert_eq!(region_min(&result, &elements).1, 0, "nothing inverts");
|
||||
for (n, p) in fine.nodes() {
|
||||
let d = field_displacement(&cells, p.xyz).unwrap_or([0.0; 3]);
|
||||
let got = result.node(*n).unwrap().xyz;
|
||||
if Annulus::faces(p.xyz).is_empty() {
|
||||
assert!(dist(got, add(p.xyz, d)) < 1e-9, "node {n}: {got:?} vs {:?}", add(p.xyz, d));
|
||||
} else {
|
||||
assert!(Annulus::on(got, &Annulus::faces(p.xyz)), "node {n} stays on its faces");
|
||||
}
|
||||
}
|
||||
// A fine node at a coarse node takes exactly that node's displacement.
|
||||
let coarse_node = original.nodes().iter().find(|(n, p)| r.positions.contains_key(n) && Annulus::faces(p.xyz).is_empty()).unwrap();
|
||||
let fine_twin = fine.nodes().values().find(|p| dist(p.xyz, coarse_node.1.xyz) < 1e-9).unwrap();
|
||||
let d = field_displacement(&cells, fine_twin.xyz).unwrap();
|
||||
assert!(dist(add(fine_twin.xyz, d), relaxed.node(*coarse_node.0).unwrap().xyz) < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_are_refused() {
|
||||
let m = hex_annulus(2);
|
||||
assert!(sphere_weights(&m, [f64::NAN, 0.0, 0.0], 1.0, 1.0, Falloff::Smooth, None).is_err());
|
||||
assert!(sphere_weights(&m, CENTRE, -1.0, 1.0, Falloff::Smooth, None).is_err());
|
||||
assert!(sphere_weights(&m, CENTRE, 1.0, 1.5, Falloff::Smooth, None).is_err());
|
||||
let w = sphere_weights(&m, CENTRE, 1.0, 1.0, Falloff::Smooth, None).unwrap();
|
||||
assert!(relax(&m, &w, 0, &NoGeometry).is_err());
|
||||
assert!(relax(&m, &w, MAX_ITERATIONS + 1, &NoGeometry).is_err());
|
||||
let bad: BTreeMap<u32, f64> = [(1, f64::NAN)].into();
|
||||
assert!(relax(&m, &bad, 1, &NoGeometry).is_err());
|
||||
// Without geometry, skin nodes stay put.
|
||||
let r = relax(&m, &w, 3, &NoGeometry).unwrap();
|
||||
assert!(r.positions.is_empty() || r.positions.keys().all(|n| Annulus::faces(m.node(*n).unwrap().xyz).is_empty()));
|
||||
}
|
||||
@@ -154,6 +154,59 @@ pub(crate) fn place(poly: &[P], fractions: &[f64]) -> Result<Vec<P>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A use of a curve: its index and whether it is traversed from its start.
|
||||
pub(crate) type Use = (usize, bool);
|
||||
|
||||
/// The spacing of biased seeds carried to the curves interval assignment ties to them (#257).
|
||||
///
|
||||
/// `own[c]` is curve `c`'s seed (any identity `T`) and whether the curve runs against it; each
|
||||
/// tie says two curve uses run side by side with the same count (opposite sides of a mapped face
|
||||
/// or a sweep's linking face, both traversed the same way along it). A biased seed's spacing
|
||||
/// spreads along the ties to every curve without a seed of its own, which it reaches through
|
||||
/// unseeded curves only; the curve takes the seed, oriented as the tie gives, so its nodes are
|
||||
/// placed as the seeded curve's are. Seeds are spread in curve order and the first one to reach a
|
||||
/// curve keeps it. Returns the carried seed of each unseeded curve (`None` elsewhere).
|
||||
pub(crate) fn carry<T: Clone>(own: &[Option<(T, bool)>], biased: impl Fn(&T) -> bool, ties: &[[Use; 2]]) -> Vec<Option<(T, bool)>> {
|
||||
let n = own.len();
|
||||
let mut next: Vec<Vec<(usize, bool, bool)>> = vec![Vec::new(); n];
|
||||
for &[(a, fa), (b, fb)] in ties {
|
||||
if a == b || a >= n || b >= n {
|
||||
continue;
|
||||
}
|
||||
// Traversed as tied, `a` runs forwards iff `fa`; `b`'s orientation follows from `a`'s.
|
||||
if let Some(l) = next.get_mut(a) {
|
||||
l.push((b, fa, fb));
|
||||
}
|
||||
if let Some(l) = next.get_mut(b) {
|
||||
l.push((a, fb, fa));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Option<(T, bool)>> = vec![None; n];
|
||||
for (start, seed) in own.iter().enumerate() {
|
||||
let Some((seed, rev)) = seed else { continue };
|
||||
if !biased(seed) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(start, *rev)];
|
||||
while let Some((c, rev_c)) = stack.pop() {
|
||||
for &(d, fc, fd) in next.get(c).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
if own.get(d).is_some_and(Option::is_some) || out.get(d).is_some_and(Option::is_some) {
|
||||
continue;
|
||||
}
|
||||
// Traversed as tied, `c` runs against the seed iff it runs forwards and against
|
||||
// it, or backwards and with it; `d` runs the same way as `c`.
|
||||
let against = rev_c == fc;
|
||||
let rev_d = if against { fd } else { !fd };
|
||||
if let Some(slot) = out.get_mut(d) {
|
||||
*slot = Some((seed.clone(), rev_d));
|
||||
}
|
||||
stack.push((d, rev_d));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The element size a seed asks for at each of its nodes (the mean length of the elements on
|
||||
/// either side), for size fields.
|
||||
pub fn node_sizes(seed: &EdgeSeed) -> Vec<(P, f64)> {
|
||||
@@ -211,4 +264,19 @@ mod tests {
|
||||
assert_eq!(h.len(), 4);
|
||||
assert!((h[0].1 - 10.0 * f[1]).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A biased seed's spacing carries along the ties to unseeded curves, oriented as each tie
|
||||
/// says, and stops at a curve with a seed of its own (#257).
|
||||
#[test]
|
||||
fn biased_spacing_carries_along_ties() {
|
||||
// 0 runs against its seed; 0 forwards beside 1 backwards; 1 forwards beside 2 forwards;
|
||||
// 3 has its own seed and 4 lies beyond it.
|
||||
let own = vec![Some((0, true)), None, None, Some((1, false)), None, None];
|
||||
let ties = [[(0, true), (1, false)], [(1, true), (2, true)], [(2, true), (3, true)], [(3, true), (4, true)]];
|
||||
let biased = [true, true];
|
||||
let c = carry(&own, |&i: &usize| biased[i], &ties);
|
||||
assert_eq!(c, vec![None, Some((0, false)), Some((0, false)), None, Some((1, false)), None]);
|
||||
// Unbiased seeds keep to their own curve.
|
||||
assert!(carry(&own, |_: &usize| false, &ties).iter().all(Option::is_none));
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,76 @@ impl AnalyticSurface {
|
||||
out.iter().all(|x| x.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// Move a point of the surface by the displacement `d` in the surface's own parameters (UV):
|
||||
/// `p` is first put on the surface, `d` is resolved into the parameter steps along the
|
||||
/// surface's coordinate directions there (the tangential part of `d`; the normal part is
|
||||
/// dropped), and the surface is evaluated at the new parameters, so the result lies exactly
|
||||
/// on the surface. Parameters are local to `p` (angles measured from it), so the chart has no
|
||||
/// seam. A plane translates in its own plane; a cylinder, cone and torus turn about their
|
||||
/// axis (and a torus round its tube) by the arc length over the radius; a sphere follows the
|
||||
/// great circle in the direction of `d`. Angular steps are kept within half a turn. `None`
|
||||
/// where the parameters are singular (on an axis, at an apex) or the data is not finite.
|
||||
pub fn slide(&self, p: P, d: P) -> Option<P> {
|
||||
let q = self.project(p)?;
|
||||
let rotate = |r: P, t: P, angle: f64| {
|
||||
let (s, c) = sin_cos(angle.clamp(-std::f64::consts::PI, std::f64::consts::PI));
|
||||
add(scale(r, c), scale(t, s))
|
||||
};
|
||||
let out = match *self {
|
||||
AnalyticSurface::Plane { normal, .. } => {
|
||||
let n = unit(normal)?;
|
||||
add(q, sub(d, scale(n, dot(d, n))))
|
||||
}
|
||||
AnalyticSurface::Cylinder { origin, axis, radius } => {
|
||||
let a = unit(axis)?;
|
||||
let foot = add(origin, scale(a, dot(sub(q, origin), a)));
|
||||
let r = unit(sub(q, foot))?;
|
||||
let t = cross(a, r);
|
||||
let radial = rotate(r, t, dot(d, t) / radius);
|
||||
add(add(foot, scale(a, dot(d, a))), scale(radial, radius))
|
||||
}
|
||||
AnalyticSurface::Sphere { center, radius } => {
|
||||
let r = unit(sub(q, center))?;
|
||||
let tangent = sub(d, scale(r, dot(d, r)));
|
||||
let Some(t) = unit(tangent) else { return Some(q) };
|
||||
add(center, scale(rotate(r, t, norm(tangent) / radius), radius))
|
||||
}
|
||||
AnalyticSurface::Cone { apex, axis, half_angle } => {
|
||||
let a = unit(axis)?;
|
||||
let g0 = sub(q, apex);
|
||||
let along = norm(g0);
|
||||
let g = unit(g0)?;
|
||||
let r = unit(sub(g0, scale(a, dot(g0, a))))?;
|
||||
let t = cross(a, r);
|
||||
let rho = norm(sub(g0, scale(a, dot(g0, a))));
|
||||
let s = along + dot(d, g);
|
||||
if !(s > 0.0 && rho > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let r2 = rotate(r, t, dot(d, t) / rho);
|
||||
let (sa, ca) = sin_cos(half_angle);
|
||||
add(apex, scale(add(scale(a, ca), scale(r2, sa)), s))
|
||||
}
|
||||
AnalyticSurface::Torus { center, axis, major, minor } => {
|
||||
let a = unit(axis)?;
|
||||
let c = sub(q, center);
|
||||
let radial = sub(c, scale(a, dot(c, a)));
|
||||
let rho = norm(radial);
|
||||
let r = unit(radial)?;
|
||||
let ring = add(center, scale(r, major));
|
||||
let m = unit(sub(q, ring))?;
|
||||
let around = cross(a, r);
|
||||
let tube = cross(around, m);
|
||||
// Round the tube, then about the axis.
|
||||
let m2 = rotate(m, tube, dot(d, tube) / minor);
|
||||
let r2 = rotate(r, around, dot(d, around) / rho.max(f64::MIN_POSITIVE));
|
||||
let m3 = add(scale(r2, dot(m2, r)), scale(a, dot(m2, a)));
|
||||
add(add(center, scale(r2, major)), scale(m3, minor))
|
||||
}
|
||||
};
|
||||
out.iter().all(|x| x.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// The unit normal (up to sign) at a point on the surface, if defined there.
|
||||
pub fn normal_at(&self, q: P) -> Option<P> {
|
||||
let n = match *self {
|
||||
@@ -473,6 +543,31 @@ impl BoundaryGeometry for Tessellation {
|
||||
}
|
||||
}
|
||||
|
||||
impl Tessellation {
|
||||
/// Move a point of face `face` by the displacement `d` and keep it on the face: through the
|
||||
/// face's own parameters ([`AnalyticSurface::slide`]) when its exact surface is known and the
|
||||
/// result stays near the face's triangles (not on another part of the surface), else by
|
||||
/// projecting `p + d` onto the face's triangles (free-form faces: accurate to the
|
||||
/// tessellation's chordal tolerance, as [`BoundaryGeometry::project_face`]).
|
||||
pub fn slide_face(&self, face: u32, p: P, d: P) -> Option<P> {
|
||||
let target = add(p, d);
|
||||
let (near, scale_len) = self.on_tessellation(face, target)?;
|
||||
if let Some(q) = self.analytic(face).and_then(|s| s.slide(p, d)) {
|
||||
let tol = (0.5 * scale_len).max(2.0 * measure::dist(target, near));
|
||||
if measure::dist(q, near) <= tol {
|
||||
return Some(q);
|
||||
}
|
||||
}
|
||||
self.project_face(face, target)
|
||||
}
|
||||
|
||||
/// Move a point of the feature curve between faces `faces` by `d` and keep it on the curve
|
||||
/// (the curve's closest point to `p + d`: the move along the curve's parameter).
|
||||
pub fn slide_curve(&self, faces: [u32; 2], p: P, d: P) -> Option<P> {
|
||||
self.project_curve(faces, add(p, d))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tessellation {
|
||||
/// The local thickness at each vertex: the distance to the nearest triangle facing it from
|
||||
/// across the solid (a triangle that does not touch the vertex and whose outward normal
|
||||
@@ -737,4 +832,36 @@ mod tests {
|
||||
assert_eq!(far, 10.0);
|
||||
assert!(f.size([f64::NAN, 0.0, 0.0]) > 0.0);
|
||||
}
|
||||
|
||||
/// A slide in UV keeps the point exactly on every kind of surface, moves it by the tangential
|
||||
/// part of the displacement (to first order), and turns a cylinder point by arc / radius.
|
||||
#[test]
|
||||
fn slides_stay_on_the_surface() {
|
||||
let surfaces = [
|
||||
AnalyticSurface::Plane { normal: [0.0, 0.0, 1.0], point: [0.0, 0.0, 2.0] },
|
||||
AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: 3.0 },
|
||||
AnalyticSurface::Sphere { center: [1.0, 0.0, 0.0], radius: 2.0 },
|
||||
AnalyticSurface::Cone { apex: [0.0; 3], axis: [0.0, 0.0, 1.0], half_angle: 0.4 },
|
||||
AnalyticSurface::Torus { center: [0.0; 3], axis: [0.0, 0.0, 1.0], major: 5.0, minor: 1.0 },
|
||||
];
|
||||
let probe = [2.5, 1.5, 2.2];
|
||||
for s in surfaces {
|
||||
let q = s.project(probe).unwrap();
|
||||
for d in [[0.01, 0.0, 0.0], [0.0, 0.02, 0.0], [0.0, 0.0, -0.015], [0.3, -0.2, 0.1]] {
|
||||
let moved = s.slide(q, d).unwrap();
|
||||
let back = s.project(moved).unwrap();
|
||||
assert!(measure::dist(moved, back) < 1e-12, "{s:?} {d:?}: {moved:?} off by {}", measure::dist(moved, back));
|
||||
let n = s.normal_at(q).unwrap();
|
||||
let tangential = sub(d, scale(n, dot(d, n)));
|
||||
if norm(d) < 0.05 {
|
||||
assert!(measure::dist(moved, add(q, tangential)) < 0.05 * norm(d) + 1e-12, "{s:?} {d:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let cyl = AnalyticSurface::Cylinder { origin: [0.0; 3], axis: [0.0, 0.0, 1.0], radius: 2.0 };
|
||||
let q = cyl.slide([2.0, 0.0, 1.0], [0.0, std::f64::consts::PI, 0.5]).unwrap();
|
||||
assert!(measure::dist(q, [0.0, 2.0, 1.5]) < 1e-12, "{q:?}");
|
||||
// On the axis the parameters are singular.
|
||||
assert!(cyl.slide([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use crate::predicates::Point3;
|
||||
use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, face_toward, sorted4};
|
||||
use super::measure::{self, P};
|
||||
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
|
||||
use super::refine::{Context, MAX_CAVITY};
|
||||
use super::refine::{Context, MAX_CAVITY, MIN_GAP};
|
||||
use super::smooth::smooth_vertex;
|
||||
|
||||
/// Settings of the schedule.
|
||||
@@ -293,7 +293,11 @@ fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKin
|
||||
cx.journal.recording = true;
|
||||
let len = cx.journal.steps.len();
|
||||
let kept = (|| -> Result<bool> {
|
||||
let Some(v) = ops::insert_delaunay(cx, p, kind, site, MAX_CAVITY)? else { return Ok(false) };
|
||||
// Refused within MIN_GAP of the local size of an existing vertex, as in refinement: a
|
||||
// tetrahedron that stays below the target would otherwise draw an insertion every pass
|
||||
// and pile vertices around one of its own, whose star then grows without bound (#311).
|
||||
let gap = MIN_GAP * ctx.h(p.coords(), 0.0);
|
||||
let Some(v) = ops::insert_delaunay_apart(cx, p, kind, site, MAX_CAVITY, gap)? else { return Ok(false) };
|
||||
for _ in 0..4 {
|
||||
smooth_vertex(cx, ctx, v)?;
|
||||
if !cx.is_used(v) {
|
||||
@@ -458,6 +462,9 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
|
||||
break;
|
||||
}
|
||||
stats.passes += 1;
|
||||
fieldcraft_model::progress::stage("Improvement pass", Some(o.max_passes as f64), "");
|
||||
fieldcraft_model::progress::count(stats.passes as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let before = match carried.take() {
|
||||
Some(b) => b,
|
||||
None => indicators(cx)?,
|
||||
@@ -499,3 +506,54 @@ pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cdt::tests::{surface, uv_sphere};
|
||||
use crate::cdt::{CdtOptions, tetrahedralize};
|
||||
use crate::size::UniformSize;
|
||||
use crate::surface::{BoundarySurface, SurfaceOptions};
|
||||
use crate::tet::geometry::Tessellation;
|
||||
use crate::tet::{QualityOptions, complex_from_cdt};
|
||||
|
||||
/// An insertion closer than `MIN_GAP` × the local size to an existing vertex is refused,
|
||||
/// whatever it would do to the quality (#311): a stubborn tetrahedron must not draw vertex
|
||||
/// after vertex onto one of its own.
|
||||
#[test]
|
||||
fn an_insertion_beside_an_existing_vertex_is_refused() {
|
||||
const H: f64 = 100.0;
|
||||
let (p, t) = uv_sphere(6, 10);
|
||||
let p: Vec<[f64; 3]> = p.iter().map(|c| c.map(|x| 5.0 * x)).collect();
|
||||
let s = surface(&p, &t, [0.0; 3]);
|
||||
let faces = vec![0u32; s.triangles().len()];
|
||||
let s = BoundarySurface::new(s.points(), s.triangles(), &faces, &SurfaceOptions::default()).unwrap();
|
||||
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
||||
let (mut cx, curve_faces) = complex_from_cdt(&s, &cdt, &QualityOptions::default()).unwrap();
|
||||
let (geometry, size) = (Tessellation::new(&s, Vec::new()), UniformSize(H));
|
||||
let ctx = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces };
|
||||
let (points, live) = (cx.point_count(), cx.live_count());
|
||||
let mut tried = 0;
|
||||
for (t, vs) in cx.live_tets().collect::<Vec<_>>() {
|
||||
if !cx.is_live(t) {
|
||||
continue;
|
||||
}
|
||||
// A point half the gap from each vertex toward the centroid: unguarded, the coarse
|
||||
// sphere keeps some of these.
|
||||
let g = measure::centroid(&cx.coords(vs).unwrap());
|
||||
for v in vs {
|
||||
let x = cx.xyz(v).unwrap();
|
||||
let d = measure::sub(g, x);
|
||||
let q = measure::add(x, measure::scale(d, 0.5 * MIN_GAP * H / measure::norm(d)));
|
||||
let q = Point3::try_from(q).unwrap();
|
||||
if let Some(Walk::Inside(at)) = cx.walk(t, &q, 1 << 12).unwrap() {
|
||||
tried += 1;
|
||||
assert!(!attempt_insert(&mut cx, &ctx, q, VertexKind::Interior, Site::Interior(at)).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(tried > 0);
|
||||
assert_eq!((cx.point_count(), cx.live_count()), (points, live));
|
||||
cx.validate().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -445,6 +445,8 @@ fn boundary_volume(points: &[[f64; 3]], tets: &[[u32; 4]], boundary: &[TaggedFac
|
||||
enclosed_volume(points, &triangles)
|
||||
}
|
||||
|
||||
use fieldcraft_model::progress;
|
||||
|
||||
/// Run the quality pipeline on a constrained Delaunay tetrahedralization of `surface` (see the
|
||||
/// module docs). `analytic[face]` is the exact surface of a B-rep face when known.
|
||||
pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Option<AnalyticSurface>>, o: &QualityOptions) -> Result<QualityMesh> {
|
||||
@@ -486,7 +488,10 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
|
||||
report.collapsed = refine::coarsen(&mut cx, &ctx, 0.6, 1.4, 0.05, 6)?;
|
||||
seed_curves(&mut cx, &ctx, &o.seeds, global)?;
|
||||
let ropt = RefineOptions { radius_edge: o.radius_edge, ..RefineOptions::default() };
|
||||
for _ in 0..o.rounds {
|
||||
progress::stage("Refinement round", Some(o.rounds as f64), "");
|
||||
for round in 0..o.rounds {
|
||||
progress::count((round + 1) as f64);
|
||||
progress::check()?;
|
||||
let stats = refine::refine(&mut cx, &ctx, &ropt)?;
|
||||
let quiet = stats.interior + stats.boundary == 0;
|
||||
let stopped = stats.stopped.clone();
|
||||
@@ -510,6 +515,8 @@ pub fn quality_mesh(surface: &BoundarySurface, cdt: &TetMesh, analytic: Vec<Opti
|
||||
}
|
||||
// Put the boundary vertices on the exact surfaces (the tessellation is within its chordal
|
||||
// tolerance of them).
|
||||
progress::check()?;
|
||||
progress::stage("Snapping to the geometry", None, "");
|
||||
report.unsnapped = snap_to_geometry(&mut cx, &exact, true)?;
|
||||
let c = cx.compact()?;
|
||||
let boundary: Vec<TaggedFace> = c.boundary.iter().map(|&(tet, k, face)| TaggedFace { tet, opposite: k as u8, face }).collect();
|
||||
|
||||
@@ -263,6 +263,10 @@ pub fn refine(cx: &mut TetComplex, ctx: &Context, o: &RefineOptions) -> Result<R
|
||||
while head < queue.len() {
|
||||
let t = queue.get(head).copied().unwrap_or(NONE);
|
||||
head += 1;
|
||||
// A round can insert hundreds of thousands of vertices: stop between them when asked.
|
||||
if head.is_multiple_of(1024) {
|
||||
fieldcraft_model::progress::check()?;
|
||||
}
|
||||
// Keep the queue's memory bounded: drop the processed prefix now and then.
|
||||
if head > 1 << 20 {
|
||||
queue.drain(..head);
|
||||
|
||||
@@ -172,6 +172,16 @@ impl BoundaryCondition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it drives the model on its own, as a load does: a non-zero prescribed
|
||||
/// displacement or a non-zero prescribed temperature (a support only holds).
|
||||
pub fn drives(&self) -> bool {
|
||||
match self {
|
||||
BoundaryCondition::Displacement { values, .. } => values.iter().flatten().any(|v| *v != 0.0),
|
||||
BoundaryCondition::Temperature { value, .. } => *value != 0.0,
|
||||
BoundaryCondition::Fixed { .. } | BoundaryCondition::Symmetry { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it prescribes temperatures (rather than motion).
|
||||
pub fn is_thermal(&self) -> bool {
|
||||
matches!(self, BoundaryCondition::Temperature { .. })
|
||||
|
||||
@@ -11,14 +11,13 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing};
|
||||
use crate::geometry::MAX_GEOMETRY_NAME_LEN;
|
||||
use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing, require};
|
||||
|
||||
/// Most partitions a model holds.
|
||||
pub const MAX_PARTITIONS: usize = 256;
|
||||
/// Most bodies one partition names.
|
||||
pub const MAX_PARTITION_BODIES: usize = 1000;
|
||||
/// Longest body name a partition holds.
|
||||
const MAX_BODY_NAME: usize = 256;
|
||||
|
||||
/// A datum plane, axis or point.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -100,6 +99,9 @@ pub struct Partition {
|
||||
/// empty: every body the plane divides.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bodies: Vec<String>,
|
||||
/// Kept in the model but left out of it: the design is cut without it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
@@ -127,7 +129,46 @@ impl Model {
|
||||
|
||||
pub fn remove_datum(&mut self, name: &str) -> Result<()> {
|
||||
not_in_use("datum", name, self.datum_users(name))?;
|
||||
remove_existing(&mut self.datums, "datum", name).map(|_| ())
|
||||
remove_existing(&mut self.datums, "datum", name)?;
|
||||
self.suppressed_datums.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether datum `name` is suppressed: kept, but not usable as an input and not cutting.
|
||||
pub fn datum_suppressed(&self, name: &str) -> bool {
|
||||
self.suppressed_datums.contains(name)
|
||||
}
|
||||
|
||||
/// Suppress datum `name` (or bring it back).
|
||||
pub fn set_datum_suppressed(&mut self, name: &str, suppressed: bool) -> Result<()> {
|
||||
require(&self.datums, "datum", name)?;
|
||||
if suppressed {
|
||||
self.suppressed_datums.insert(name.to_string());
|
||||
} else {
|
||||
self.suppressed_datums.remove(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Datum `name` as an input to another definition: refused while it is suppressed.
|
||||
pub fn usable_datum(&self, name: &str) -> Result<&Datum> {
|
||||
let d = self.datums.get(name).ok_or_else(|| Error::NotFound(format!("datum `{name}`")))?;
|
||||
if self.datum_suppressed(name) {
|
||||
return Err(Error::Invalid(format!("datum `{name}` is suppressed: unsuppress it to use it")));
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Suppress partition `name` (or bring it back): the design is cut without it.
|
||||
pub fn set_partition_suppressed(&mut self, name: &str, suppressed: bool) -> Result<()> {
|
||||
let p = self.partitions.iter_mut().find(|p| p.name == name).ok_or_else(|| Error::NotFound(format!("partition `{name}`")))?;
|
||||
p.suppressed = suppressed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether partition `p` cuts: neither it nor its datum is suppressed.
|
||||
pub fn partition_cuts(&self, p: &Partition) -> bool {
|
||||
!p.suppressed && !self.datum_suppressed(&p.datum)
|
||||
}
|
||||
|
||||
/// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\``
|
||||
@@ -163,8 +204,8 @@ impl Model {
|
||||
return Err(invalid(format!("at most {MAX_PARTITION_BODIES} bodies")));
|
||||
}
|
||||
for (i, b) in partition.bodies.iter().enumerate() {
|
||||
if b.trim().is_empty() || b.len() > MAX_BODY_NAME {
|
||||
return Err(invalid(format!("body names are 1 to {MAX_BODY_NAME} characters")));
|
||||
if b.trim().is_empty() || b.len() > MAX_GEOMETRY_NAME_LEN {
|
||||
return Err(invalid(format!("body names are 1 to {MAX_GEOMETRY_NAME_LEN} bytes")));
|
||||
}
|
||||
if partition.bodies.get(..i).is_some_and(|before| before.contains(b)) {
|
||||
return Err(invalid(format!("body `{b}` is listed twice")));
|
||||
@@ -188,6 +229,33 @@ mod tests {
|
||||
Datum::Plane { point: [0.0, 0.0, z], normal: [0.0, 0.0, 2.0] }
|
||||
}
|
||||
|
||||
/// A suppressed datum is kept but not usable, and stops its partitions cutting; a suppressed
|
||||
/// partition stops cutting; both are saved (#291).
|
||||
#[test]
|
||||
fn suppressed_datums_and_partitions_are_kept_but_left_out() {
|
||||
let mut m = Model::default();
|
||||
m.add_datum("Mid", plane(5.0)).unwrap();
|
||||
m.add_datum("Low", plane(1.0)).unwrap();
|
||||
let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![], suppressed: false };
|
||||
m.add_partition(p("Half", "Mid")).unwrap();
|
||||
m.add_partition(p("Base", "Low")).unwrap();
|
||||
assert!(m.partitions().iter().all(|p| m.partition_cuts(p)));
|
||||
m.set_datum_suppressed("Mid", true).unwrap();
|
||||
m.set_partition_suppressed("Base", true).unwrap();
|
||||
assert!(m.partitions().iter().all(|p| !m.partition_cuts(p)));
|
||||
assert!(m.usable_datum("Mid").unwrap_err().to_string().contains("suppressed"));
|
||||
assert!(m.usable_datum("Low").is_ok() && m.datum("Mid").is_some());
|
||||
assert!(m.set_datum_suppressed("Nope", true).is_err() && m.set_partition_suppressed("Nope", true).is_err());
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let back: Model = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, m);
|
||||
assert!(back.datum_suppressed("Mid") && back.partitions().iter().any(|p| p.suppressed));
|
||||
m.set_datum_suppressed("Mid", false).unwrap();
|
||||
assert!(m.usable_datum("Mid").is_ok());
|
||||
let plain = serde_json::to_string(&Model::default()).unwrap();
|
||||
assert!(!plain.contains("suppressed"), "nothing suppressed, nothing written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datums_are_normalised_and_partitions_refer_to_planes() {
|
||||
let mut m = Model::default();
|
||||
@@ -197,7 +265,7 @@ mod tests {
|
||||
let Some(Datum::Axis { direction, .. }) = m.datum("Axis") else { panic!() };
|
||||
assert!((direction[0] - 0.5f64.sqrt()).abs() < 1e-15);
|
||||
|
||||
let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![] };
|
||||
let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![], suppressed: false };
|
||||
m.add_partition(p("Half", "Mid")).unwrap();
|
||||
assert!(m.add_partition(p("Half", "Mid")).is_err());
|
||||
assert!(m.add_partition(p("Bad", "Axis")).is_err());
|
||||
|
||||
@@ -16,9 +16,13 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Model, Result};
|
||||
|
||||
/// Longest body or entity name accepted (persistent names can be long; they are not written to
|
||||
/// decks as such).
|
||||
pub const MAX_GEOMETRY_NAME_LEN: usize = 256;
|
||||
/// Longest body or entity name accepted, in bytes. Persistent names grow with the design's split
|
||||
/// history (a face cut by a groove, then split by joined bosses, names every step), so a real
|
||||
/// design can produce names of hundreds or thousands of characters; this cap only bounds hostile
|
||||
/// input (control channel, model files). The same cap holds wherever a geometry name is taken:
|
||||
/// opening, mesh tags, sets, ties, loads, partitions and saved views. Names are not written to
|
||||
/// decks as such.
|
||||
pub const MAX_GEOMETRY_NAME_LEN: usize = 64 * 1024;
|
||||
|
||||
/// Most distinct geometry entities a mesh can be tagged with.
|
||||
pub const MAX_ENTITIES: usize = 1_000_000;
|
||||
@@ -114,7 +118,7 @@ impl From<GeometryRef> for GeometryRefRepr {
|
||||
fn check_geometry_name(what: &str, name: &str) -> Result<()> {
|
||||
if name.is_empty() || name.len() > MAX_GEOMETRY_NAME_LEN || name.chars().any(char::is_control) {
|
||||
let shown: String = name.chars().take(64).filter(|c| !c.is_control()).collect();
|
||||
return Err(Error::Invalid(format!("{what} name `{shown}` must be 1 to {MAX_GEOMETRY_NAME_LEN} characters with no control characters")));
|
||||
return Err(Error::Invalid(format!("{what} name `{shown}` must be 1 to {MAX_GEOMETRY_NAME_LEN} bytes with no control characters")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -262,6 +266,13 @@ impl Classification {
|
||||
pub(crate) fn forget_node(&mut self, label: u32) {
|
||||
self.nodes.remove(&label);
|
||||
}
|
||||
|
||||
/// Give tagged nodes and elements their new labels (labels `node` or `element` leaves alone
|
||||
/// keep theirs; the caller checked the new labels are distinct).
|
||||
pub(crate) fn relabel(&mut self, node: &dyn Fn(u32) -> u32, element: &dyn Fn(u32) -> u32) {
|
||||
self.nodes = std::mem::take(&mut self.nodes).into_iter().map(|(l, t)| (node(l), t)).collect();
|
||||
self.elements = std::mem::take(&mut self.elements).into_iter().map(|(l, t)| (element(l), t)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user