Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b0831884 | ||
|
|
bc75fd08d1 | ||
|
|
ce8e23d05b | ||
|
|
94f7e8533a | ||
|
|
ebf8c655ee | ||
|
|
9f6afe7a89 | ||
|
|
a953dc6216 | ||
|
|
72696614c2 | ||
|
|
0bb1c2462f | ||
|
|
788edbbddd | ||
|
|
1b57e2e672 | ||
|
|
9a8ae50bca | ||
|
|
188f2d5fc4 | ||
|
|
217628ead4 | ||
|
|
8848ef9e7c | ||
|
|
5c5516aae0 | ||
|
|
6a604c24b6 | ||
|
|
a30d0b5c5b | ||
|
|
ecf947c090 | ||
|
|
6c2b2509e7 | ||
|
|
9ba6224bb7 | ||
|
|
c09ccc8a59 | ||
|
|
908dae180b | ||
|
|
11a69d8171 | ||
|
|
d012c84ee5 | ||
|
|
779a7ce93c | ||
|
|
1c2adfd592 | ||
|
|
34d3247d2c | ||
|
|
d93c106143 | ||
|
|
2dacf5ea9e | ||
|
|
ebd2493051 | ||
|
|
5011ae5896 | ||
|
|
0638f91f00 | ||
|
|
8f27ad225a | ||
|
|
7d942574fd | ||
|
|
bdd453e462 | ||
|
|
fb1f71e11d | ||
|
|
7942a7aea7 | ||
|
|
8c158b1e5c | ||
|
|
254acbb5c0 | ||
|
|
760c7fa7d3 | ||
|
|
557a950336 | ||
|
|
fed231910b | ||
|
|
85fa350b6b | ||
|
|
90d5f9a771 | ||
|
|
4445be0b48 | ||
|
|
e4e3c9a699 | ||
|
|
8a4209a20c | ||
|
|
cf72503f76 | ||
|
|
90729e4532 | ||
|
|
fe7d536719 |
No files matched your search
@@ -41,6 +41,6 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version and platform
|
||||
placeholder: "v0.1.0 (or commit), macOS 15 arm64"
|
||||
placeholder: "v0.5.7 (or commit), macOS 15 arm64"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,59 @@
|
||||
# Weekly dependency updates against develop, the trunk, grouped so each ecosystem opens one pull
|
||||
# request a week. Security updates are switched on in the repository settings and open their own
|
||||
# pull requests as advisories appear. CI runs the full gate on Dependabot's pull requests
|
||||
# (.github/workflows/ci.yml).
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "05:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: deps
|
||||
groups:
|
||||
# Compatible updates (Cargo.lock only) in one pull request; a breaking release of a
|
||||
# dependency comes on its own, since it usually needs code changes.
|
||||
cargo-compatible:
|
||||
update-types: [minor, patch]
|
||||
ignore:
|
||||
# Pinned to the SolidCraft commit FieldCraft builds against, together with the truck and
|
||||
# egui-wgpu patches from that commit ([patch.crates-io] in Cargo.toml); moved by hand.
|
||||
- dependency-name: "solidcraft-*"
|
||||
- dependency-name: "truck-*"
|
||||
- dependency-name: matext4cgmath
|
||||
- dependency-name: egui-wgpu
|
||||
# The egui line follows SolidCraft's (its egui-wgpu fixes are patched in): patch releases
|
||||
# come with the group, a new line (0.36 -> 0.37) moves by hand with SolidCraft.
|
||||
- dependency-name: egui
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: eframe
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
# The shared Craft components follow VectorCraft's rev.
|
||||
- dependency-name: craft-ui
|
||||
# The exact predicates' BigInt must be num-rational's (0.4 uses num-bigint 0.4): both move
|
||||
# together, by hand.
|
||||
- dependency-name: num-bigint
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: num-rational
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "05:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: ci
|
||||
groups:
|
||||
# Actions are pinned by commit SHA with a version comment; Dependabot updates both.
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
@@ -0,0 +1,83 @@
|
||||
# Linux on other architectures, weekly and on demand: CI and the release build x86_64 Linux,
|
||||
# macOS and Windows only.
|
||||
# - arm64: a native runner builds the shipped binaries (release profile) and runs the CLI and
|
||||
# engine tests.
|
||||
# - riscv64: cross-built with Ubuntu's GNU toolchain and linked; the CLI then runs under QEMU.
|
||||
# The GUI needs no target system libraries at link time (Wayland, X11 and GL are opened at run
|
||||
# time), so only the cross C toolchain and libc are installed.
|
||||
# The RISC-V job is adapted from SolidCraft's CI (see ATTRIBUTION.md).
|
||||
name: Linux arm64 and riscv64
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 5 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: arch-check-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
arm64:
|
||||
name: Linux arm64 (native)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: arm64
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- name: Build fieldcraft and fieldcraft-cli (release)
|
||||
run: cargo build --locked --release -p fieldcraft -p fieldcraft-cli
|
||||
- name: Run the binaries
|
||||
run: |
|
||||
file target/release/fieldcraft target/release/fieldcraft-cli
|
||||
target/release/fieldcraft --version
|
||||
target/release/fieldcraft-cli --version
|
||||
target/release/fieldcraft-cli commands >/dev/null
|
||||
- name: CLI and engine tests
|
||||
run: cargo test --locked --profile ci -p fieldcraft-cli -p fieldcraft-engine
|
||||
|
||||
riscv64:
|
||||
name: Linux riscv64 (cross)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
TARGET: riscv64gc-unknown-linux-gnu
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUNNER: qemu-riscv64 -L /usr/riscv64-linux-gnu
|
||||
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: riscv64
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- name: Install the cross toolchain and QEMU
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends gcc-riscv64-linux-gnu g++-riscv64-linux-gnu \
|
||||
libc6-dev-riscv64-cross qemu-user
|
||||
- name: Build fieldcraft and fieldcraft-cli
|
||||
run: cargo build --locked --profile ci --target "$TARGET" -p fieldcraft -p fieldcraft-cli
|
||||
- name: Run the CLI under QEMU
|
||||
run: |
|
||||
bin="target/$TARGET/ci"
|
||||
file "$bin/fieldcraft" "$bin/fieldcraft-cli"
|
||||
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft-cli" --version
|
||||
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft-cli" commands >/dev/null
|
||||
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft" --version
|
||||
@@ -1,9 +1,11 @@
|
||||
name: CI
|
||||
|
||||
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): the core team's pull
|
||||
# requests are checked locally on the crates they touch and merged; a red main is fixed forward.
|
||||
# CI runs once per merge on `develop`, the trunk (AGENTS.md, "Workflow for every change"): the core
|
||||
# team's pull requests are checked locally on the crates they touch and merged; a red trunk is
|
||||
# fixed forward. `main` holds released code only (docs/releasing.md).
|
||||
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
|
||||
# the run, so the review sees it.
|
||||
# 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.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -13,7 +15,7 @@ on:
|
||||
- 'docs/features.md'
|
||||
- 'docs/oracle.md'
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [develop]
|
||||
# A merge that only touches prose starts no run. The generated pages are checked by the jobs
|
||||
# (features.md by `cargo xtask ci`, oracle.md by the oracle job), so they still count.
|
||||
# (Later patterns override earlier ones.)
|
||||
@@ -24,8 +26,11 @@ on:
|
||||
- 'docs/features.md'
|
||||
- 'docs/oracle.md'
|
||||
schedule:
|
||||
# Nightly: the heavy tests on main.
|
||||
# Nightly, on the default branch (develop): the heavy tests.
|
||||
- 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).
|
||||
- cron: '41 4 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -43,7 +48,7 @@ env:
|
||||
jobs:
|
||||
ci:
|
||||
name: cargo xtask ci
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
|
||||
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: 60
|
||||
steps:
|
||||
@@ -54,13 +59,19 @@ jobs:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
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
|
||||
|
||||
heavy:
|
||||
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
|
||||
name: cargo xtask test --heavy
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -68,7 +79,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask test --heavy
|
||||
|
||||
oracle:
|
||||
@@ -76,7 +87,7 @@ jobs:
|
||||
# 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.
|
||||
name: cargo xtask oracle
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
|
||||
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: 45
|
||||
steps:
|
||||
@@ -85,14 +96,32 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
oracle-macos:
|
||||
# The same oracle on macOS, weekly and on demand: the solvers must agree across platforms
|
||||
# to the fixture bands, and docs/oracle.md must read the same.
|
||||
name: cargo xtask oracle (macOS)
|
||||
if: github.event.schedule == '41 4 * * 0' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-14
|
||||
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: oracle-macos
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- run: cargo xtask oracle
|
||||
- name: docs/oracle.md is current
|
||||
run: git diff --exit-code docs/oracle.md
|
||||
|
||||
advisories:
|
||||
name: Dependency advisories and sources
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
|
||||
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
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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}"
|
||||
@@ -0,0 +1,79 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
|
||||
# packaging shell and PowerShell scripts, the macOS property lists and the Linux desktop entry.
|
||||
# These files are otherwise exercised only by a full release run (release.yml).
|
||||
# Adapted from SolidCraft's packaging-lint.yml, itself adapted from PhotoCraft's (see ATTRIBUTION.md).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packaging-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Packaging lint
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
PSSCRIPTANALYZER_VERSION: 1.24.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils libxml2-utils
|
||||
archive="$RUNNER_TEMP/actionlint.tar.gz"
|
||||
curl -fsSL -o "$archive" \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
echo "$ACTIONLINT_SHA256 $archive" | sha256sum -c -
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
# Also runs shellcheck on every `run:` script.
|
||||
run: '"$RUNNER_TEMP/actionlint" -color'
|
||||
- name: shellcheck
|
||||
# packaging/.shellcheckrc lets the scripts' `source=../env.sh` directives resolve.
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
$failed = $false
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) {
|
||||
$errors | ForEach-Object { Write-Output "::error file=$($f.FullName),line=$($_.Extent.StartLineNumber)::$($_.Message)" }
|
||||
$failed = $true
|
||||
} else {
|
||||
Write-Output "parsed $($f.Name)"
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
if (-not (Get-Module -ListAvailable PSScriptAnalyzer | Where-Object Version -eq $env:PSSCRIPTANALYZER_VERSION)) {
|
||||
Install-Module PSScriptAnalyzer -RequiredVersion $env:PSSCRIPTANALYZER_VERSION -Scope CurrentUser -Force
|
||||
}
|
||||
Import-Module PSScriptAnalyzer -RequiredVersion $env:PSSCRIPTANALYZER_VERSION
|
||||
$issues = Invoke-ScriptAnalyzer -Path packaging/windows -Recurse -Severity Error, Warning
|
||||
$issues | ForEach-Object { Write-Output "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" }
|
||||
if ($issues) { exit 1 }
|
||||
- name: Property lists and desktop entry
|
||||
run: |
|
||||
app_id="$(sed -n 's/^APP_ID=//p' packaging/env.sh)"
|
||||
test -n "$app_id"
|
||||
xmllint --noout packaging/macos/*.plist*
|
||||
desktop="packaging/linux/$app_id.desktop"
|
||||
desktop-file-validate "$desktop"
|
||||
# The window's X11 class / Wayland app_id is packaging's APP_ID (apps/fieldcraft/src/main.rs).
|
||||
grep -qx "StartupWMClass=$app_id" "$desktop"
|
||||
grep -qF "const APP_ID: &str = \"$app_id\";" apps/fieldcraft/src/main.rs
|
||||
@@ -0,0 +1,84 @@
|
||||
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://jyviko.github.io/fieldcraft/
|
||||
# After SolidCraft's pages.yml (MIT OR Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# A push to `main` (a release; main holds released code only) builds the app with Trunk (release,
|
||||
# wasm-opt) and deploys it. A merge to `develop` that touches code builds it without deploying, so a
|
||||
# broken web build shows up before the release. A manual run builds the branch it runs on and
|
||||
# deploys only from `main`. Needs Settings › Pages › Source = GitHub Actions, and `main` allowed
|
||||
# by the github-pages environment's deployment branches.
|
||||
name: Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
# As ci.yml: a merge that only touches prose builds nothing.
|
||||
paths:
|
||||
- '**'
|
||||
- '!**/*.md'
|
||||
- '!docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One deployment at a time; a newer push waits its turn instead of cancelling a deploy part way.
|
||||
concurrency:
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TRUNK_VERSION: v0.21.14
|
||||
TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: web
|
||||
save-if: ${{ github.ref == 'refs/heads/develop' }}
|
||||
- name: Install Trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -sSfL -o trunk.tar.gz "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "${TRUNK_SHA256} trunk.tar.gz" | sha256sum -c -
|
||||
tar -xzf trunk.tar.gz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/fieldcraft-web
|
||||
run: trunk build --release --public-url /fieldcraft/
|
||||
- name: Bundle size
|
||||
run: |
|
||||
{
|
||||
echo "### Web app bundle"
|
||||
echo '| file | bytes | gzip |'
|
||||
echo '|---|---:|---:|'
|
||||
for f in dist/web/*.wasm dist/web/*.js; do
|
||||
echo "| $(basename "$f") | $(stat -c %s "$f") | $(gzip -9 -c "$f" | wc -c) |"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: dist/web
|
||||
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
@@ -1,11 +1,12 @@
|
||||
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
|
||||
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# A push to `release` (fast-forwarded from main) builds every package and creates or updates the
|
||||
# A push to `main` (fast-forwarded from a verified `develop` commit; main holds released code only)
|
||||
# builds every package and creates or updates the
|
||||
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt and generated notes. The
|
||||
# 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 main or tags never start it.
|
||||
# and pushes to develop or tags never start it.
|
||||
#
|
||||
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
|
||||
# Linux x86_64 (glibc 2.35+) .tar.gz and .AppImage packaging/linux
|
||||
@@ -15,17 +16,17 @@
|
||||
# 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
|
||||
# `release-assets` artifact without creating a release. `-f draft=true` on `release` retries
|
||||
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
|
||||
# the draft.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: "Create/update the draft release (release branch only; off: build artifacts only)"
|
||||
description: "Create/update the draft release (main only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -60,8 +61,8 @@ jobs:
|
||||
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1, not '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
|
||||
echo "::error::Draft releases are built from the release branch only"
|
||||
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/main ]; then
|
||||
echo "::error::Draft releases are built from main only"
|
||||
exit 1
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
@@ -86,7 +87,7 @@ jobs:
|
||||
with:
|
||||
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/release' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -129,7 +130,7 @@ jobs:
|
||||
with:
|
||||
key: release-linux-x86_64
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/release' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Build and package (tar.gz, AppImage)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
@@ -167,7 +168,7 @@ jobs:
|
||||
with:
|
||||
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/release' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Build and package (zip)
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/package.ps1
|
||||
|
||||
@@ -99,7 +99,7 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
1. Work in your own worktree on a branch, with your own `CARGO_TARGET_DIR`. While you work:
|
||||
`cargo check` and the tests of the crates you touch (`cargo test -p <crate>`). Commit after
|
||||
each working step (a layer that compiles with its tests passing), not one big commit per PR.
|
||||
Every commit lands on `main` as it is, so each one must build and pass its tests and say
|
||||
Every commit lands on `develop` as it is, so each one must build and pass its tests and say
|
||||
what it does: `git bisect` walks them one by one.
|
||||
2. When the change is done: review it once with `/simplify`, then `cargo fmt --all` and
|
||||
`cargo clippy -p <crate> --all-targets -- -D warnings` for each crate you touched, once.
|
||||
@@ -108,11 +108,12 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
oracle cases or solver numerics change (`cargo xtask oracle`).
|
||||
3. Fold every fix into the commit it fixes, never a separate "fmt", "clippy", "review fixes" or
|
||||
"regenerate features.md" commit: `git commit --fixup <sha>`, then
|
||||
`git rebase -i --autosquash origin/main` (`GIT_SEQUENCE_EDITOR=true` keeps it
|
||||
`git rebase -i --autosquash origin/develop` (`GIT_SEQUENCE_EDITOR=true` keeps it
|
||||
non-interactive). A regenerated page goes into the commit that changed what it lists.
|
||||
4. Rebase onto the latest `origin/main` (never merge `main` into a branch), rerun the tests of
|
||||
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `main`
|
||||
linear and every commit bisectable. `main` is protected (CODEOWNERS review, linear history,
|
||||
4. Branch from and rebase onto the latest `origin/develop`, the trunk (never merge `develop` into
|
||||
a branch), rerun the tests of
|
||||
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `develop`
|
||||
linear and every commit bisectable. `develop` and `main` are protected (CODEOWNERS review, linear history,
|
||||
rebase merges only); the core team merges as the maintainer with
|
||||
`gh pr merge <N> --rebase --admin --delete-branch`, run as its own command. Outside
|
||||
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
|
||||
@@ -120,20 +121,21 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
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 `main`; the coordinator
|
||||
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
|
||||
watches it and sends a failure back to the change that caused it, which is fixed forward
|
||||
at once.
|
||||
- **Keep the main gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
|
||||
- **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 main gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
|
||||
test, benchmarks included, and the doc tests) on every merge to `main`, nightly and on
|
||||
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
|
||||
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
|
||||
as the workflow above describes; the coordinator reads every merged diff and sends findings
|
||||
back as fix-forward work. Never force-push `main`. Never commit `plan/`, `.agent-files/`,
|
||||
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
|
||||
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
|
||||
`worktrees/` or `target*/`; check `git ls-files` before pushing.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -14,7 +14,9 @@ is committed and listed here with its exports.
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/media/claude-mcp.mp4, docs/media/claude-mcp.gif | FieldCraft contributors | Original: a screen recording of Claude Code driving FieldCraft over MCP, edited with ffmpeg (title and end cards from docs/media/og.png) | MIT OR Apache-2.0 |
|
||||
| docs/media/ibeam-analysis.mp4, docs/media/ibeam-analysis.gif | FieldCraft contributors | Original: the FieldCraft window captured during an MCP session, generated by `tools/demo-video/make.py` | MIT OR Apache-2.0 |
|
||||
| docs/media/og.svg, docs/media/og.png | FieldCraft contributors | Original social-preview image (1280 × 640): the application icon's cube layer (assets/app-icon/FieldCraft.icon/Assets/02-part.svg) beside the name and tagline set in Inter (OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
||||
| docs/media/bracket-partition.png, docs/media/bracket-hex.png, docs/media/bracket-stress.png, docs/media/setup-symbols.png | FieldCraft contributors | Original: FieldCraft renders (headless MCP screenshots and a window capture) of corpus parts and the cantilever sample | 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 |
|
||||
| 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 |
|
||||
@@ -134,12 +136,11 @@ is committed and listed here with its exports.
|
||||
| assets/icons/ui.sun.svg, assets/icons/ui.sun.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.sun.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.views.svg, assets/icons/ui.views.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.views.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui.zoom.svg, assets/icons/ui.zoom.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.zoom.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (an L bracket with a bolt hole under a structured hexahedral mesh, on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (a hexahedral-meshed cube with a banded colour fringe on its faces on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (the fringe cube alone; no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/01-floor-dark.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor-dark.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Dark appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/02-part.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/02-part.svg | FieldCraft contributors | Original bracket faces with transparent gaps at the shared edges, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/03-mesh.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/03-mesh.svg | FieldCraft contributors | Original hexahedral mesh lines (O-grid around the hole) clipped to the bracket's faces, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/layers/02-part.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/02-part.svg | FieldCraft contributors | Original cube faces with a banded colour fringe and transparent gaps at the shared edges, and the hexahedral mesh lines clipped to them, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/FieldCraft.icon/icon.json | FieldCraft contributors | Icon Composer composition of the layers above (Default and Dark floor grid), written by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/Assets.car | FieldCraft contributors | Apple actool compilation of assets/app-icon/FieldCraft.icon; contains original artwork only | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.icns | FieldCraft contributors | iconutil compilation of assets/app-icon/icon.iconset | MIT OR Apache-2.0 |
|
||||
@@ -167,15 +168,21 @@ is committed and listed here with its exports.
|
||||
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
|
||||
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
|
||||
| packaging/env.sh, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
|
||||
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
|
||||
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
|
||||
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/memory.rs (files kept in memory for the browser build) | SolidCraft `crates/io/src/vfs.rs` | MIT OR Apache-2.0 |
|
||||
| .github/workflows/pages.yml | SolidCraft `.github/workflows/pages.yml` | MIT OR Apache-2.0 |
|
||||
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
|
||||
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
|
||||
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
|
||||
|
||||
## Test oracles
|
||||
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
# Contributing
|
||||
# Contributing to FieldCraft
|
||||
|
||||
Thanks for helping. Read [AGENTS.md](AGENTS.md) first: it holds the rules every change follows,
|
||||
whether a person or an agent writes it.
|
||||
FieldCraft is one open-source modeler for structures, heat and fluids, written in pure Rust: open a
|
||||
parametric SolidCraft part, mesh it (tet or all-hex), define the analysis, solve it with our own
|
||||
implicit, explicit, thermal or lattice Boltzmann solvers, and look at verified results — from the
|
||||
desktop app, the command line, or an AI agent over MCP.
|
||||
|
||||
Help is welcome, whether that's a one-line fix, a verification benchmark, a file format or owning a
|
||||
whole solver. This page covers how to build it, the few rules that aren't negotiable, where help is
|
||||
needed most, and how to claim work. The rules every change follows, by a person or an agent, are in
|
||||
[AGENTS.md](AGENTS.md).
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
|
||||
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
|
||||
cargo run -p fieldcraft-cli -- commands # every command and its parameters
|
||||
cargo test -p fieldcraft-solver # the tests of one crate
|
||||
cargo xtask oracle --quick # the verification cases (docs/oracle.md)
|
||||
```
|
||||
|
||||
`develop` is the trunk and `main` holds released code. The layers and crates are in
|
||||
[docs/architecture.md](docs/architecture.md); every user-visible capability is a command in
|
||||
`crates/engine/src/cmd/` ([docs/features.md](docs/features.md) lists them).
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
|
||||
the `area:` labels for your field. In order of how much they would help users:
|
||||
|
||||
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
|
||||
must give the same tet mesh on every platform and every run, as the solvers already do.
|
||||
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
|
||||
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
|
||||
are next. Hex meshing is the centre of gravity of the project.
|
||||
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
|
||||
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
|
||||
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
|
||||
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
|
||||
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
|
||||
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
|
||||
can add it as an oracle case without touching solver code.
|
||||
5. **Nonlinear elements and materials**: incompatible modes at finite strain
|
||||
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
|
||||
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
|
||||
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
|
||||
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
|
||||
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
|
||||
|
||||
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
|
||||
propagation, turbulence — are open to anyone who wants to own one: start a thread in
|
||||
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
|
||||
|
||||
## Before you start: claim an issue
|
||||
|
||||
@@ -18,7 +70,8 @@ Issues labelled **in progress** are being worked on by the core team; please don
|
||||
|
||||
## Making the change
|
||||
|
||||
1. Branch from `main`, keep a pull request to one topic.
|
||||
1. Branch from `develop` (the trunk; `main` holds released code only) and open the pull request
|
||||
against `develop`. Keep it to one topic.
|
||||
2. Before pushing: `cargo fmt --all`, and `cargo clippy -p <crate> --all-targets -- -D warnings`
|
||||
and `cargo test -p <crate>` for the crates you touched. CI runs the full gate on your pull
|
||||
request once the maintainer approves the run.
|
||||
@@ -30,6 +83,7 @@ Issues labelled **in progress** are being worked on by the core team; please don
|
||||
|
||||
By contributing you agree your work is licensed under MIT OR Apache-2.0.
|
||||
|
||||
`main` is protected: changes land only through pull requests that the maintainer has reviewed,
|
||||
`develop` and `main` are protected: changes land on `develop` only through pull requests that the
|
||||
maintainer has reviewed,
|
||||
as rebase merges (keep each commit building and passing its tests). Be kind: see the
|
||||
[code of conduct](CODE_OF_CONDUCT.md).
|
||||
@@ -425,6 +425,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -461,6 +467,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -694,6 +709,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -749,6 +770,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -807,6 +837,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -849,8 +888,19 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1187,7 +1237,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1202,7 +1252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1212,7 +1262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1221,7 +1271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1243,7 +1293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1254,7 +1304,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1262,7 +1312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
@@ -1278,20 +1328,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1301,7 +1351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1312,7 +1362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"num-bigint",
|
||||
@@ -1324,7 +1374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1333,14 +1383,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1350,7 +1400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1362,7 +1412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1373,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1390,6 +1440,25 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1750,6 +1819,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -3238,8 +3316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3406,7 +3495,7 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-sketch",
|
||||
@@ -3448,7 +3537,7 @@ dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4897,7 +4986,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.5.7"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -39,7 +39,7 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
|
||||
# content hashes and part checksums of .fieldcraft files (the version SolidCraft uses)
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
|
||||
# images (PNG snapshots and screenshots)
|
||||
png = "0.18"
|
||||
|
||||
@@ -14,6 +14,10 @@ the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
|
||||
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||

|
||||
|
||||
*Claude driving FieldCraft through its [MCP server](docs/mcp.md): one prompt, and it opens the part, meshes it, defines and solves the analysis, then checks the tip deflection against beam theory. Real session, waits shortened. [Full video](docs/media/claude-mcp.mp4).*
|
||||
|
||||

|
||||
|
||||
An AI agent drives FieldCraft over [MCP](docs/mcp.md), each stage captioned with the tool it
|
||||
@@ -22,6 +26,23 @@ solves, and shows the von Mises stress and the deformed shape (tip deflection 0.
|
||||
theory with shear 0.177 mm).
|
||||
[MP4](docs/media/ibeam-analysis.mp4) · regenerated by [tools/demo-video](tools/demo-video/README.md).
|
||||
|
||||
## Download
|
||||
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://github.com/jyviko/fieldcraft/releases/latest)
|
||||
[](https://jyviko.github.io/fieldcraft/)
|
||||
|
||||
- **macOS:** open the `.dmg` and drag FieldCraft into Applications (Apple silicon and Intel).
|
||||
- **Windows:** unzip and run `fieldcraft.exe`.
|
||||
- **Linux:** the `.AppImage` (allow it to run as a program), or the `.tar.gz`.
|
||||
- **Browser:** [try it at jyviko.github.io/fieldcraft](https://jyviko.github.io/fieldcraft/), no install.
|
||||
- **Command line:** `fieldcraft-cli` ships in every package; AI agents connect to it over MCP
|
||||
([docs/mcp.md](docs/mcp.md)).
|
||||
|
||||
Every release lists its checksums in `SHA256SUMS.txt`. Building from source is in
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#get-it-running-about-10-minutes-on-a-fast-machine).
|
||||
|
||||
## Hex meshing a part that does not sweep
|
||||
|
||||
A filleted L-bracket with a hole through each leg cannot be swept in one piece. Split into 30
|
||||
|
||||
@@ -33,15 +33,15 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
|
||||
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
|
||||
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection with a bounding-volume search shared with explicit contact; 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 | planned |
|
||||
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (the model's contact definitions, `interaction.contact.*` with `find`, the deck mapping, `fieldcraft_fem::contact`'s closest-point projection and bucket grid, and the explicit solver's segmentation and friction return map are in place to reuse; the implicit solver refuses contact by name until then); 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 | planned |
|
||||
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (global search, penalty with friction, rigid walls); runs on hex and tet meshes | started |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. Next: incompatible modes (EAS), kinematic hardening at finite strain, shells and beams, linear buckling, arc-length with an indefinite factorization) | started |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
|
||||
| M16 Structural dynamics | Implicit transient (Newmark, HHT-α, generalised-α); modal superposition transient; steady-state harmonic (direct and modal), with Rayleigh, modal, structural and material damping; random vibration from PSDs and response spectrum (SRSS, CQC); complex eigenvalues with damping; rotating machinery (Campbell diagrams, gyroscopic terms); substructures (Craig–Bampton) | planned |
|
||||
| M17 Constitutive library and material library | Elastic: transversely isotropic, fully anisotropic (21 constants), temperature-dependent. Elastomers: hyperelastic neo-Hookean, Mooney–Rivlin, Yeoh (these three done, in nonlinear steps), Ogden, Arruda–Boyce, Gent, Marlow, with Mullins softening and finite-strain viscoelasticity (Prony series; parallel networks after Bergström–Boyce), foams. Viscoelasticity: small- and finite-strain Prony series, time–temperature superposition (WLF, Arrhenius), storage and loss moduli for harmonic steps. Viscoplasticity and creep: Norton power law, time and strain hardening, Perzyna, Anand, Chaboche combined hardening. Plasticity: Hill anisotropic, Drucker–Prager, Mohr–Coulomb, Cam-clay, crushable foam, concrete damaged plasticity, Gurson. Fluids: Newtonian and non-Newtonian viscosity shared with M12. Material library: curated, cited data sets (steels, aluminium and titanium alloys, polymers, elastomers, composites, tissues) with units and temperature dependence; calibration from test data (uniaxial, biaxial, shear, relaxation, DMA) by least squares with Drucker stability checks; user materials as a safe Rust trait | planned |
|
||||
| M18 Composites and anisotropy | Material orientations (rectangular, cylindrical, spherical, from geometry edges and faces, per-element fields, draping of fibres over curved parts); layups on shells and continuum shells, ply stacks on swept hex; classical lamination theory checks; ply stresses and strains through the thickness; failure indices (Tsai–Wu, Tsai–Hill, Hashin, Puck, LaRC) and progressive damage; homogenisation (rule of mixtures, Mori–Tanaka, RVEs with periodic boundary conditions); delamination through M20's cohesive zones. Verified by laminate closed forms and the NAFEMS composite benchmarks | planned |
|
||||
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
|
||||
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
|
||||
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
|
||||
|
||||
## Next
|
||||
|
||||
@@ -77,7 +77,7 @@ What exists and what is missing, by area; each gap is in a milestone above.
|
||||
| Area | Today | Missing |
|
||||
|---|---|---|
|
||||
| Solution procedures | Linear static, nonlinear static (solids with geometric and material nonlinearity: hyperelasticity, J2 plasticity), frequency (Lanczos), steady and transient heat, coupled temperature–displacement, explicit dynamics, LBM flow | Nonlinear shells and beams, arc-length, buckling (M15); implicit, harmonic, random and spectrum dynamics (M16) |
|
||||
| Contact, friction, adhesion | Explicit planar rigid walls (frictionless); couplings, springs, dashpots | Implicit and explicit deformable contact, tie, friction laws, thermal contact, wear (M7); cohesive adhesion and debonding (M20) |
|
||||
| Contact, friction, adhesion | Explicit deformable contact (pairs, general, self; segment to segment, penalty) with Coulomb friction and exponential decay; rigid walls with friction; couplings, springs, dashpots | Implicit contact, tie, anisotropic and pressure- or temperature-dependent friction, thermal contact, wear, eroding contact (M7, M10); cohesive adhesion and debonding (M20) |
|
||||
| Materials | Isotropic and orthotropic elasticity, density, thermal, J2 plasticity (bilinear, table, Johnson–Cook), Johnson–Cook damage and erosion, compressible neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, Newtonian fluids | Elastomers beyond Yeoh (Ogden, Arruda–Boyce and the rest), viscoelasticity, viscoplasticity and creep, anisotropic and pressure-dependent plasticity, non-Newtonian viscosity, a cited material library and calibration (M17) |
|
||||
| Composites and anisotropy | Orthotropic constants | Orientations, layups, ply results, failure criteria, homogenisation (M18) |
|
||||
| Coupled fields | Thermal–structural (one way and coupled) | Piezoelectric, poroelastic, electro-thermal, acoustics (M19) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in FieldCraft
|
||||
|
||||
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 444 crates below.
|
||||
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 452 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
FieldCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -41,11 +41,13 @@ atomic-waker 1.1.2 (Apache-2.0 OR MIT)
|
||||
atspi 0.29.0 (Apache-2.0 OR MIT)
|
||||
atspi-common 0.13.0 (Apache-2.0 OR MIT)
|
||||
atspi-proxies 0.13.0 (Apache-2.0 OR MIT)
|
||||
base64 0.22.1 (MIT OR Apache-2.0)
|
||||
bit-set 0.10.0 (Apache-2.0 OR MIT)
|
||||
bit-vec 0.9.1 (Apache-2.0 OR MIT)
|
||||
bitflags 1.3.2 (MIT/Apache-2.0)
|
||||
bitflags 2.13.2 (MIT OR Apache-2.0)
|
||||
block-buffer 0.10.4 (MIT OR Apache-2.0)
|
||||
block-buffer 0.12.1 (MIT OR Apache-2.0)
|
||||
block2 0.5.1 (MIT)
|
||||
block2 0.6.2 (MIT)
|
||||
blocking 1.7.0 (Apache-2.0 OR MIT)
|
||||
@@ -68,12 +70,14 @@ codespan-reporting 0.13.1 (Apache-2.0)
|
||||
color 0.3.3 (Apache-2.0 OR MIT)
|
||||
combine 4.6.8 (MIT)
|
||||
concurrent-queue 2.5.0 (Apache-2.0 OR MIT)
|
||||
const-oid 0.10.2 (Apache-2.0 OR MIT)
|
||||
convert_case 0.4.0 (MIT)
|
||||
core-foundation 0.9.4 (MIT OR Apache-2.0)
|
||||
core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
|
||||
core-graphics 0.23.2 (MIT OR Apache-2.0)
|
||||
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.2.17 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.3.1 (MIT OR Apache-2.0)
|
||||
craft-ui 0.1.0 (MIT OR Apache-2.0)
|
||||
crc32fast 1.5.2 (MIT OR Apache-2.0)
|
||||
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
|
||||
@@ -81,11 +85,13 @@ crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
|
||||
crossbeam-utils 0.8.23 (MIT OR Apache-2.0)
|
||||
crunchy 0.2.4 (MIT)
|
||||
crypto-common 0.1.7 (MIT OR Apache-2.0)
|
||||
crypto-common 0.2.2 (MIT OR Apache-2.0)
|
||||
cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib)
|
||||
data-url 0.3.2 (MIT OR Apache-2.0)
|
||||
derive_arbitrary 1.5.0 (MIT OR Apache-2.0)
|
||||
derive_more 0.99.20 (MIT)
|
||||
digest 0.10.7 (MIT OR Apache-2.0)
|
||||
digest 0.11.3 (MIT OR Apache-2.0)
|
||||
dispatch 0.2.0 (MIT)
|
||||
dispatch2 0.3.1 (Zlib OR Apache-2.0 OR MIT)
|
||||
dlib 0.5.3 (MIT)
|
||||
@@ -148,6 +154,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0)
|
||||
hashbrown 0.17.1 (MIT OR Apache-2.0)
|
||||
hermit-abi 0.5.3 (MIT OR Apache-2.0)
|
||||
hex 0.4.3 (MIT OR Apache-2.0)
|
||||
hybrid-array 0.4.15 (MIT OR Apache-2.0)
|
||||
iana-time-zone 0.1.65 (MIT OR Apache-2.0)
|
||||
iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0)
|
||||
image 0.25.10 (MIT OR Apache-2.0)
|
||||
@@ -290,6 +297,7 @@ serde_derive 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_json 1.0.154 (MIT OR Apache-2.0)
|
||||
serde_repr 0.1.21 (MIT OR Apache-2.0)
|
||||
sha2 0.10.9 (MIT OR Apache-2.0)
|
||||
sha2 0.11.1 (MIT OR Apache-2.0)
|
||||
signal-hook-registry 1.4.8 (MIT OR Apache-2.0)
|
||||
simd-adler32 0.3.10 (MIT)
|
||||
simd_cesu8 1.2.0 (Apache-2.0 OR MIT)
|
||||
@@ -746,14 +754,14 @@ DEALINGS IN THE SOFTWARE.
|
||||
================================================================================
|
||||
ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io
|
||||
2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task
|
||||
4.7.1, atomic-waker 1.1.2, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo 3.20.3, cfg-if
|
||||
1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys 0.8.7,
|
||||
core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21,
|
||||
crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent 1.0.2,
|
||||
errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0,
|
||||
flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown
|
||||
0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
|
||||
khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
4.7.1, atomic-waker 1.1.2, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
|
||||
3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys
|
||||
0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch
|
||||
0.9.21, crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent
|
||||
1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand
|
||||
2.5.0, flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1,
|
||||
hashbrown 0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
|
||||
0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
|
||||
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png
|
||||
0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
|
||||
@@ -2219,6 +2227,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
base64 0.22.1
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Alice Maz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
bit-set 0.10.0
|
||||
================================================================================
|
||||
@@ -2546,7 +2580,9 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9
|
||||
block-buffer 0.10.4, block-buffer 0.12.1, const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1,
|
||||
crypto-common 0.1.7, crypto-common 0.2.2, digest 0.10.7, digest 0.11.3, hybrid-array 0.4.15, sha2
|
||||
0.10.9, sha2 0.11.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -2751,6 +2787,36 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
block-buffer 0.12.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2018-2025 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
branches 0.3.0, rclite 0.2.8
|
||||
================================================================================
|
||||
@@ -3379,6 +3445,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
const-oid 0.10.2, cpufeatures 0.3.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2020-2026 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
cpufeatures 0.2.17
|
||||
================================================================================
|
||||
@@ -3731,6 +3827,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
crypto-common 0.2.2
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2021-2026 RustCrypto Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
cursor-icon 1.2.0
|
||||
================================================================================
|
||||
@@ -4063,6 +4189,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
digest 0.11.3
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2017-2025 RustCrypto Developers
|
||||
Copyright (c) 2017 Artyom Pavlov
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
dlib 0.5.3
|
||||
================================================================================
|
||||
@@ -6093,6 +6250,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
hybrid-array 0.4.15
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2022-2026 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2
|
||||
================================================================================
|
||||
@@ -9890,6 +10077,39 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
sha2 0.11.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2016-2026 The RustCrypto Project Developers
|
||||
Copyright (c) 2016 Artyom Pavlov
|
||||
Copyright (c) 2009-2013 Mozilla Foundation
|
||||
Copyright (c) 2006-2009 Graydon Hoare
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
signal-hook-registry 1.4.8
|
||||
================================================================================
|
||||
|
||||
@@ -15,6 +15,7 @@ use serde_json::json;
|
||||
const USAGE: &str = "\
|
||||
usage:
|
||||
fieldcraft-cli commands list every command with its parameters
|
||||
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
|
||||
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
|
||||
and print the model summary
|
||||
fieldcraft-cli features print docs/features.md (every capability, ready or
|
||||
@@ -36,6 +37,10 @@ const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
|
||||
let all: Vec<_> = fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect();
|
||||
serde_json::to_string_pretty(&all).map(|text| println!("{text}")).map_err(|e| e.to_string())
|
||||
}
|
||||
Some("commands") => {
|
||||
for c in fieldcraft_engine::commands() {
|
||||
println!("{:<24} {:<18} {}", c.id, c.label, c.params);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "fieldcraft-web"
|
||||
description = "FieldCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "fieldcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
# `file-handle-inner`: a picked file's size, checked before it is read.
|
||||
rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage"] }
|
||||
# Autosaves kept in the browser's local storage, which holds text.
|
||||
base64 = "0.22"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Build with: trunk build --release (from apps/fieldcraft-web; docs/web.md)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8790
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>FieldCraft</title>
|
||||
<meta name="description" content="Open-source CAE modeling in Rust: mesh a part, set up the analysis and solve it, in your browser.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FieldCraft">
|
||||
<meta property="og:title" content="FieldCraft">
|
||||
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://jyviko.github.io/fieldcraft/">
|
||||
<meta property="og:image" content="https://jyviko.github.io/fieldcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FieldCraft">
|
||||
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://jyviko.github.io/fieldcraft/og.png">
|
||||
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
|
||||
<link data-trunk rel="copy-file" href="../../docs/media/og.png" />
|
||||
<link rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
|
||||
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#fieldcraft_loading img { width: 96px; height: 96px; }
|
||||
#fieldcraft_loading p { margin: 0; max-width: 32em; }
|
||||
#fieldcraft_loading.failed { pointer-events: auto; }
|
||||
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
|
||||
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fieldcraft_canvas"></canvas>
|
||||
<div id="fieldcraft_loading">
|
||||
<img src="icons/icon_128x128@2x.png" alt="">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading FieldCraft…</p>
|
||||
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
|
||||
</div>
|
||||
<script>
|
||||
// The page could not load or start the app (no WebAssembly, a failed download): say so
|
||||
// instead of loading forever. Once the app runs it removes the loading screen.
|
||||
(function () {
|
||||
function fail(why) {
|
||||
var el = document.getElementById("fieldcraft_loading");
|
||||
if (!el || el.classList.contains("failed")) return;
|
||||
el.classList.add("failed");
|
||||
el.textContent = "";
|
||||
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
|
||||
var p = document.createElement("p");
|
||||
p.textContent = t;
|
||||
el.appendChild(p);
|
||||
});
|
||||
}
|
||||
if (typeof WebAssembly !== "object") fail("this browser has no WebAssembly.");
|
||||
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
//! FieldCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`fieldcraft_ui_egui::ModelApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`); docs/web.md says what works in the browser.
|
||||
//!
|
||||
//! Differences from the desktop app, all behind the UI's host services:
|
||||
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
|
||||
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
|
||||
//! a download;
|
||||
//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
|
||||
//! - no control channel (a page can't listen on a socket);
|
||||
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
|
||||
//! that they run.
|
||||
//!
|
||||
//! Address flags: `?sample` or `?sample=cantilever` opens a sample, `?webgl` forces WebGL2.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod storage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("fieldcraft-web only runs in the browser: build it with `trunk build --release` in apps/fieldcraft-web (docs/web.md)");
|
||||
}
|
||||
|
||||
/// The sample an address asks for (`?sample` opens the first sample, `?sample=NAME` that one).
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn sample_in_query(query: &str) -> Option<String> {
|
||||
let first = fieldcraft_engine::sample::SAMPLES.first().map_or("", |s| s.name);
|
||||
query.trim_start_matches('?').split('&').find_map(|kv| {
|
||||
let (key, value) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
(key == "sample").then(|| if value.is_empty() { first } else { value }.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// The name a file from the computer is kept under in the page: its last path part, without
|
||||
/// characters a path would read as folders.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn safe_name(name: &str) -> String {
|
||||
let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let clean: String = last.chars().filter(|c| !c.is_control()).take(200).collect();
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "file".to_string() } else { clean }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn samples_from_the_address() {
|
||||
assert_eq!(sample_in_query(""), None);
|
||||
assert_eq!(sample_in_query("?sample").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?webgl&sample=cantilever").as_deref(), Some("cantilever"));
|
||||
assert_eq!(sample_in_query("?sample=").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?samples=x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_stay_in_their_folder() {
|
||||
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
||||
assert_eq!(safe_name("../../etc/passwd"), "passwd");
|
||||
assert_eq!(safe_name("C:\\work\\plate.solidcraft"), "plate.solidcraft");
|
||||
assert_eq!(safe_name(".."), "file");
|
||||
assert_eq!(safe_name(""), "file");
|
||||
assert_eq!(safe_name("a\u{0}b.3mf"), "ab.3mf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Autosaves kept in the browser.
|
||||
//!
|
||||
//! The engine's recovery folder is [`fieldcraft_io::recovery::BROWSER_DIR`] among the page's
|
||||
//! files, which last only as long as the page. This mirrors it to the browser's local storage
|
||||
//! (kept across visits, shared by the site's tabs): each file as base64 under
|
||||
//! [`PREFIX`]`<file name>`. At start the stored files go back into the folder, so the desktop's
|
||||
//! recovery flow (File › Recover…, `file.recovery.*`) offers what a closed or crashed page left.
|
||||
//!
|
||||
//! Local storage holds a few megabytes per site: an autosave larger than [`MAX_AUTOSAVE_BYTES`]
|
||||
//! (or one the browser refuses) is not kept, and the status bar says so.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use fieldcraft_io::recovery::BROWSER_DIR;
|
||||
|
||||
const PREFIX: &str = "fieldcraft-recovery/";
|
||||
|
||||
/// Largest stored file taken back (local storage itself holds less).
|
||||
const MAX_STORED_CHARS: usize = 64 << 20;
|
||||
|
||||
/// Largest autosave kept: base64 makes it a third larger, and browsers give a site about 5 MB.
|
||||
pub const MAX_AUTOSAVE_BYTES: usize = 3 << 20;
|
||||
|
||||
fn local_storage() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().ok_or("no window")?.local_storage().map_err(crate::web::js_err)?.ok_or_else(|| "browser storage is turned off".into())
|
||||
}
|
||||
|
||||
/// The mirror: the stored files this page knows of (took back or wrote), by file name.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
known: BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub fn dir() -> &'static Path {
|
||||
Path::new(BROWSER_DIR)
|
||||
}
|
||||
|
||||
/// A file name of the recovery folder that is safe as a key (the engine's ids and extensions).
|
||||
fn valid(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 100 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') && !name.starts_with('.')
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Put the stored files back into the recovery folder.
|
||||
pub fn load() -> Store {
|
||||
let mut store = Store::default();
|
||||
let s = match local_storage() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("autosaves: {e}");
|
||||
return store;
|
||||
}
|
||||
};
|
||||
let keys: Vec<String> = (0..s.length().unwrap_or(0)).filter_map(|i| s.key(i).ok().flatten()).collect();
|
||||
for key in keys {
|
||||
let Some(name) = key.strip_prefix(PREFIX).filter(|n| valid(n)) else { continue };
|
||||
let Some(text) = s.get_item(&key).ok().flatten().filter(|t| t.len() <= MAX_STORED_CHARS) else { continue };
|
||||
let Ok(bytes) = STANDARD.decode(text.as_bytes()).map_err(|e| log::warn!("autosave {name} is damaged: {e}")) else { continue };
|
||||
match fieldcraft_io::fs::insert(&dir().join(name), bytes) {
|
||||
Ok(()) => {
|
||||
store.known.insert(name.to_string());
|
||||
}
|
||||
Err(e) => log::warn!("autosave {name}: {e}"),
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// Keep a file the engine wrote in the recovery folder.
|
||||
pub fn put(&mut self, path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name =
|
||||
path.file_name().and_then(|n| n.to_str()).filter(|n| valid(n)).ok_or_else(|| format!("{}: not a recovery file", path.display()))?;
|
||||
if bytes.len() > MAX_AUTOSAVE_BYTES {
|
||||
return Err(format!("the model ({} MB) is too large for the browser's storage", bytes.len() >> 20));
|
||||
}
|
||||
self.known.insert(name.to_string());
|
||||
local_storage()?
|
||||
.set_item(&format!("{PREFIX}{name}"), &STANDARD.encode(bytes))
|
||||
.map_err(|e| format!("browser storage is full or turned off ({})", crate::web::js_err(e)))
|
||||
}
|
||||
|
||||
/// Drop the stored files whose entries left the recovery folder (cleared once saved,
|
||||
/// recovered and discarded). Only files this page knows of: another tab's stay.
|
||||
pub fn sync_removals(&mut self) {
|
||||
let present: BTreeSet<String> =
|
||||
fieldcraft_io::fs::list(dir()).iter().filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)).collect();
|
||||
let gone: Vec<String> = self.known.difference(&present).cloned().collect();
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(s) = local_storage() else { return };
|
||||
for name in gone {
|
||||
if s.remove_item(&format!("{PREFIX}{name}")).is_ok() {
|
||||
self.known.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The browser shell: the web services of the UI (file pickers, downloads), dropped files, the
|
||||
//! autosave, and eframe's web runner.
|
||||
//!
|
||||
//! Structure follows VectorCraft's `apps/vectorcraft-web/src/web.rs` and SolidCraft's
|
||||
//! `apps/solidcraft-web/src/main.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::Autosaver;
|
||||
use fieldcraft_ui_egui::files::Kind;
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::storage::{self, Store};
|
||||
|
||||
const CANVAS_ID: &str = "fieldcraft_canvas";
|
||||
const LOADING_ID: &str = "fieldcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// The page's folder for files from the computer and the files commands write.
|
||||
const FILES_DIR: &str = "/files";
|
||||
|
||||
/// How often unsaved changes are autosaved for recovery (seconds), as on the desktop.
|
||||
const AUTOSAVE_SECONDS: f64 = 30.0;
|
||||
|
||||
/// Files read from the computer (picked together or dropped together), waiting for the next
|
||||
/// frame: (name, bytes or why they could not be read) each.
|
||||
type Batch = Vec<(String, Result<Vec<u8>, String>)>;
|
||||
type Inbox = Rc<RefCell<Vec<Batch>>>;
|
||||
|
||||
/// Largest file read from the computer: what the page can hold.
|
||||
const MAX_READ_BYTES: f64 = fieldcraft_io::memory::MAX_TOTAL_BYTES as f64;
|
||||
|
||||
/// Why a file of `size` bytes is not read, if it is too large.
|
||||
fn too_large(size: f64) -> Option<String> {
|
||||
(size > MAX_READ_BYTES)
|
||||
.then(|| format!("{} MB is more than this page holds ({} MB)", (size / 1048576.0).round(), fieldcraft_io::memory::MAX_TOTAL_BYTES >> 20))
|
||||
}
|
||||
|
||||
/// Hand files read from the computer to the next frame.
|
||||
fn deliver(inbox: &Inbox, ctx: &egui::Context, batch: Batch) {
|
||||
inbox.borrow_mut().push(batch);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where a file named `name` is kept in the page.
|
||||
fn page_path(name: &str) -> String {
|
||||
format!("{FILES_DIR}/{}", crate::safe_name(name))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The model has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub fn js_err(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn element(id: &str) -> Option<web_sys::Element> {
|
||||
web_sys::window()?.document()?.get_element_by_id(id)
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// The browser's clock for the recovery entries (the plain wasm target has none).
|
||||
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options(),
|
||||
Box::new(|cc| {
|
||||
let inbox = Inbox::default();
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
// The autosaves a closed or crashed page left, back in the recovery folder.
|
||||
let store = Store::load();
|
||||
let listed = app.session.execute("file.recovery.list", &serde_json::json!({}));
|
||||
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
|
||||
let autosaver = Autosaver::new(storage::dir()).map_err(|e| log::warn!("autosave unavailable: {e}")).ok();
|
||||
if let Some(name) = crate::sample_in_query(&query()) {
|
||||
app.open_sample(&name);
|
||||
}
|
||||
if recoverable > 0 {
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
||||
}
|
||||
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
|
||||
fn web_options() -> eframe::WebOptions {
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().split(['?', '&']).any(|kv| kv == "webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Replace the loading screen with why the app could not start.
|
||||
fn failed(why: &str) {
|
||||
log::error!("{why}");
|
||||
let Some(el) = element(LOADING_ID) else { return };
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
el.set_text_content(None);
|
||||
// Best effort: the console has the message too.
|
||||
let _ = el.class_list().add_1("failed");
|
||||
for line in
|
||||
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
|
||||
{
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
let _ = el.append_child(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while the model has
|
||||
/// unsaved changes (a page that is closing can't show its own dialog).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
if let Err(e) = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()) {
|
||||
log::warn!("couldn't ask before leaving: {}", js_err(e));
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// The UI's host services in the browser.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
// The browser's file picker: the files arrive in a later frame (the picker and the reads
|
||||
// are asynchronous), so the call itself picks nothing.
|
||||
pick_open: Some(Box::new(move |filters: &[Filter]| {
|
||||
let (inbox, ctx) = (inbox.clone(), ctx.clone());
|
||||
let dialog = filters.iter().fold(rfd::AsyncFileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(files) = dialog.pick_files().await else { return };
|
||||
let mut batch = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let read = match too_large(f.inner().size()) {
|
||||
Some(why) => Err(why),
|
||||
None => Ok(f.read().await),
|
||||
};
|
||||
batch.push((f.file_name(), read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
None
|
||||
})),
|
||||
// Saving writes into the page; the shell hands each written file out as a download.
|
||||
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
|
||||
// The recent list holds the page's files, which go with the page.
|
||||
recent_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The app with the browser's files around it.
|
||||
struct Shell {
|
||||
app: ModelApp,
|
||||
inbox: Inbox,
|
||||
store: Store,
|
||||
/// `None` when autosave could not start.
|
||||
autosaver: Option<Autosaver>,
|
||||
/// App time of the last autosave check.
|
||||
autosaved_at: f64,
|
||||
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
||||
storage_error: bool,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files read from the computer go into the page's folder, then open: a model with the
|
||||
/// design and meshes it refers to picked or dropped with it, else each file in turn.
|
||||
fn open_arrived(&mut self) {
|
||||
let batches: Vec<_> = self.inbox.borrow_mut().drain(..).collect();
|
||||
for batch in batches {
|
||||
let mut paths = Vec::with_capacity(batch.len());
|
||||
for (name, read) in batch {
|
||||
let path = page_path(&name);
|
||||
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
|
||||
Ok(()) => paths.push(path),
|
||||
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
|
||||
}
|
||||
}
|
||||
let model = paths.iter().find(|p| Kind::of(p) == Some(Kind::Model)).cloned();
|
||||
match model {
|
||||
// Its geometry files open with it, from the same folder.
|
||||
Some(model) => self.app.open_path(&model),
|
||||
None => {
|
||||
for p in &paths {
|
||||
self.app.open_path(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What commands wrote: autosaves to the browser's storage, everything else downloaded.
|
||||
fn hand_out_writes(&mut self) {
|
||||
for (path, bytes) in fieldcraft_io::fs::take_writes() {
|
||||
if path.parent() == Some(storage::dir()) {
|
||||
match self.store.put(&path, &bytes) {
|
||||
Ok(()) => self.storage_error = false,
|
||||
Err(e) if !self.storage_error => {
|
||||
self.storage_error = true;
|
||||
self.app.set_status(format!("Autosave: {e}. Save the model to keep it (File › Save downloads it)"), true);
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
}
|
||||
} else if let Err(e) = download(&path, &bytes) {
|
||||
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
|
||||
}
|
||||
}
|
||||
self.store.sync_removals();
|
||||
}
|
||||
|
||||
fn autosave(&mut self, ctx: &egui::Context) {
|
||||
let Some(a) = &mut self.autosaver else { return };
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
self.autosaved_at = self.app.now;
|
||||
if let Err(e) = a.save(&self.app.session) {
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.open_arrived();
|
||||
self.app.logic(ctx);
|
||||
self.autosave(ctx);
|
||||
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
||||
}
|
||||
|
||||
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
|
||||
/// inbox; the app never sees their names as paths.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let dropped = std::mem::take(&mut raw.dropped_files);
|
||||
if !dropped.is_empty() {
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let mut batch = Vec::with_capacity(dropped.len());
|
||||
for f in dropped {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
let read = match f.web_file().and_then(|w| too_large(w.size())) {
|
||||
Some(why) => Err(why),
|
||||
None => f.bytes_async().await,
|
||||
};
|
||||
batch.push((name, read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
}
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
// Once a frame, after everything that writes: autosaves, saves and decks.
|
||||
self.hand_out_writes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `bytes` out as a download named after the last part of `path`.
|
||||
fn download(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("fieldcraft").to_string();
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type("application/octet-stream");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js_err)?.dyn_into().map_err(|_| "not a link")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js_err)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js_err)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked once the click has been dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// The download has started: a timer that can't be set leaves the URL allocated, no more.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,15 +4,15 @@
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
|
||||
</clipPath>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#4f8a82"/>
|
||||
<stop offset="0.3" stop-color="#4f8a82" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#4f8a82" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#4f8a82" stop-opacity="0"/>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#4f6a86"/>
|
||||
<stop offset="0.3" stop-color="#4f6a86" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#4f6a86" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#4f6a86" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<title>FieldCraft icon floor grid (Dark)</title>
|
||||
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
|
||||
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -4,15 +4,15 @@
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
|
||||
</clipPath>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7fa8a2"/>
|
||||
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8aa0b4"/>
|
||||
<stop offset="0.3" stop-color="#8aa0b4" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#8aa0b4" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#8aa0b4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<title>FieldCraft icon floor grid</title>
|
||||
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
|
||||
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,38 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient-1" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f786d"/>
|
||||
<stop offset="1" stop-color="#0a544e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-6" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0b665e"/>
|
||||
<stop offset="1" stop-color="#04302c"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-2">
|
||||
<path d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-3">
|
||||
<path d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-4">
|
||||
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>FieldCraft icon part</title>
|
||||
<g id="Part">
|
||||
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-1)"/>
|
||||
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-5)"/>
|
||||
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-6)"/>
|
||||
<title>FieldCraft icon cube</title>
|
||||
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-1)">
|
||||
<path id="top" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#2c4fbf"/>
|
||||
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="top_shade" data-name="top shade" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#ffffff" opacity="0.1"/>
|
||||
</g>
|
||||
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-2)">
|
||||
<path id="left" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#2c4fbf"/>
|
||||
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="left_shade" data-name="left shade" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#000000" opacity="0.12"/>
|
||||
</g>
|
||||
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-3)">
|
||||
<path id="right" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#2c4fbf"/>
|
||||
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
|
||||
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
|
||||
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="right_shade" data-name="right shade" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#000000" opacity="0.3"/>
|
||||
</g>
|
||||
<g id="Mesh" clip-path="url(#clip-path-4)">
|
||||
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M576.95 237.5 L317.14 387.5 M447.05 237.5 L706.86 387.5 M317.14 687.5 L317.14 387.5 M252.19 575 L512 725 M706.86 687.5 L706.86 387.5 M771.81 575 L512 725 M641.9 275 L382.1 425 M382.1 275 L641.9 425 M382.1 725 L382.1 425 M252.19 500 L512 650 M641.9 725 L641.9 425 M771.81 500 L512 650 M706.86 312.5 L447.05 462.5 M317.14 312.5 L576.95 462.5 M447.05 762.5 L447.05 462.5 M252.19 425 L512 575 M576.95 762.5 L576.95 462.5 M771.81 425 L512 575" fill="none" stroke="#0b2236" stroke-width="3.5" stroke-linejoin="round" opacity="0.45"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>FieldCraft icon mesh</title>
|
||||
<g id="Mesh" clip-path="url(#clip-path-1)">
|
||||
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,37 +1,6 @@
|
||||
{
|
||||
"fill": "system-light",
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"glass-specializations": [
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"image-name": "03-mesh.svg",
|
||||
"name": "03-mesh",
|
||||
"position": {
|
||||
"scale": 0.9,
|
||||
"translation-in-points": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"name": "03 Mesh",
|
||||
"shadow": {
|
||||
"kind": "none",
|
||||
"opacity": 0
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# FieldCraft application icon
|
||||
|
||||
FieldCraft's mark is an L-shaped bracket with a bolt hole, drawn in isometric view and covered
|
||||
by a structured hexahedral mesh with an O-grid around the hole. Its faces are separated by
|
||||
FieldCraft's mark is a cube in isometric view, covered by a structured hexahedral mesh, whose
|
||||
faces carry a banded colour fringe (blue to red, hottest at the front corner) as a stress or
|
||||
temperature result would. Its faces are separated by
|
||||
transparent gaps rather than painted edge lines, so the backdrop shows through them in every
|
||||
appearance. It is original artwork, attributed in `ATTRIBUTION.md`, and shares the Craft
|
||||
family's layout (rounded-square backdrop, isometric floor grid, lit top faces) in its own teal.
|
||||
family's layout (rounded-square backdrop, isometric floor grid, lit top faces).
|
||||
|
||||
Everything here is generated by `cargo xtask app-icon` (`xtask/src/app_icon.rs`), which computes
|
||||
the geometry and draws it with VectorCraft's command-line tool (`VECTORCRAFT_CLI`, else
|
||||
@@ -14,10 +15,10 @@ hand. It runs on macOS (sips, iconutil, `xcrun actool`) with ImageMagick (`magic
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `icon.vectorcraft`, `icon.svg`, `icon.png` | The full icon on its backdrop, 1024 × 1024 |
|
||||
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: no mesh or floor grid, faces edge to edge |
|
||||
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: the fringe cube alone, no mesh or floor grid, faces edge to edge |
|
||||
| `icon.iconset/`, `icon.icns` | macOS iconset (sips) and its iconutil compilation |
|
||||
| `icon.ico` | Windows icon, 16–256 px |
|
||||
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), part and mesh layers, each scaled to 90% |
|
||||
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), and cube (faces and mesh) layers, each scaled to 90% |
|
||||
| `layers/*.vectorcraft` | Editable sources of the Icon Composer layers |
|
||||
| `Assets.car` | `actool` compilation of `FieldCraft.icon` for macOS 26 and later (needs Xcode 26 or later; the task skips it with a note otherwise) |
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 64 KiB |
@@ -1,35 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d2b3b" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fbfdfd"/>
|
||||
<stop offset="1" stop-color="#dbe5e4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="385.86" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="512" y1="452.64" x2="512" y2="764.28" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="512" y1="200.36" x2="512" y2="638.14" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="512" y1="578.78" x2="512" y2="883" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-6" x1="512" y1="326.5" x2="512" y2="883" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f786d"/>
|
||||
<stop offset="1" stop-color="#0a544e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0b665e"/>
|
||||
<stop offset="1" stop-color="#04302c"/>
|
||||
<stop offset="0" stop-color="#fbfcfe"/>
|
||||
<stop offset="1" stop-color="#dce3ec"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M512 200 L771.81 350 L512 500 L252.19 350 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-2">
|
||||
<path d="M252.19 350 L512 500 L512 800 L252.19 650 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-3">
|
||||
<path d="M771.81 650 L512 800 L512 500 L771.81 350 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>FieldCraft application icon, small sizes</title>
|
||||
<g id="Backdrop">
|
||||
@@ -37,12 +22,34 @@
|
||||
<path id="backdrop" d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z" fill="url(#linear-gradient-1)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Part">
|
||||
<path id="wall_top" data-name="wall top" d="M512 141 L833.3 326.5 L730.48 385.86 L409.19 200.36 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="base_top" data-name="base top" d="M409.19 452.64 L730.48 638.14 L512 764.28 L190.7 578.78 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="wall_front" data-name="wall front" d="M409.19 200.36 L730.48 385.86 L730.48 638.14 L409.19 452.64 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="base_front" data-name="base front" d="M190.7 578.78 L512 764.28 L512 883 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
|
||||
<path id="end" d="M833.3 697.5 L512 883 L512 764.28 L730.48 638.14 L730.48 385.86 L833.3 326.5 Z" fill="url(#linear-gradient-6)"/>
|
||||
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
|
||||
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-1)">
|
||||
<path id="top" d="M512 200 L771.81 350 L512 500 L252.19 350 Z" fill="#2c4fbf"/>
|
||||
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="top_shade" data-name="top shade" d="M512 200 L771.81 350 L512 500 L252.19 350 Z" fill="#ffffff" opacity="0.1"/>
|
||||
</g>
|
||||
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-2)">
|
||||
<path id="left" d="M252.19 350 L512 500 L512 800 L252.19 650 Z" fill="#2c4fbf"/>
|
||||
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="left_shade" data-name="left shade" d="M252.19 350 L512 500 L512 800 L252.19 650 Z" fill="#000000" opacity="0.12"/>
|
||||
</g>
|
||||
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-3)">
|
||||
<path id="right" d="M771.81 650 L512 800 L512 500 L771.81 350 Z" fill="#2c4fbf"/>
|
||||
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
|
||||
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
|
||||
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="right_shade" data-name="right shade" d="M771.81 650 L512 800 L512 500 L771.81 350 Z" fill="#000000" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 171 KiB |
@@ -1,46 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d2b3b" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fbfdfd"/>
|
||||
<stop offset="1" stop-color="#dbe5e4"/>
|
||||
<stop offset="0" stop-color="#fbfcfe"/>
|
||||
<stop offset="1" stop-color="#dce3ec"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-1">
|
||||
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
|
||||
</clipPath>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7fa8a2"/>
|
||||
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8aa0b4"/>
|
||||
<stop offset="0.3" stop-color="#8aa0b4" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#8aa0b4" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#8aa0b4" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#86e3d3"/>
|
||||
<stop offset="1" stop-color="#4cc4b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20b39f"/>
|
||||
<stop offset="1" stop-color="#118c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-6" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0f786d"/>
|
||||
<stop offset="1" stop-color="#0a544e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0b665e"/>
|
||||
<stop offset="1" stop-color="#04302c"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip-path-2">
|
||||
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
|
||||
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-3">
|
||||
<path d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-4">
|
||||
<path d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-5">
|
||||
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>FieldCraft application icon</title>
|
||||
@@ -50,17 +35,39 @@
|
||||
</g>
|
||||
</g>
|
||||
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
|
||||
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g id="Part">
|
||||
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
|
||||
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-6)"/>
|
||||
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
|
||||
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-2)">
|
||||
<path id="top" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#2c4fbf"/>
|
||||
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="top_shade" data-name="top shade" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#ffffff" opacity="0.1"/>
|
||||
</g>
|
||||
<g id="Mesh" clip-path="url(#clip-path-2)">
|
||||
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
|
||||
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-3)">
|
||||
<path id="left" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#2c4fbf"/>
|
||||
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
|
||||
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
|
||||
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="left_shade" data-name="left shade" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#000000" opacity="0.12"/>
|
||||
</g>
|
||||
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-4)">
|
||||
<path id="right" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#2c4fbf"/>
|
||||
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
|
||||
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
|
||||
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
|
||||
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
|
||||
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
|
||||
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
|
||||
<path id="right_shade" data-name="right shade" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#000000" opacity="0.3"/>
|
||||
</g>
|
||||
<g id="Mesh" clip-path="url(#clip-path-5)">
|
||||
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M576.95 237.5 L317.14 387.5 M447.05 237.5 L706.86 387.5 M317.14 687.5 L317.14 387.5 M252.19 575 L512 725 M706.86 687.5 L706.86 387.5 M771.81 575 L512 725 M641.9 275 L382.1 425 M382.1 275 L641.9 425 M382.1 725 L382.1 425 M252.19 500 L512 650 M641.9 725 L641.9 425 M771.81 500 L512 650 M706.86 312.5 L447.05 462.5 M317.14 312.5 L576.95 462.5 M447.05 762.5 L447.05 462.5 M252.19 425 L512 575 M576.95 762.5 L576.95 462.5 M771.81 425 L512 575" fill="none" stroke="#0b2236" stroke-width="3.5" stroke-linejoin="round" opacity="0.45"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -13,8 +13,20 @@
|
||||
//!
|
||||
//! In order: heading, nodes (mesh nodes, then reference points), elements grouped by element type,
|
||||
//! sets (a node set, an element set and an element-face surface per model set, each written when
|
||||
//! the set resolves to some), materials, sections, couplings, the initial state's boundary
|
||||
//! conditions, then the steps.
|
||||
//! the set resolves to some), materials, sections, couplings, contact, the initial state's
|
||||
//! boundary conditions, then the steps.
|
||||
//!
|
||||
//! # Contact
|
||||
//!
|
||||
//! Each contact is a `*SURFACE INTERACTION` with its friction (`*FRICTION`, or
|
||||
//! `*FRICTION, EXPONENTIAL DECAY` with the static and kinetic coefficients and the decay
|
||||
//! coefficient, the same law). A pair is `*CONTACT PAIR, TYPE=SURFACE TO SURFACE` with the
|
||||
//! secondary surface first, and `ADJUST=0.0` when initially overclosed nodes are adjusted. General
|
||||
//! contact is the model's one `*CONTACT` definition: `*CONTACT INCLUSIONS, ALL EXTERIOR` for every
|
||||
//! exterior face, a surface with a blank partner for self contact, and a
|
||||
//! `*CONTACT PROPERTY ASSIGNMENT` line per contact. FieldCraft's penalty scale and its refusal of
|
||||
//! initial overclosure have no Abaqus counterpart and are noted in comments (Abaqus chooses its
|
||||
//! own penalty and resolves overclosure in the first increment).
|
||||
//!
|
||||
//! # Steps and the activity table
|
||||
//!
|
||||
@@ -130,8 +142,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use fieldcraft_model::{
|
||||
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, Integration, Load, Model, OutputVariable,
|
||||
Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
|
||||
BeamProfile, BoundaryCondition, ContactSurfaces, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, InitialPenetration,
|
||||
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
|
||||
};
|
||||
|
||||
use crate::{Error, Result, unsupported};
|
||||
@@ -174,6 +186,7 @@ pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
|
||||
d.sections(model)?;
|
||||
d.discrete_properties(&discrete)?;
|
||||
d.couplings(model, r);
|
||||
d.contacts(model, r)?;
|
||||
d.steps(model, r)?;
|
||||
Ok(d.out)
|
||||
}
|
||||
@@ -957,6 +970,78 @@ impl Deck {
|
||||
}
|
||||
}
|
||||
|
||||
/// One surface interaction (the friction) per contact, then its pair, and every general
|
||||
/// contact in the model's one `*CONTACT` definition.
|
||||
fn contacts(&mut self, model: &Model, r: &Resolved) -> Result<()> {
|
||||
if model.contacts().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut taken: BTreeSet<String> = model.sets().keys().map(|n| n.to_ascii_uppercase()).collect();
|
||||
taken.extend(coupling_surfaces(model, r).into_values().map(|n| n.to_ascii_uppercase()));
|
||||
self.comment("Contact: a surface interaction (Coulomb friction) per contact, contact pairs, general contact");
|
||||
let mut general = Vec::new();
|
||||
for (name, c) in model.contacts() {
|
||||
let interaction = crate::unique_name(&format!("{name}-interaction"), &mut taken);
|
||||
self.line(&format!("*SURFACE INTERACTION, NAME={interaction}"));
|
||||
let f = c.friction;
|
||||
if f.decay > 0.0 {
|
||||
self.line("*FRICTION, EXPONENTIAL DECAY");
|
||||
self.numbers_line("", &[f.static_coefficient, f.kinetic_coefficient(), f.decay])?;
|
||||
} else {
|
||||
self.line("*FRICTION");
|
||||
self.numbers_line("", &[f.static_coefficient])?;
|
||||
}
|
||||
if c.penalty_scale != 1.0 {
|
||||
self.comment(&format!(
|
||||
"contact `{name}`: penalty scale {} of FieldCraft's explicit penalty stiffness; Abaqus chooses its own",
|
||||
c.penalty_scale
|
||||
));
|
||||
}
|
||||
match &c.surfaces {
|
||||
ContactSurfaces::Pair { main, secondary } => {
|
||||
// ADJUST=0.0 moves the secondary nodes that start inside the main surface
|
||||
// onto it, strain-free; Abaqus has no refusal of initial overclosure.
|
||||
let adjust = match c.initial_penetration {
|
||||
InitialPenetration::Adjust => ", ADJUST=0.0",
|
||||
InitialPenetration::Refuse => {
|
||||
self.comment(&format!(
|
||||
"contact `{name}`: FieldCraft refuses initially overclosed nodes; Abaqus resolves them in the first increment"
|
||||
));
|
||||
""
|
||||
}
|
||||
};
|
||||
self.line(&format!("*CONTACT PAIR, INTERACTION={interaction}, TYPE=SURFACE TO SURFACE{adjust}"));
|
||||
self.line(&format!("{secondary}, {main}"));
|
||||
}
|
||||
ContactSurfaces::General { surface } => general.push((surface.clone(), interaction)),
|
||||
}
|
||||
}
|
||||
if general.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// General contact: every surface against itself (a blank second surface), or every
|
||||
// exterior face; each with its interaction.
|
||||
self.line("*CONTACT");
|
||||
if general.iter().any(|(s, _)| s.is_none()) {
|
||||
self.line("*CONTACT INCLUSIONS, ALL EXTERIOR");
|
||||
}
|
||||
let named: Vec<&str> = general.iter().filter_map(|(s, _)| s.as_deref()).collect();
|
||||
if !named.is_empty() {
|
||||
self.line("*CONTACT INCLUSIONS");
|
||||
for s in &named {
|
||||
self.line(&format!("{s},"));
|
||||
}
|
||||
}
|
||||
self.line("*CONTACT PROPERTY ASSIGNMENT");
|
||||
for (surface, interaction) in &general {
|
||||
match surface {
|
||||
Some(s) => self.line(&format!("{s}, {s}, {interaction}")),
|
||||
None => self.line(&format!(", , {interaction}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn boundary_lines<'n>(&mut self, model: &Model, r: &Resolved, names: impl IntoIterator<Item = &'n String>) -> Result<()> {
|
||||
for name in names {
|
||||
let Some(bc) = model.boundary_condition(name) else { continue };
|
||||
@@ -1106,10 +1191,38 @@ impl Deck {
|
||||
Procedure::Fluid { .. } => return Err(crate::fluid_step(FORMAT, &step.name)),
|
||||
Procedure::Static {} | Procedure::NonlinearStatic(_) => {
|
||||
if let Procedure::NonlinearStatic(n) = &step.procedure {
|
||||
if n.imperfection.is_some() {
|
||||
return Err(Error::Unsupported {
|
||||
format: FORMAT,
|
||||
what: format!(
|
||||
"step `{}`: an imperfection from a buckling mode (FieldCraft computes the mode when it solves)",
|
||||
step.name
|
||||
),
|
||||
});
|
||||
}
|
||||
let nlgeom = if n.nonlinear_geometry { "YES" } else { "NO" };
|
||||
self.line(&format!("*STEP, NAME={}, NLGEOM={nlgeom}, INC={}", step.name, n.max_increments));
|
||||
self.line("*STATIC");
|
||||
self.numbers_line("", &[n.initial_increment, 1.0, n.min_increment, n.max_increment])?;
|
||||
if let Some(arc) = &n.arc_length {
|
||||
// Riks: initial arc, period, minimum and maximum arcs, the load
|
||||
// proportionality factor and the displacement that end the step.
|
||||
if arc.constraint != fieldcraft_model::ArcConstraint::Cylindrical {
|
||||
return Err(Error::Unsupported {
|
||||
format: FORMAT,
|
||||
what: format!("step `{}`: a spherical arc-length constraint", step.name),
|
||||
});
|
||||
}
|
||||
self.line("*STATIC, RIKS");
|
||||
self.numbers_line("", &[n.initial_increment, 1.0, n.min_increment, n.max_increment, arc.max_load_factor])?;
|
||||
if let Some(l) = &arc.displacement_limit {
|
||||
self.out.pop();
|
||||
let _ = write!(self.out, ", {}, {}, ", l.node, l.direction);
|
||||
push_number(&mut self.out, l.displacement)?;
|
||||
self.out.push('\n');
|
||||
}
|
||||
} else {
|
||||
self.line("*STATIC");
|
||||
self.numbers_line("", &[n.initial_increment, 1.0, n.min_increment, n.max_increment])?;
|
||||
}
|
||||
if n.line_search {
|
||||
self.line("*CONTROLS, PARAMETERS=LINE SEARCH");
|
||||
self.line("5,");
|
||||
@@ -1131,6 +1244,34 @@ impl Deck {
|
||||
base_bcs.clone_from(&step.boundary_conditions);
|
||||
base_loads.clone_from(&step.loads);
|
||||
}
|
||||
Procedure::Buckle { modes, shift } => {
|
||||
// Abaqus's buckling base state is the end of the previous general step;
|
||||
// FieldCraft's steps all start from the undeformed, unloaded model.
|
||||
if !base_loads.is_empty()
|
||||
|| steps
|
||||
.iter()
|
||||
.take_while(|s| !std::ptr::eq(*s, step))
|
||||
.any(|s| !matches!(s.procedure, Procedure::Frequency { .. } | Procedure::Buckle { .. }))
|
||||
{
|
||||
return Err(Error::Unsupported {
|
||||
format: FORMAT,
|
||||
what: format!(
|
||||
"step `{}`: a buckling step after a general step (Abaqus would buckle from that step's loaded state)",
|
||||
step.name
|
||||
),
|
||||
});
|
||||
}
|
||||
if shift.is_some() {
|
||||
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: a buckling shift", step.name) });
|
||||
}
|
||||
self.line(&format!("*STEP, NAME={}", step.name));
|
||||
self.line("*BUCKLE, EIGENSOLVER=LANCZOS");
|
||||
self.line(&format!("{modes}"));
|
||||
self.step_boundaries(model, r, &base_bcs, &step.boundary_conditions)?;
|
||||
let loads = StepLoads::new(model, r, step, &everything);
|
||||
self.step_loads(&loads, written)?;
|
||||
written = loads.written();
|
||||
}
|
||||
Procedure::Frequency { modes, min_hz, max_hz } => {
|
||||
self.line(&format!("*STEP, NAME={}", step.name));
|
||||
self.line("*FREQUENCY, EIGENSOLVER=LANCZOS");
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
//! - couplings: kinematic as `*CONSTRAINED_NODAL_RIGID_BODY` (the reference point first in its
|
||||
//! node set), distributing as `*CONSTRAINED_INTERPOLATION` (the reference point dependent on
|
||||
//! the set's translations, equal weights);
|
||||
//! - contact: a pair as `*CONTACT_AUTOMATIC_SURFACE_TO_SURFACE` between segment sets (the
|
||||
//! secondary surface first), general contact as `*CONTACT_AUTOMATIC_SINGLE_SURFACE` on a segment
|
||||
//! set or on every part (`surfatyp` 5); the static and kinetic coefficients and the decay
|
||||
//! coefficient (`fs`, `fd`, `dc`: the same exponential decay law) and the penalty scale on both
|
||||
//! sides (`sfsa`, `sfsb`, a factor on LS-DYNA's own default stiffness). LS-DYNA moves nodes
|
||||
//! that start inside the other surface out, as `adjust` does; a refusal is noted in a comment;
|
||||
//! - per step: `*CONTROL_IMPLICIT_GENERAL`, `*CONTROL_IMPLICIT_SOLUTION` (linear) or
|
||||
//! `*CONTROL_IMPLICIT_EIGENVALUE`, `*CONTROL_TERMINATION` (a static step is one implicit step
|
||||
//! from time 0 to 1 with every load ramped by one curve, `*DEFINE_CURVE`); a nonlinear static
|
||||
@@ -69,8 +75,8 @@ mod elements;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_model::{
|
||||
BoundaryCondition, CouplingKind, Diagnostic, Dofs, Elastic, Element, ElementKind, Hardening, Load, Material, Model, OutputVariable, Plastic,
|
||||
Procedure, Resolved, ResolvedDirection, Section, SectionKind, Step, Target,
|
||||
BoundaryCondition, ContactSurfaces, CouplingKind, Diagnostic, Dofs, Elastic, Element, ElementKind, Hardening, InitialPenetration, Load, Material,
|
||||
Model, OutputVariable, Plastic, Procedure, Resolved, ResolvedDirection, Section, SectionKind, Step, Target,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
@@ -177,6 +183,7 @@ fn write_with(model: &Model, r: &Resolved) -> Result<Written> {
|
||||
element_cards(&mut d, &plan)?;
|
||||
sets(&mut d, model, r, &plan)?;
|
||||
couplings(&mut d, model, r, &plan)?;
|
||||
contacts(&mut d, model, &plan)?;
|
||||
discrete(&mut d, model, r, &plan)?;
|
||||
if let Some(parts) = &plan.gravity_parts {
|
||||
d.line("*SET_PART_LIST");
|
||||
@@ -269,6 +276,10 @@ fn plan<'a>(model: &'a Model, r: &Resolved) -> Result<Plan<'a>> {
|
||||
node_sets.insert(c.set.as_str());
|
||||
}
|
||||
}
|
||||
// Contact surfaces are segment sets.
|
||||
for c in model.contacts().values() {
|
||||
segment_sets.extend(c.surfaces.sets());
|
||||
}
|
||||
let mut gravity_sets: BTreeSet<Option<&str>> = BTreeSet::new();
|
||||
for step in model.steps() {
|
||||
if matches!(step.procedure, Procedure::ExplicitDynamic(_)) {
|
||||
@@ -689,6 +700,42 @@ fn couplings(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Contacts (see the module docs): a pair as an automatic surface-to-surface contact between the
|
||||
/// segment sets, general contact as an automatic single-surface contact.
|
||||
fn contacts(d: &mut Deck, model: &Model, plan: &Plan) -> Result<()> {
|
||||
for (name, c) in model.contacts() {
|
||||
let sid = |set: &str| plan.sets.get(set).copied().ok_or_else(|| broken(format!("contact `{name}`: set `{set}`")));
|
||||
d.comment(&format!("contact `{name}`"));
|
||||
if c.initial_penetration == InitialPenetration::Refuse {
|
||||
d.comment("FieldCraft refuses nodes that start inside the other surface; LS-DYNA moves them out (as `adjust` does)");
|
||||
}
|
||||
match &c.surfaces {
|
||||
ContactSurfaces::Pair { main, secondary } => {
|
||||
d.line("*CONTACT_AUTOMATIC_SURFACE_TO_SURFACE");
|
||||
d.header(&["surfa", "surfb", "surfatyp", "surfbtyp"]);
|
||||
d.card([Field::from(sid(secondary)?), Field::from(sid(main)?), Field::from(0), Field::from(0)]);
|
||||
}
|
||||
ContactSurfaces::General { surface: Some(s) } => {
|
||||
d.line("*CONTACT_AUTOMATIC_SINGLE_SURFACE");
|
||||
d.header(&["surfa", "surfb", "surfatyp"]);
|
||||
d.card([Field::from(sid(s)?), Field::Blank, Field::from(0)]);
|
||||
}
|
||||
ContactSurfaces::General { surface: None } => {
|
||||
// SURFATYP 5: every part.
|
||||
d.line("*CONTACT_AUTOMATIC_SINGLE_SURFACE");
|
||||
d.header(&["surfa", "surfb", "surfatyp"]);
|
||||
d.card([Field::from(0), Field::Blank, Field::from(5)]);
|
||||
}
|
||||
}
|
||||
let f = c.friction;
|
||||
d.header(&["fs", "fd", "dc"]);
|
||||
d.card([Field::from(f.static_coefficient), Field::from(f.kinetic_coefficient()), Field::from(f.decay)]);
|
||||
d.header(&["sfsa", "sfsb"]);
|
||||
d.card([Field::from(c.penalty_scale), Field::from(c.penalty_scale)]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point masses, springs and dashpots (see the module docs).
|
||||
fn discrete(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan) -> Result<()> {
|
||||
if r.masses.is_empty() && r.springs.is_empty() {
|
||||
@@ -827,6 +874,15 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
|
||||
d.card([Field::from(1)]);
|
||||
}
|
||||
Procedure::NonlinearStatic(n) => {
|
||||
if n.arc_length.is_some() {
|
||||
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: arc-length control", step.name) });
|
||||
}
|
||||
if n.imperfection.is_some() {
|
||||
return Err(Error::Unsupported {
|
||||
format: FORMAT,
|
||||
what: format!("step `{}`: an imperfection from a buckling mode (FieldCraft computes the mode when it solves)", step.name),
|
||||
});
|
||||
}
|
||||
// Nonlinear with a stiffness reformation every iteration (full Newton), at most
|
||||
// `max_iterations` per increment; LS-DYNA's own convergence norms and line search.
|
||||
d.line("*CONTROL_IMPLICIT_SOLUTION");
|
||||
@@ -852,7 +908,7 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
|
||||
d.card([Field::Blank, Field::Blank, Field::Blank, Field::Blank, Field::from(1)]);
|
||||
}
|
||||
}
|
||||
Procedure::ExplicitDynamic(_) | Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {} => {
|
||||
Procedure::ExplicitDynamic(_) | Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {} | Procedure::Buckle { .. } => {
|
||||
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: {} steps", step.name, step.procedure.name()) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,9 @@ fn solution(steps: &[&Step]) -> std::result::Result<u32, String> {
|
||||
if let Some(s) = steps.iter().find(|s| matches!(s.procedure, Procedure::ExplicitDynamic(_))) {
|
||||
return Err(format!("step `{}`: explicit dynamic steps (Nastran's linear solutions have none)", s.name));
|
||||
}
|
||||
if let Some(s) = steps.iter().find(|s| matches!(s.procedure, Procedure::Buckle { .. })) {
|
||||
return Err(format!("step `{}`: buckling steps (Nastran's SOL 105; this writer writes SOL 101 and 103 so far)", s.name));
|
||||
}
|
||||
if let Some(s) = steps.iter().find(|s| matches!(s.procedure, Procedure::NonlinearStatic(_))) {
|
||||
return Err(format!("step `{}`: nonlinear static steps (Nastran's SOL 400; this writer writes SOL 101 and 103 so far)", s.name));
|
||||
}
|
||||
@@ -331,6 +334,9 @@ impl<'m> Deck<'m> {
|
||||
self.plan_constraints()?;
|
||||
self.plan_loads()?;
|
||||
self.problems.extend(crate::thermal_items(self.model));
|
||||
for name in self.model.contacts().keys() {
|
||||
self.problems.insert(format!("contact `{name}` (Nastran's linear solutions SOL 101 and 103 have no contact)"));
|
||||
}
|
||||
for sub in &mut self.subcases {
|
||||
if matches!(sub.step.procedure, Procedure::Frequency { .. }) {
|
||||
sub.method = Some(next_id(&mut self.next_sid)?);
|
||||
|
||||
@@ -828,3 +828,42 @@ fn nonlinear_plastic_steps_and_what_is_refused() {
|
||||
m.add_step(step("Again", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"]), None).unwrap();
|
||||
assert!(unsupported(&m).contains("plastic material `Steel` in a deck of 2 general steps"));
|
||||
}
|
||||
|
||||
/// A buckling step is `*BUCKLE` with its loads as the perturbation loads; after a general step
|
||||
/// (Abaqus's base state would be that step's) and with a shift it is refused by name.
|
||||
#[test]
|
||||
fn buckling_steps() {
|
||||
let mut m = cantilever_mesh();
|
||||
m.add_load("end", Load::Force { target: Target::Set("tip".into()), vector: [-100.0, 0.0, 0.0], distribution: Distribution::Total }).unwrap();
|
||||
m.add_step(step("Buckle", Procedure::Buckle { modes: 4, shift: None }, &["clamp"], &["end"]), None).unwrap();
|
||||
let deck = abaqus::write(&m).unwrap();
|
||||
assert!(deck.contains("*BUCKLE, EIGENSOLVER=LANCZOS\n4\n") && deck.contains("*CLOAD"), "{deck}");
|
||||
let mut shifted = m.clone();
|
||||
shifted.replace_step(step("Buckle", Procedure::Buckle { modes: 4, shift: Some(2.0) }, &["clamp"], &["end"])).unwrap();
|
||||
assert!(abaqus::write(&shifted).unwrap_err().to_string().contains("shift"));
|
||||
m.add_step(step("Bend", Procedure::Static {}, &["clamp"], &["end"]), Some("Buckle")).unwrap();
|
||||
assert!(abaqus::write(&m).unwrap_err().to_string().contains("after a general step"));
|
||||
assert!(fieldcraft_decks::lsdyna::write(&m).is_err() && fieldcraft_decks::nastran::write(&m).is_err());
|
||||
}
|
||||
|
||||
/// An arc-length step is `*STATIC, RIKS` with the arcs, the maximum load factor and the
|
||||
/// displacement that end it; a spherical constraint is refused, and LS-DYNA refuses the step.
|
||||
#[test]
|
||||
fn arc_length_steps() {
|
||||
use fieldcraft_model::{ArcConstraint, ArcLength, DisplacementLimit, NonlinearStatic};
|
||||
let mut m = cantilever_mesh();
|
||||
m.add_load("end", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -100.0], distribution: Distribution::Total }).unwrap();
|
||||
let arc = ArcLength {
|
||||
max_load_factor: 3.0,
|
||||
displacement_limit: Some(DisplacementLimit { node: 2, direction: 3, displacement: -5.0 }),
|
||||
..ArcLength::default()
|
||||
};
|
||||
let settings = NonlinearStatic { arc_length: Some(arc.clone()), ..NonlinearStatic::default() };
|
||||
m.add_step(step("Snap", Procedure::NonlinearStatic(settings), &["clamp"], &["end"]), None).unwrap();
|
||||
let deck = abaqus::write(&m).unwrap();
|
||||
assert!(deck.contains("*STATIC, RIKS\n0.1, 1.0, 1.0E-5, 1.0, 3.0, 2, 3, -5.0\n"), "{deck}");
|
||||
assert!(fieldcraft_decks::lsdyna::write(&m).is_err());
|
||||
let spherical = NonlinearStatic { arc_length: Some(ArcLength { constraint: ArcConstraint::Spherical, ..arc }), ..NonlinearStatic::default() };
|
||||
m.replace_step(step("Snap", Procedure::NonlinearStatic(spherical), &["clamp"], &["end"])).unwrap();
|
||||
assert!(abaqus::write(&m).is_err());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Contact definitions in every deck format: Abaqus surface interactions, contact pairs and
|
||||
//! general contact; LS-DYNA automatic contacts; Nastran refuses them by name.
|
||||
|
||||
use fieldcraft_decks::{Error, abaqus, lsdyna, nastran};
|
||||
use fieldcraft_model::{
|
||||
BoundaryCondition, Contact, ContactSurfaces, Elastic, ElementFace, ElementKind, Friction, InitialPenetration, Material, Model, Procedure,
|
||||
Section, SectionKind, Set, Step, Target, UnitSystem,
|
||||
};
|
||||
|
||||
/// Two unit hex8 cubes, the second on top of the first (separate nodes), with face sets for the
|
||||
/// touching faces and a static step.
|
||||
fn model() -> Model {
|
||||
let mut m = Model::default();
|
||||
m.set_units(UnitSystem::MmTonneS);
|
||||
let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
|
||||
for cube in 0..2u32 {
|
||||
for (i, c) in corners.iter().enumerate() {
|
||||
m.set_node(8 * cube + i as u32 + 1, [c[0], c[1], c[2] + cube as f64]).unwrap();
|
||||
}
|
||||
m.set_element(cube + 1, ElementKind::Hex8, (8 * cube + 1..=8 * cube + 8).collect()).unwrap();
|
||||
}
|
||||
m.add_set("all", Set { elements: vec![1, 2], ..Set::default() }).unwrap();
|
||||
m.add_set("lower", Set { faces: vec![ElementFace { element: 1, face: 2 }], ..Set::default() }).unwrap();
|
||||
m.add_set("upper", Set { faces: vec![ElementFace { element: 2, face: 1 }], ..Set::default() }).unwrap();
|
||||
m.add_set("base", Set { nodes: vec![1, 2, 3, 4], ..Set::default() }).unwrap();
|
||||
m.add_material(
|
||||
"Steel",
|
||||
Material { elastic: Some(Elastic::Isotropic { young: 210_000.0, poisson: 0.3 }), density: Some(7.85e-9), ..Material::default() },
|
||||
)
|
||||
.unwrap();
|
||||
m.add_section("all", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation: Default::default() })
|
||||
.unwrap();
|
||||
m.add_boundary_condition("fix", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
|
||||
let mut step = Step::new("Load", Procedure::Static {});
|
||||
step.boundary_conditions.insert("fix".into());
|
||||
m.add_step(step, None).unwrap();
|
||||
let pair = Contact {
|
||||
friction: Friction { static_coefficient: 0.3, kinetic: Some(0.2), decay: 5.0 },
|
||||
initial_penetration: InitialPenetration::Adjust,
|
||||
..Contact::new(ContactSurfaces::Pair { main: "lower".into(), secondary: "upper".into() })
|
||||
};
|
||||
m.add_contact("stack", pair).unwrap();
|
||||
m.add_contact("everything", Contact { friction: Friction::coulomb(0.1), ..Contact::new(ContactSurfaces::General { surface: None }) }).unwrap();
|
||||
m.add_contact("fold", Contact { penalty_scale: 0.5, ..Contact::new(ContactSurfaces::General { surface: Some("upper".into()) }) }).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abaqus_writes_interactions_pairs_and_general_contact() {
|
||||
let deck = abaqus::write(&model()).unwrap();
|
||||
for expected in [
|
||||
"*SURFACE, TYPE=ELEMENT, NAME=lower\n1, S2\n",
|
||||
"*SURFACE INTERACTION, NAME=stack-interaction\n*FRICTION, EXPONENTIAL DECAY\n0.3, 0.2, 5.0\n",
|
||||
"*CONTACT PAIR, INTERACTION=stack-interaction, TYPE=SURFACE TO SURFACE, ADJUST=0.0\nupper, lower\n",
|
||||
"*SURFACE INTERACTION, NAME=everything-interaction\n*FRICTION\n0.1\n",
|
||||
"*SURFACE INTERACTION, NAME=fold-interaction\n*FRICTION\n0.0\n",
|
||||
"*CONTACT\n*CONTACT INCLUSIONS, ALL EXTERIOR\n*CONTACT INCLUSIONS\nupper,\n*CONTACT PROPERTY ASSIGNMENT\n, , everything-interaction\nupper, upper, fold-interaction\n",
|
||||
"** contact `fold`: penalty scale 0.5",
|
||||
] {
|
||||
assert!(deck.contains(expected), "{expected}\n{deck}");
|
||||
}
|
||||
// Model data: before the first step.
|
||||
assert!(deck.find("*CONTACT PAIR") < deck.find("*STEP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsdyna_writes_automatic_contacts_on_segment_sets() {
|
||||
let deck = lsdyna::write(&model()).unwrap().text;
|
||||
let after = |keyword: &str| -> Vec<String> {
|
||||
let at = deck.find(keyword).unwrap_or_else(|| panic!("{keyword}\n{deck}"));
|
||||
deck[at..].lines().skip(1).filter(|l| !l.starts_with('$')).take(3).map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).collect()
|
||||
};
|
||||
// Sets are numbered in name order: all 1, base 2, lower 3, upper 4.
|
||||
assert_eq!(after("*CONTACT_AUTOMATIC_SURFACE_TO_SURFACE"), ["4 3 0 0", "0.3 0.2 5.0", "1.0 1.0"], "{deck}");
|
||||
let single: Vec<&str> = deck.match_indices("*CONTACT_AUTOMATIC_SINGLE_SURFACE").map(|(i, _)| &deck[i..]).collect();
|
||||
assert_eq!(single.len(), 2, "{deck}");
|
||||
let cards = |text: &str| -> Vec<String> {
|
||||
text.lines().skip(1).filter(|l| !l.starts_with('$')).take(3).map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).collect()
|
||||
};
|
||||
// `everything` (every part), then `fold` (the upper face's segment set).
|
||||
assert_eq!(cards(single[0]), ["0 5", "0.1 0.1 0.0", "1.0 1.0"], "{deck}");
|
||||
assert_eq!(cards(single[1]), ["4 0", "0.0 0.0 0.0", "0.5 0.5"], "{deck}");
|
||||
for set in ["lower", "upper"] {
|
||||
assert!(deck.contains(&format!("*SET_SEGMENT_TITLE\n{set}\n")), "{set}\n{deck}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nastran_refuses_contact_by_name() {
|
||||
match nastran::write(&model()) {
|
||||
Err(Error::Unsupported { what, .. }) => {
|
||||
for name in ["everything", "fold", "stack"] {
|
||||
assert!(what.contains(&format!("contact `{name}`")), "{what}");
|
||||
}
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -415,9 +415,29 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
&["model.coupling.edit", "model.coupling.delete"],
|
||||
),
|
||||
soon("interaction.tie", "Tie", Interactions, "Constraints", "M7", "Tie non-matching meshes together"),
|
||||
soon("interaction.contact.general", "General Contact", Interactions, "Contact", "M7", "Contact between everything that touches, with friction"),
|
||||
soon("interaction.contact.pair", "Contact Pair", Interactions, "Contact", "M7", "Contact between two surfaces"),
|
||||
soon("interaction.contact.find", "Find Contacts", Interactions, "Contact", "M7", "Detect contact pairs automatically"),
|
||||
ready_more(
|
||||
"interaction.contact.general",
|
||||
"General Contact",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"Contact between everything that may touch, or self contact of a surface, with friction (explicit dynamics)",
|
||||
&["interaction.contact.edit", "interaction.contact.delete"],
|
||||
),
|
||||
ready_more(
|
||||
"interaction.contact.pair",
|
||||
"Contact Pair",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"Contact between two surfaces, with friction (explicit dynamics)",
|
||||
&["interaction.contact.edit", "interaction.contact.delete"],
|
||||
),
|
||||
ready(
|
||||
"interaction.contact.find",
|
||||
"Find Contacts",
|
||||
Interactions,
|
||||
"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"),
|
||||
soon("interaction.bolt", "Bolt Load", Interactions, "Connectors", "M7", "Bolt pretension"),
|
||||
ready_more(
|
||||
@@ -450,8 +470,8 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Static",
|
||||
Solve,
|
||||
"Implicit",
|
||||
"Linear static (or coupled temperature–displacement) solve of a step with FieldCraft's own solver; nonlinear static and heat transfer in the drop-down",
|
||||
&["solve.nonlinear", "solve.heat"],
|
||||
"Linear static (or coupled temperature–displacement) solve of a step with FieldCraft's own solver; nonlinear static, buckling and heat transfer in the drop-down",
|
||||
&["solve.nonlinear", "solve.buckle", "solve.heat"],
|
||||
),
|
||||
ready("solve.frequency", "Frequency", Solve, "Implicit", "Natural frequencies and mode shapes of a frequency step"),
|
||||
ready(
|
||||
|
||||
@@ -18,6 +18,7 @@ pub const BLOCK: CommandSpec = CommandSpec {
|
||||
id: "mesh.block",
|
||||
label: "Mesh Block",
|
||||
params: r#"{"body": "Plate", "kind": "hex8|hex20|tet4|tet10|wedge6", "corners": [[x, y, z] × 8] (hex8 order: 1-2-3-4 counter-clockwise seen from 5-6-7-8), "divisions": [n12, n14, n15] (elements along 1→2, 1→4, 1→5), "bias": [1, 1, 1] (optional: last over first element size along each direction), "edges": [{"corners": [1, 2], "arc": {"center": [x, y, z]}} | {"corners": [2, 3], "ellipse": {"center": [x, y, z], "axes": [[a], [b]] (perpendicular)}} | {"corners": [5, 6], "name": "tip_edge"}] (optional: curved edges, the shorter arc between the corners, and edge names), "faces": {"1": "bottom", "4": "tip"} (optional: names of block faces, numbered as hex8 faces: 1 = 1-2-3-4, 2 = 5-6-7-8, 3 = 1-2-6-5, 4 = 2-3-7-6, 5 = 3-4-8-7, 6 = 4-1-5-8), "vertices": {"1": "A"} (optional: names of corners), "merge": distance (optional, default 1e-6 of the block's diagonal; 0 never joins)} — adds a mapped mesh of the block (transfinite interpolation of its edges; quadratic nodes on the curves), uses existing nodes within `merge` of new ones so blocks that share a face join (the shared face must have the same elements on both sides, or nothing changes), and tags the elements with the body and element faces, edges and nodes with the named faces, edges and vertices. 2D: "kind": "quad4|quad8|tri3|tri6", four "corners" at z = 0 (counter-clockwise from +z), "divisions": [n12, n14], "bias": [r, r], "face": "area" (the face of the body the elements fill, default `area`), edges and vertices numbered among the four corners; for plane stress, plane strain and axisymmetric sections"#,
|
||||
form: Some(super::forms::BLOCK),
|
||||
kind: CommandKind::Model,
|
||||
run: block_mesh,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
|
||||
//! search for surfaces that touch.
|
||||
//!
|
||||
//! A contact's surfaces are sets of element faces ([`fieldcraft_model::contact`]). The commands
|
||||
//! take the friction, penalty scale and initial-penetration choice of the model's form; `edit`
|
||||
//! replaces a definition with the model's JSON form, `delete` removes it.
|
||||
//!
|
||||
//! `find` looks for the bodies of the mesh that touch: the solid elements are split into
|
||||
//! connected parts (elements sharing a node belong to the same part), and a node of one part's
|
||||
//! exterior faces within `tolerance` of an exterior face of another part makes the two parts
|
||||
//! touch there. Each touching pair is reported with the faces of each side that are close to the
|
||||
//! other; with `create`, each becomes two face sets and a contact pair.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_fem::contact::{Grid, bounds, closest_point};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Set};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The options every contact takes, from the parameters.
|
||||
fn contact(p: &Value, surfaces: ContactSurfaces) -> Result<Contact> {
|
||||
Ok(Contact {
|
||||
surfaces,
|
||||
friction: opt::<Friction>(p, "friction")?.unwrap_or(Friction::NONE),
|
||||
penalty_scale: opt::<f64>(p, "penalty_scale")?.unwrap_or(1.0),
|
||||
initial_penetration: opt::<InitialPenetration>(p, "initial_penetration")?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A required name-valued parameter.
|
||||
fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key).and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("`{key}` must be a set name")))
|
||||
}
|
||||
|
||||
pub const PAIR: CommandSpec = CommandSpec {
|
||||
id: "interaction.contact.pair",
|
||||
label: "Contact Pair",
|
||||
params: concat!(
|
||||
r#"{"name": "impact", "main": "plate" (a set of element faces), "secondary": "ball" (another), "#,
|
||||
r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the stiffest penalty that leaves the time increment alone), "initial_penetration": "adjust" (move nodes that start inside onto the surface, strain-free, and report them) | "refuse" (refuse the analysis, naming the first)"#,
|
||||
r#"} — contact between two surfaces, each one's nodes kept out of the other's faces (explicit dynamics; fails if the name is taken)"#
|
||||
),
|
||||
form: Some(super::forms::CONTACT_PAIR),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surfaces = ContactSurfaces::Pair { main: text(p, "main")?.to_string(), secondary: text(p, "secondary")?.to_string() };
|
||||
s.model.add_contact(n, contact(p, surfaces)?)?;
|
||||
Ok(json!({"name": n}))
|
||||
},
|
||||
};
|
||||
|
||||
pub const GENERAL: CommandSpec = CommandSpec {
|
||||
id: "interaction.contact.general",
|
||||
label: "General Contact",
|
||||
params: concat!(
|
||||
r#"{"name": "all", "surface": "skin" (optional: a set of element faces, each in contact with every other: self contact; default every exterior face of the solids), "#,
|
||||
r#""friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb, μ = μk + (μs − μk) e^(−decay × slip rate); default frictionless), "penalty_scale": 1 (0 < scale ≤ 1: a factor on the stiffest penalty that leaves the time increment alone), "initial_penetration": "adjust" (move nodes that start inside onto the surface, strain-free, and report them) | "refuse" (refuse the analysis, naming the first)"#,
|
||||
r#"} — single-surface contact of everything that may touch (explicit dynamics; fails if the name is taken)"#
|
||||
),
|
||||
form: Some(super::forms::CONTACT_GENERAL),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surface = match p.get("surface") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(text(p, "surface")?.to_string()),
|
||||
};
|
||||
s.model.add_contact(n, contact(p, ContactSurfaces::General { surface })?)?;
|
||||
Ok(json!({"name": n}))
|
||||
},
|
||||
};
|
||||
|
||||
pub const EDIT: CommandSpec = CommandSpec {
|
||||
id: "interaction.contact.edit",
|
||||
label: "Edit Contact",
|
||||
params: r#"{"name": "impact", "surfaces": {"type": "pair", "main": "plate", "secondary": "ball"} | {"type": "general", "surface": "skin" (optional)}, "friction": {…}, "penalty_scale": 1, "initial_penetration": "adjust|refuse"} — replaces the contact of that name (the model's form; see interaction.contact.pair)"#,
|
||||
form: Some(super::forms::CONTACT_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params("the parameters of a contact are a JSON object".into()))?;
|
||||
obj.remove("name");
|
||||
let c: Contact = serde_json::from_value(Value::Object(obj)).map_err(|e| EngineError::Params(format!("contact: {e}")))?;
|
||||
s.model.replace_contact(n, c)?;
|
||||
Ok(json!({"name": n}))
|
||||
},
|
||||
};
|
||||
|
||||
pub const DELETE: CommandSpec = CommandSpec {
|
||||
id: "interaction.contact.delete",
|
||||
label: "Delete Contact",
|
||||
params: r#"{"name": "impact"}"#,
|
||||
form: Some(super::forms::CONTACT_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_contact(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
};
|
||||
|
||||
pub const FIND: CommandSpec = CommandSpec {
|
||||
id: "interaction.contact.find",
|
||||
label: "Find Contacts",
|
||||
params: concat!(
|
||||
r#"{"tolerance": 0.1 (distance within which surfaces touch; default a tenth of the mean exterior face size), "create": false (true: add two face sets and a contact pair per touching pair of parts, named find-N-main, find-N-secondary and find-N), "#,
|
||||
r#""friction", "penalty_scale", "initial_penetration" (of the created pairs, as interaction.contact.pair)} — the parts of the mesh (solid elements connected through their nodes) whose exterior faces come within the tolerance of each other"#
|
||||
),
|
||||
form: Some(super::forms::CONTACT_FIND),
|
||||
kind: CommandKind::Model,
|
||||
run: find,
|
||||
};
|
||||
|
||||
/// Most touching part pairs `find` reports.
|
||||
pub const MAX_FOUND: usize = fieldcraft_model::contact::MAX_CONTACTS;
|
||||
|
||||
/// Most point–face checks `find` makes.
|
||||
pub const MAX_CHECKS: usize = 200_000_000;
|
||||
|
||||
/// The connected parts of the solid elements: per element label, its part (numbered from 0 in
|
||||
/// the order of their lowest element label).
|
||||
fn parts(model: &Model) -> BTreeMap<u32, usize> {
|
||||
// Union–find over the node labels of the solid elements.
|
||||
let solids: Vec<(u32, &[u32])> =
|
||||
model.elements().iter().filter(|(_, e)| e.kind.dimension() == 3).map(|(l, e)| (*l, e.nodes.as_slice())).collect();
|
||||
let mut index: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for (_, nodes) in &solids {
|
||||
for n in *nodes {
|
||||
let next = index.len();
|
||||
index.entry(*n).or_insert(next);
|
||||
}
|
||||
}
|
||||
let mut parent: Vec<usize> = (0..index.len()).collect();
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&p) = parent.get(i) {
|
||||
if p == i {
|
||||
break;
|
||||
}
|
||||
let grand = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parent.get_mut(i) {
|
||||
*slot = grand;
|
||||
}
|
||||
i = grand;
|
||||
}
|
||||
i
|
||||
}
|
||||
for (_, nodes) in &solids {
|
||||
let mut it = nodes.iter().filter_map(|n| index.get(n).copied());
|
||||
let Some(first) = it.next() else { continue };
|
||||
for other in it {
|
||||
let (a, b) = (root(&mut parent, first), root(&mut parent, other));
|
||||
if a != b {
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if let Some(slot) = parent.get_mut(hi) {
|
||||
*slot = lo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Number the parts in element order.
|
||||
let mut numbers: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut out = BTreeMap::new();
|
||||
for (label, nodes) in &solids {
|
||||
let Some(r) = nodes.first().and_then(|n| index.get(n).copied()).map(|i| root(&mut parent, i)) else { continue };
|
||||
let next = numbers.len();
|
||||
out.insert(*label, *numbers.entry(r).or_insert(next));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let model = &s.model;
|
||||
let faces = model.exterior_faces();
|
||||
let part_of = parts(model);
|
||||
// Each exterior face's corner coordinates and part.
|
||||
let mut face_xyz: Vec<Vec<[f64; 3]>> = Vec::with_capacity(faces.len());
|
||||
for f in &faces {
|
||||
let labels = model.face_nodes(*f).unwrap_or_default();
|
||||
let corners = if labels.len() == 3 || labels.len() == 6 { 3 } else { 4 };
|
||||
face_xyz.push(labels.iter().take(corners).filter_map(|l| model.node(*l)).map(|n| n.xyz).collect());
|
||||
}
|
||||
let size = {
|
||||
let sizes: Vec<f64> = face_xyz
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
let b = bounds(x, 0.0);
|
||||
let d = (0..3).map(|k| b[1][k] - b[0][k]).fold(0.0, f64::max);
|
||||
d.is_finite().then_some(d)
|
||||
})
|
||||
.collect();
|
||||
if sizes.is_empty() { 0.0 } else { sizes.iter().sum::<f64>() / sizes.len() as f64 }
|
||||
};
|
||||
let tolerance = opt::<f64>(p, "tolerance")?.unwrap_or(0.1 * size);
|
||||
if !(tolerance >= 0.0 && tolerance.is_finite()) {
|
||||
return Err(EngineError::Params(format!("`tolerance` must be a finite distance ≥ 0, got {tolerance}")));
|
||||
}
|
||||
let create = opt::<bool>(p, "create")?.unwrap_or(false);
|
||||
let options = contact(p, ContactSurfaces::General { surface: None })?;
|
||||
options.validate()?;
|
||||
|
||||
let boxes: Vec<_> = face_xyz.iter().map(|x| bounds(x, tolerance)).collect();
|
||||
let grid = Grid::new(&boxes, 4 * faces.len() + 64);
|
||||
let part = |c: usize| faces.get(c).and_then(|f| part_of.get(&f.element).copied());
|
||||
// Each face's centroid and outward normal there.
|
||||
let centroid = |x: &[[f64; 3]]| -> [f64; 3] { [0, 1, 2].map(|k| x.iter().map(|p| p[k]).sum::<f64>() / x.len().max(1) as f64) };
|
||||
let normals: Vec<Option<[f64; 3]>> = face_xyz.iter().map(|x| closest_point(x, centroid(x)).map(|q| q.normal)).collect();
|
||||
// (lower part, higher part) → the faces of each side that touch the other: a face whose
|
||||
// centroid or a corner is within the tolerance of a face of the other part facing it
|
||||
// (normals more than 60° apart from parallel).
|
||||
let mut touching: BTreeMap<(usize, usize), [std::collections::BTreeSet<usize>; 2]> = BTreeMap::new();
|
||||
let mut checks = 0usize;
|
||||
for (c, xyz) in face_xyz.iter().enumerate() {
|
||||
let (Some(k), Some(normal)) = (part(c), normals.get(c).copied().flatten()) else { continue };
|
||||
for point in xyz.iter().copied().chain(std::iter::once(centroid(xyz))) {
|
||||
let candidates = grid.candidates(point);
|
||||
checks += candidates.len();
|
||||
if checks > MAX_CHECKS {
|
||||
return Err(EngineError::Params(format!(
|
||||
"the tolerance {tolerance:e} puts too many faces near each other (more than {MAX_CHECKS} checks); lower it"
|
||||
)));
|
||||
}
|
||||
for &d in candidates {
|
||||
let d = d as usize;
|
||||
let Some(other) = part(d).filter(|o| *o != k) else { continue };
|
||||
let facing = normals.get(d).copied().flatten().is_some_and(|n| (0..3).map(|i| n[i] * normal[i]).sum::<f64>() < -0.5);
|
||||
let near = facing && face_xyz.get(d).and_then(|x| closest_point(x, point)).is_some_and(|q| q.distance <= tolerance);
|
||||
if near {
|
||||
let key = (k.min(other), k.max(other));
|
||||
touching.entry(key).or_default()[usize::from(k != key.0)].insert(c);
|
||||
if touching.len() > MAX_FOUND {
|
||||
return Err(EngineError::Params(format!("more than {MAX_FOUND} pairs of parts touch; lower the tolerance")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let face_list = |set: &std::collections::BTreeSet<usize>| -> Vec<ElementFace> { set.iter().filter_map(|c| faces.get(*c).copied()).collect() };
|
||||
let mut found = Vec::new();
|
||||
let mut created = Vec::new();
|
||||
for (n, ((a, b), sides)) in touching.iter().enumerate() {
|
||||
let (main, secondary) = (face_list(&sides[0]), face_list(&sides[1]));
|
||||
let mut entry = json!({"parts": [a, b], "faces": [main.len(), secondary.len()]});
|
||||
if create {
|
||||
let names = [format!("find-{}-main", n + 1), format!("find-{}-secondary", n + 1), format!("find-{}", n + 1)];
|
||||
created.push((names, main, secondary));
|
||||
entry["contact"] = json!(format!("find-{}", n + 1));
|
||||
}
|
||||
found.push(entry);
|
||||
}
|
||||
let part_count = part_of.values().max().map_or(0, |m| m + 1);
|
||||
for ([main_name, secondary_name, contact_name], main, secondary) in created {
|
||||
s.model.add_set(&main_name, Set { faces: main, ..Set::default() })?;
|
||||
s.model.add_set(&secondary_name, Set { faces: secondary, ..Set::default() })?;
|
||||
let surfaces = ContactSurfaces::Pair { main: main_name, secondary: secondary_name };
|
||||
s.model.add_contact(&contact_name, Contact { surfaces, ..options.clone() })?;
|
||||
}
|
||||
Ok(json!({"parts": part_count, "exterior_faces": faces.len(), "tolerance": tolerance, "touching": found}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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) {
|
||||
let mut next_node = 1u32;
|
||||
let mut next_element = 1u32;
|
||||
for (z0, x0) in [(0.0, 0.0), (1.0, 0.0), (0.0, 10.0)] {
|
||||
let base = next_node;
|
||||
for k in 0..3 {
|
||||
for j in 0..3 {
|
||||
for i in 0..3 {
|
||||
let xyz = [x0 + 0.5 * i as f64, 0.5 * j as f64, z0 + 0.5 * k as f64];
|
||||
s.execute("mesh.node.add", &json!({"label": next_node, "xyz": xyz})).unwrap();
|
||||
next_node += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = |i: u32, j: u32, k: u32| base + i + 3 * j + 9 * k;
|
||||
for k in 0..2 {
|
||||
for j in 0..2 {
|
||||
for i in 0..2 {
|
||||
let nodes = [
|
||||
n(i, j, k),
|
||||
n(i + 1, j, k),
|
||||
n(i + 1, j + 1, k),
|
||||
n(i, j + 1, k),
|
||||
n(i, j, k + 1),
|
||||
n(i + 1, j, k + 1),
|
||||
n(i + 1, j + 1, k + 1),
|
||||
n(i, j + 1, k + 1),
|
||||
];
|
||||
s.execute("mesh.element.add", &json!({"label": next_element, "kind": "hex8", "nodes": nodes})).unwrap();
|
||||
next_element += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_are_created_edited_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
stacked(&mut s);
|
||||
s.execute("model.set.create", &json!({"name": "a", "faces": [{"element": 5, "face": 2}]})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "b", "faces": [{"element": 9, "face": 1}]})).unwrap();
|
||||
s.execute("interaction.contact.pair", &json!({"name": "ab", "main": "a", "secondary": "b", "friction": {"static": 0.2}})).unwrap();
|
||||
let c = s.model.contact("ab").unwrap();
|
||||
assert_eq!(c.friction, Friction::coulomb(0.2));
|
||||
assert!(s.execute("interaction.contact.pair", &json!({"name": "ab", "main": "a", "secondary": "b"})).is_err(), "taken");
|
||||
assert!(s.execute("interaction.contact.pair", &json!({"name": "aa", "main": "a", "secondary": "a"})).is_err(), "self pair");
|
||||
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "nope"})).is_err());
|
||||
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "b", "penalty_scale": 2})).is_err());
|
||||
assert!(s.execute("interaction.contact.pair", &json!({"name": "x", "main": "a", "secondary": "b", "friction": {"static": -1}})).is_err());
|
||||
s.execute("interaction.contact.general", &json!({"name": "all"})).unwrap();
|
||||
s.execute("interaction.contact.general", &json!({"name": "self", "surface": "a", "initial_penetration": "refuse"})).unwrap();
|
||||
assert_eq!(s.model.contact("self").unwrap().initial_penetration, InitialPenetration::Refuse);
|
||||
s.execute("interaction.contact.edit", &json!({"name": "all", "surfaces": {"type": "general", "surface": "b"}, "penalty_scale": 0.5}))
|
||||
.unwrap();
|
||||
assert_eq!(s.model.contact("all").unwrap().surfaces, ContactSurfaces::General { surface: Some("b".into()) });
|
||||
assert!(s.execute("interaction.contact.edit", &json!({"name": "nope", "surfaces": {"type": "general"}})).is_err());
|
||||
assert!(s.execute("model.set.delete", &json!({"name": "a"})).unwrap_err().to_string().contains("contact `ab`"));
|
||||
s.execute("model.set.create", &json!({"name": "n", "nodes": [1, 2]})).unwrap();
|
||||
s.execute("interaction.contact.general", &json!({"name": "bad", "surface": "n"})).unwrap();
|
||||
let check = s.execute("model.check", &json!({})).unwrap();
|
||||
assert!(check.to_string().contains("set `n` has no element faces"), "{check}");
|
||||
for n in ["ab", "all", "self", "bad"] {
|
||||
s.execute("interaction.contact.delete", &json!({"name": n})).unwrap();
|
||||
}
|
||||
assert!(s.model.contacts().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_reports_and_creates_the_touching_parts() {
|
||||
let mut s = Session::default();
|
||||
stacked(&mut s);
|
||||
let r = s.execute("interaction.contact.find", &json!({})).unwrap();
|
||||
assert_eq!(r["parts"], 3, "{r}");
|
||||
assert_eq!(r["exterior_faces"], 3 * 24, "{r}");
|
||||
// The two stacked cubes touch over four faces each; the far one touches nothing.
|
||||
assert_eq!(r["touching"], json!([{"parts": [0, 1], "faces": [4, 4]}]), "{r}");
|
||||
assert!(s.model.contacts().is_empty(), "nothing created without `create`");
|
||||
let r = s.execute("interaction.contact.find", &json!({"create": true, "friction": {"static": 0.1}})).unwrap();
|
||||
assert_eq!(r["touching"][0]["contact"], "find-1");
|
||||
let c = s.model.contact("find-1").unwrap();
|
||||
assert_eq!(c.friction, Friction::coulomb(0.1));
|
||||
let main = s.model.resolve_set("find-1-main").unwrap();
|
||||
assert!(main.faces.iter().all(|f| (5..=8).contains(&f.element) && f.face == 2) && main.faces.len() == 4, "{main:?}");
|
||||
let secondary = s.model.resolve_set("find-1-secondary").unwrap();
|
||||
assert!(secondary.faces.iter().all(|f| (9..=12).contains(&f.element) && f.face == 1) && secondary.faces.len() == 4, "{secondary:?}");
|
||||
// A tight tolerance on a gap finds nothing; a second create reuses no names.
|
||||
assert!(s.execute("interaction.contact.find", &json!({"create": true})).is_err(), "the names are taken");
|
||||
for bad in [json!({"tolerance": -1}), json!({"tolerance": "x"}), json!({"tolerance": f64::MAX, "create": "yes"})] {
|
||||
assert!(s.execute("interaction.contact.find", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let empty = s.execute("interaction.contact.find", &json!({"tolerance": 0})).unwrap();
|
||||
assert_eq!(empty["touching"].as_array().map(Vec::len), Some(1), "touching faces are at distance 0: {empty}");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub const WRITE: CommandSpec = CommandSpec {
|
||||
id: "deck.write",
|
||||
label: "Write Solver Deck",
|
||||
params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: fieldcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); LS-DYNA writes several steps as *CASE analyses (run it with CASE); fails naming every error of the model check and everything the format cannot express"#,
|
||||
form: Some(super::forms::DECK),
|
||||
kind: CommandKind::Session,
|
||||
run: write,
|
||||
};
|
||||
|
||||
@@ -25,12 +25,13 @@ fn fields<T: DeserializeOwned>(p: &Value, what: &str) -> Result<T> {
|
||||
|
||||
/// Create, edit and delete commands for one kind of definition.
|
||||
macro_rules! definition_commands {
|
||||
($what:literal, $ty:ty, $id:literal, $label:literal, $doc:literal, $add:ident, $replace:ident, $remove:ident,
|
||||
($what:literal, $ty:ty, $id:literal, $label:literal, $doc:literal, $form:expr, $delete_form:expr, $add:ident, $replace:ident, $remove:ident,
|
||||
$CREATE:ident, $EDIT:ident, $DELETE:ident) => {
|
||||
pub const $CREATE: CommandSpec = CommandSpec {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
@@ -42,6 +43,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".edit"),
|
||||
label: concat!("Edit ", $label),
|
||||
params: concat!($doc, " — replaces the existing definition of that name"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
@@ -53,6 +55,7 @@ macro_rules! definition_commands {
|
||||
id: concat!("model.", $id, ".delete"),
|
||||
label: concat!("Delete ", $label),
|
||||
params: r#"{"name": "…"} — refused while another definition refers to it"#,
|
||||
form: Some($delete_form),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
@@ -68,6 +71,8 @@ definition_commands!(
|
||||
"set",
|
||||
"Set",
|
||||
r#"{"name": "root", "geometry": [{"body": "Body1", "face": "F2:top"}, {"body": "Body1", "edge": "…"}, {"body": "Body1", "vertex": "…"}, {"body": "Body1"}], "nodes": [labels], "elements": [labels], "faces": [{"element": 1, "face": 3}], "edges": [{"element": 1, "edge": 2}]} — any of the member lists (element edges: where a 2D model's surface loads act); geometry members resolve against the mesh's geometry tags after every remesh (a face name also selects its split pieces `name#0`, `name#1`, …)"#,
|
||||
super::forms::SET,
|
||||
super::forms::SET_DELETE,
|
||||
add_set,
|
||||
replace_set,
|
||||
remove_set,
|
||||
@@ -82,6 +87,8 @@ definition_commands!(
|
||||
"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"#,
|
||||
super::forms::MATERIAL,
|
||||
super::forms::MATERIAL_DELETE,
|
||||
add_material,
|
||||
replace_material,
|
||||
remove_material,
|
||||
@@ -103,6 +110,7 @@ pub const MATERIAL_PLASTICITY: CommandSpec = CommandSpec {
|
||||
id: "model.material.plasticity",
|
||||
label: "Plasticity",
|
||||
params: r#"{"name": "Steel", "plastic": {"type": "bilinear", "yield_stress": 250, "hardening_modulus": 1000, "hardening": "isotropic|kinematic"} | {"type": "table", "points": [{"strain": 0, "stress": 250}, {"strain": 0.1, "stress": 300}]} | {"type": "johnson_cook", "a": 792, "b": 510, "n": 0.26, "c": 0.014, "m": 1.03, "reference_strain_rate": 1, "room_temperature": 294, "melt_temperature": 1793, "specific_heat": 4.77e8, "taylor_quinney": 0.9} | null} — von Mises plasticity of an isotropic material (null removes it); explicit dynamics uses it, the linear solver the elastic constants alone"#,
|
||||
form: Some(super::forms::MATERIAL_PLASTICITY),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
@@ -116,6 +124,7 @@ pub const MATERIAL_DAMAGE: CommandSpec = CommandSpec {
|
||||
id: "model.material.damage",
|
||||
label: "Damage",
|
||||
params: r#"{"name": "Steel", "damage": {"type": "johnson_cook", "d1": 0.05, "d2": 3.44, "d3": -2.12, "d4": 0.002, "d5": 0.61} | null, "erosion": {"plastic_strain": 1.0} | null} — damage to failure and element erosion of a plastic material (each key optional; null removes it); a failed element is eroded in explicit dynamics"#,
|
||||
form: Some(super::forms::MATERIAL_DAMAGE),
|
||||
kind: CommandKind::Model,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
@@ -141,6 +150,8 @@ definition_commands!(
|
||||
"section",
|
||||
"Section",
|
||||
r#"{"name": "web", "set": "all", "material": "Steel", "kind": {"type": "solid"} | {"type": "shell", "thickness": 2, "offset": 0 (-0.5..=0.5 of the thickness), "points": 5} | {"type": "beam", "profile": {"type": "rectangle", "width", "height"} | {"type": "circle", "radius"} | {"type": "pipe", "radius", "thickness"} | {"type": "i", "height", "width", "web", "flange"} | {"type": "box", "width", "height", "thickness"}, "orientation": [x, y, z]} | {"type": "rod", "area": 10} | {"type": "plane_stress", "thickness": 1} | {"type": "plane_strain", "thickness": 1 (default)} | {"type": "axisymmetric"} (2D: elements in the XY plane; axisymmetric about Y with x the radius, forces totals over the circumference), "formulation": {"integration": "full|reduced", "incompatible_modes": false, "hybrid": false}}"#,
|
||||
super::forms::SECTION,
|
||||
super::forms::SECTION_DELETE,
|
||||
add_section,
|
||||
replace_section,
|
||||
remove_section,
|
||||
@@ -155,6 +166,8 @@ definition_commands!(
|
||||
"reference_point",
|
||||
"Reference Point",
|
||||
r#"{"name": "RP", "xyz": [x, y, z], "label": 0} — label: its node label in decks (default: the highest free label, kept through remeshes)"#,
|
||||
super::forms::REFERENCE_POINT,
|
||||
super::forms::REFERENCE_POINT_DELETE,
|
||||
add_reference_point,
|
||||
replace_reference_point,
|
||||
remove_reference_point,
|
||||
@@ -169,6 +182,8 @@ definition_commands!(
|
||||
"coupling",
|
||||
"Coupling",
|
||||
r#"{"name": "pad", "reference_point": "RP", "set": "hole", "kind": "kinematic|distributing", "dofs": [1, 2, 3, 4, 5, 6] (default all)}"#,
|
||||
super::forms::COUPLING,
|
||||
super::forms::COUPLING_DELETE,
|
||||
add_coupling,
|
||||
replace_coupling,
|
||||
remove_coupling,
|
||||
@@ -183,6 +198,8 @@ definition_commands!(
|
||||
"mass",
|
||||
"Point Mass",
|
||||
r#"{"name": "motor", "target": {"set": "…"} | {"reference_point": "…"}, "mass": 2.5 | [mx, my, mz] (per global direction), "inertia": [I11, I22, I33, I12, I13, I23] (tensor about the node in global axes; needs rotations: a reference point)} — at every node of the target, each with the full value"#,
|
||||
super::forms::MASS,
|
||||
super::forms::MASS_DELETE,
|
||||
add_mass,
|
||||
replace_mass,
|
||||
remove_mass,
|
||||
@@ -197,6 +214,8 @@ definition_commands!(
|
||||
"spring",
|
||||
"Spring or Dashpot",
|
||||
r#"{"name": "mount", "type": "spring", "stiffness": 1000 | "type": "dashpot", "coefficient": 5, "first": {"set": "…"} | {"reference_point": "…"}, "second": (optional; without it each node of `first` connects to the ground; with it each end is one node), "direction": {"dof": 1..6} (4–6: torsional) | {"vector": [x, y, z]} | "line" (along the two ends, two-point only)} — dashpots act in dynamic steps; static and frequency steps ignore them"#,
|
||||
super::forms::SPRING,
|
||||
super::forms::SPRING_DELETE,
|
||||
add_spring,
|
||||
replace_spring,
|
||||
remove_spring,
|
||||
@@ -211,6 +230,8 @@ definition_commands!(
|
||||
"boundary_condition",
|
||||
"Boundary Condition",
|
||||
r#"{"name": "clamp", "type": "fixed", "target": {"set": "root"} | {"reference_point": "RP"}} | {"name", "type": "displacement", "target", "values": [u1, u2, u3, ur1, ur2, ur3] (null leaves a degree of freedom free)} | {"name", "type": "symmetry", "target", "plane": "x|y|z" (the plane's normal)} — active in the steps that list it (model.step.activate)"#,
|
||||
super::forms::BOUNDARY_CONDITION,
|
||||
super::forms::BOUNDARY_CONDITION_DELETE,
|
||||
add_boundary_condition,
|
||||
replace_boundary_condition,
|
||||
remove_boundary_condition,
|
||||
@@ -225,6 +246,8 @@ definition_commands!(
|
||||
"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)"#,
|
||||
super::forms::LOAD,
|
||||
super::forms::LOAD_DELETE,
|
||||
add_load,
|
||||
replace_load,
|
||||
remove_load,
|
||||
@@ -239,6 +262,8 @@ definition_commands!(
|
||||
"fluid_domain",
|
||||
"Fluid Domain",
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
|
||||
super::forms::FLUID_DOMAIN,
|
||||
super::forms::FLUID_DOMAIN_DELETE,
|
||||
add_fluid_domain,
|
||||
replace_fluid_domain,
|
||||
remove_fluid_domain,
|
||||
@@ -251,6 +276,7 @@ pub const FLUID_BOUNDARY: CommandSpec = CommandSpec {
|
||||
id: "model.fluid_domain.boundary",
|
||||
label: "Inlet, Outlet or Wall",
|
||||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"} | {"type": "pressure", "pressure": 0} | {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w]} | {"type": "slip"} | {"type": "periodic"}} — sets one boundary's condition (obstacle faces take walls and moving walls)"#,
|
||||
form: Some(super::forms::FLUID_BOUNDARY),
|
||||
kind: CommandKind::Model,
|
||||
run: fluid_boundary,
|
||||
};
|
||||
@@ -266,7 +292,8 @@ 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"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "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": "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,
|
||||
run: step_create,
|
||||
};
|
||||
@@ -275,6 +302,7 @@ pub const STEP_EDIT: CommandSpec = CommandSpec {
|
||||
id: "model.step.edit",
|
||||
label: "Edit Step",
|
||||
params: r#"{"name": "Bend", "procedure", "output", "boundary_conditions", "loads"} — as model.step.create; what is left out keeps its value"#,
|
||||
form: Some(super::forms::STEP_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
run: step_edit,
|
||||
};
|
||||
@@ -283,6 +311,7 @@ pub const STEP_DELETE: CommandSpec = CommandSpec {
|
||||
id: "model.step.delete",
|
||||
label: "Delete Step",
|
||||
params: r#"{"name": "Bend"}"#,
|
||||
form: Some(super::forms::STEP_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
@@ -291,12 +320,19 @@ pub const STEP_ACTIVATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.activate",
|
||||
label: "Activate in Step",
|
||||
params: r#"{"step": "Bend", "boundary_conditions": [names], "loads": [names], "active": true} — switches the listed items on (or off with false) in that step only"#,
|
||||
form: Some(super::forms::STEP_ACTIVATE),
|
||||
kind: CommandKind::Model,
|
||||
run: step_activate,
|
||||
};
|
||||
|
||||
pub const ACTIVITY: CommandSpec =
|
||||
CommandSpec { id: "model.activity", label: "Step Table", params: "{}", kind: CommandKind::Model, run: |s, _| Ok(activity(&s.model)) };
|
||||
pub const ACTIVITY: CommandSpec = CommandSpec {
|
||||
id: "model.activity",
|
||||
label: "Step Table",
|
||||
params: "{}",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
run: |s, _| Ok(activity(&s.model)),
|
||||
};
|
||||
|
||||
fn step_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = name(p)?;
|
||||
|
||||
@@ -36,6 +36,7 @@ pub const UNDO: CommandSpec = CommandSpec {
|
||||
id: "edit.undo",
|
||||
label: "Undo",
|
||||
params: r#"{"count"?: 1} — takes back the last command that changed the model (it leaves the journal and can be redone); `count` takes back that many, as far as the history reaches"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
run: |s, p| repeat(s, p, Session::undo),
|
||||
};
|
||||
@@ -44,6 +45,7 @@ pub const REDO: CommandSpec = CommandSpec {
|
||||
id: "edit.redo",
|
||||
label: "Redo",
|
||||
params: r#"{"count"?: 1} — runs the last undone command's change again; `count` redoes that many, as far as there are undone commands"#,
|
||||
form: Some(super::forms::COUNT),
|
||||
kind: CommandKind::Session,
|
||||
run: |s, p| repeat(s, p, Session::redo),
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
params: r#"{"path": "bracket.fieldcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
form: Some(super::forms::FILE_OPEN),
|
||||
kind: CommandKind::Session,
|
||||
run: open,
|
||||
};
|
||||
@@ -31,6 +32,7 @@ pub const SAVE: CommandSpec = CommandSpec {
|
||||
id: "file.save",
|
||||
label: "Save Model",
|
||||
params: r#"{"path"?: "bracket.fieldcraft"} — saves to `path`, else to the file the model was opened from or last saved to; written atomically, the previous version kept as `<file>.bak`"#,
|
||||
form: Some(super::forms::FILE_SAVE),
|
||||
kind: CommandKind::Session,
|
||||
run: save,
|
||||
};
|
||||
@@ -39,6 +41,7 @@ pub const SAVE_AS: CommandSpec = CommandSpec {
|
||||
id: "file.save_as",
|
||||
label: "Save Model As",
|
||||
params: r#"{"path": "bracket.fieldcraft"} — saves to a new file, which becomes the model's file"#,
|
||||
form: Some(super::forms::FILE_SAVE_AS),
|
||||
kind: CommandKind::Session,
|
||||
run: save_as,
|
||||
};
|
||||
@@ -47,6 +50,7 @@ pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.list",
|
||||
label: "Recoverable Models",
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
form: Some(super::forms::RECOVERY_LIST),
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_list,
|
||||
};
|
||||
@@ -55,6 +59,7 @@ pub const RECOVER: CommandSpec = CommandSpec {
|
||||
id: "file.recover",
|
||||
label: "Recover Model",
|
||||
params: r#"{"id": "…", "dir"?: "…"} — opens an autosaved model (an id from file.recovery.list) with its original file path, as unsaved changes; the autosave stays until file.recovery.discard"#,
|
||||
form: Some(super::forms::RECOVER),
|
||||
kind: CommandKind::Session,
|
||||
run: recover,
|
||||
};
|
||||
@@ -63,6 +68,7 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.discard",
|
||||
label: "Discard Recovered Model",
|
||||
params: r#"{"id": "…"} or {"all": true}; "dir"?: "…" — deletes autosaves of apps that are no longer running"#,
|
||||
form: Some(super::forms::RECOVERY_DISCARD),
|
||||
kind: CommandKind::Session,
|
||||
run: recovery_discard,
|
||||
};
|
||||
@@ -71,6 +77,7 @@ pub const SAMPLE: CommandSpec = CommandSpec {
|
||||
id: "file.sample",
|
||||
label: "Open Sample",
|
||||
params: r#"{"name": "parts|cantilever|impact"} — opens a sample model (fieldcraft_engine::sample) in place of the current one, unsaved changes discarded; `cantilever` and `impact` are ready to solve (solve.run)"#,
|
||||
form: Some(super::forms::SAMPLE),
|
||||
kind: CommandKind::Session,
|
||||
run: open_sample,
|
||||
};
|
||||
@@ -89,6 +96,12 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// A file of at most `max` bytes, through FieldCraft's file edge ([`fieldcraft_io::fs`]: the disk,
|
||||
/// or the page's files in the browser).
|
||||
pub(crate) fn read_file(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
Ok(fieldcraft_io::fs::read_capped(Path::new(path), max)?)
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working folder (as given where there is none).
|
||||
pub(crate) fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
|
||||
@@ -119,7 +132,7 @@ fn reopen<T>(
|
||||
stored: &SourceFile,
|
||||
base: Option<&Path>,
|
||||
roots: &Roots,
|
||||
max: usize,
|
||||
max: u64,
|
||||
open: impl FnOnce(&[u8]) -> fieldcraft_geometry::Result<T>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> (Option<T>, SourceFile) {
|
||||
@@ -132,7 +145,7 @@ fn reopen<T>(
|
||||
return (None, kept);
|
||||
}
|
||||
};
|
||||
let bytes = match fieldcraft_geometry::read_file(&checked, max) {
|
||||
let bytes = match read_file(&checked, max) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
|
||||
@@ -173,13 +186,13 @@ impl Session {
|
||||
let mut sources = Geometry::default();
|
||||
let mut design: Option<Arc<Design>> = None;
|
||||
if let Some(stored) = &file.geometry.design {
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings);
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
|
||||
design = opened.map(Arc::new);
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for stored in &file.geometry.meshes {
|
||||
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
|
||||
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES as u64, tagged_mesh::read_3mf, &mut warnings);
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
@@ -226,8 +239,7 @@ fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Res
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = absolute(&file_param(s, p, "path", Access::Read)?);
|
||||
let max = usize::try_from(fieldcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
|
||||
let bytes = fieldcraft_geometry::read_file(&path, max)?;
|
||||
let bytes = read_file(&path, fieldcraft_io::MAX_FILE_BYTES)?;
|
||||
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
//! The commands' parameter schemas ([`crate::schema`]), area by area. Each describes the JSON
|
||||
//! the command's `run` reads, as its `params` doc string does in words.
|
||||
|
||||
use crate::schema::Collection as C;
|
||||
use crate::schema::Dim as D;
|
||||
use crate::schema::*;
|
||||
|
||||
// ---- Shared pieces ----
|
||||
|
||||
/// `{"set": …}` or `{"reference_point": …}`.
|
||||
const TARGET_OPTIONS: &[Field] = &[reference("set", "Set", C::Set), reference("reference_point", "Reference point", C::ReferencePoint)];
|
||||
const fn target(key: &'static str, label: &'static str) -> Field {
|
||||
either(key, label, TARGET_OPTIONS)
|
||||
}
|
||||
|
||||
/// The name of an existing definition, for delete commands.
|
||||
macro_rules! existing {
|
||||
($label:literal, $c:expr) => {
|
||||
&[reference("name", $label, $c).req()]
|
||||
};
|
||||
}
|
||||
|
||||
pub const SET_DELETE: &[Field] = existing!("Set", C::Set);
|
||||
pub const MATERIAL_DELETE: &[Field] = existing!("Material", C::Material);
|
||||
pub const SECTION_DELETE: &[Field] = existing!("Section", C::Section);
|
||||
pub const REFERENCE_POINT_DELETE: &[Field] = existing!("Reference point", C::ReferencePoint);
|
||||
pub const COUPLING_DELETE: &[Field] = existing!("Coupling", C::Coupling);
|
||||
pub const MASS_DELETE: &[Field] = existing!("Point mass", C::Mass);
|
||||
pub const SPRING_DELETE: &[Field] = existing!("Spring", C::Spring);
|
||||
pub const BOUNDARY_CONDITION_DELETE: &[Field] = existing!("Boundary condition", C::BoundaryCondition);
|
||||
pub const LOAD_DELETE: &[Field] = existing!("Load", C::Load);
|
||||
pub const FLUID_DOMAIN_DELETE: &[Field] = existing!("Fluid domain", C::FluidDomain);
|
||||
pub const CONTACT_DELETE: &[Field] = existing!("Contact", C::Contact);
|
||||
pub const STEP_DELETE: &[Field] = existing!("Step", C::Step);
|
||||
pub const SET_RESOLVE: &[Field] = existing!("Set", C::Set);
|
||||
|
||||
// ---- Model definitions ----
|
||||
|
||||
pub const SET: &[Field] = &[
|
||||
NAME,
|
||||
rows(
|
||||
"geometry",
|
||||
"Geometry",
|
||||
&[
|
||||
reference("body", "Body", C::Body).req(),
|
||||
text("face", "Face").hint("persistent face name"),
|
||||
text("edge", "Edge"),
|
||||
text("vertex", "Vertex"),
|
||||
],
|
||||
)
|
||||
.hint("whole bodies, or their faces, edges or vertices"),
|
||||
json("nodes", "Nodes").hint("[labels]"),
|
||||
json("elements", "Elements").hint("[labels]"),
|
||||
json("faces", "Element faces").hint("[{\"element\", \"face\"}]"),
|
||||
json("edges", "Element edges").hint("[{\"element\", \"edge\"}]"),
|
||||
];
|
||||
|
||||
const ELASTIC: Field = variants(
|
||||
"elastic",
|
||||
"Elasticity",
|
||||
&[
|
||||
variant(
|
||||
"isotropic",
|
||||
"Isotropic",
|
||||
&[num("young", "Young's modulus", D::Stress).req(), range("poisson", "Poisson's ratio", D::None, -1.0, 0.5).req()],
|
||||
),
|
||||
variant(
|
||||
"engineering_constants",
|
||||
"Orthotropic",
|
||||
&[
|
||||
vector("young", "E1, E2, E3", D::Stress, &["E1", "E2", "E3"]).req(),
|
||||
vector("poisson", "ν12, ν13, ν23", D::None, &["ν12", "ν13", "ν23"]).req(),
|
||||
vector("shear", "G12, G13, G23", D::Stress, &["G12", "G13", "G23"]).req(),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
.def(r#"{"type": "isotropic"}"#);
|
||||
|
||||
const HARDENING: &[(&str, &str)] = &[("isotropic", "Isotropic"), ("kinematic", "Kinematic")];
|
||||
|
||||
const PLASTIC: Field = variants(
|
||||
"plastic",
|
||||
"Plasticity",
|
||||
&[
|
||||
variant(
|
||||
"bilinear",
|
||||
"Bilinear",
|
||||
&[
|
||||
positive("yield_stress", "Yield stress", D::Stress).req(),
|
||||
num("hardening_modulus", "Hardening modulus", D::Stress).def("0"),
|
||||
choice("hardening", "Hardening", HARDENING).def("\"isotropic\""),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
"table",
|
||||
"Table",
|
||||
&[rows(
|
||||
"points",
|
||||
"Hardening curve",
|
||||
&[positive("strain", "Plastic strain", D::None).req(), positive("stress", "Stress", D::Stress).req()],
|
||||
)
|
||||
.req()],
|
||||
),
|
||||
variant(
|
||||
"johnson_cook",
|
||||
"Johnson–Cook",
|
||||
&[
|
||||
positive("a", "A", D::Stress).req(),
|
||||
num("b", "B", D::Stress).req(),
|
||||
num("n", "n", D::None).req(),
|
||||
num("c", "C", D::None).def("0"),
|
||||
num("m", "m", D::None).def("1"),
|
||||
positive("reference_strain_rate", "Reference strain rate", D::None).def("1").hint("per time unit"),
|
||||
num("room_temperature", "Room temperature", D::Temperature).req(),
|
||||
num("melt_temperature", "Melt temperature", D::Temperature).req(),
|
||||
positive("specific_heat", "Specific heat", D::SpecificHeat).hint("isothermal when empty"),
|
||||
range("taylor_quinney", "Taylor–Quinney", D::None, 0.0, 1.0).def("0.9"),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const DAMAGE: Field = variants(
|
||||
"damage",
|
||||
"Damage",
|
||||
&[variant(
|
||||
"johnson_cook",
|
||||
"Johnson–Cook",
|
||||
&[
|
||||
num("d1", "D1", D::None).req(),
|
||||
num("d2", "D2", D::None).req(),
|
||||
num("d3", "D3", D::None).req(),
|
||||
num("d4", "D4", D::None),
|
||||
num("d5", "D5", D::None),
|
||||
],
|
||||
)],
|
||||
);
|
||||
const EROSION: Field = group("erosion", "Erosion", &[positive("plastic_strain", "Plastic strain", D::None).req()]);
|
||||
|
||||
const HYPERELASTIC: Field = variants(
|
||||
"hyperelastic",
|
||||
"Hyperelasticity",
|
||||
&[
|
||||
variant("neo_hookean", "Neo-Hookean", &[]),
|
||||
variant("mooney_rivlin", "Mooney–Rivlin", &[num("c01", "C01", D::Stress).req()]),
|
||||
variant("yeoh", "Yeoh", &[num("c20", "C20", D::Stress).req(), num("c30", "C30", D::Stress).req()]),
|
||||
],
|
||||
)
|
||||
.hint("large strain in nonlinear steps, from the isotropic constants");
|
||||
|
||||
const THERMAL: Field = group(
|
||||
"thermal",
|
||||
"Thermal",
|
||||
&[
|
||||
json("conductivity", "Conductivity").hint("k or [k1, k2, k3]"),
|
||||
positive("specific_heat", "Specific heat", D::SpecificHeat),
|
||||
json("expansion", "Expansion").hint("α or [α1, α2, α3], 1/K"),
|
||||
num("reference_temperature", "Reference temperature", D::Temperature).def("0"),
|
||||
],
|
||||
);
|
||||
|
||||
pub const MATERIAL: &[Field] = &[
|
||||
NAME,
|
||||
ELASTIC,
|
||||
positive("density", "Density", D::Density),
|
||||
PLASTIC,
|
||||
HYPERELASTIC,
|
||||
DAMAGE,
|
||||
EROSION,
|
||||
THERMAL,
|
||||
variants("fluid", "Fluid", &[variant("newtonian", "Newtonian", &[positive("viscosity", "Viscosity", D::Viscosity).req()])])
|
||||
.hint("a fluid needs a density"),
|
||||
];
|
||||
|
||||
pub const MATERIAL_PLASTICITY: &[Field] = &[reference("name", "Material", C::Material).req(), PLASTIC.req().def(r#"{"type": "bilinear"}"#)];
|
||||
pub const MATERIAL_DAMAGE: &[Field] =
|
||||
&[reference("name", "Material", C::Material).req(), DAMAGE.def(r#"{"type": "johnson_cook"}"#).hint("give damage, erosion or both"), EROSION];
|
||||
|
||||
const PROFILE: Field = variants(
|
||||
"profile",
|
||||
"Profile",
|
||||
&[
|
||||
variant("rectangle", "Rectangle", &[positive("width", "Width", D::Length).req(), positive("height", "Height", D::Length).req()]),
|
||||
variant("circle", "Circle", &[positive("radius", "Radius", D::Length).req()]),
|
||||
variant("pipe", "Pipe", &[positive("radius", "Radius", D::Length).req(), positive("thickness", "Wall", D::Length).req()]),
|
||||
variant(
|
||||
"i",
|
||||
"I-beam",
|
||||
&[
|
||||
positive("height", "Height", D::Length).req(),
|
||||
positive("width", "Width", D::Length).req(),
|
||||
positive("web", "Web", D::Length).req(),
|
||||
positive("flange", "Flange", D::Length).req(),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
"box",
|
||||
"Box",
|
||||
&[
|
||||
positive("width", "Width", D::Length).req(),
|
||||
positive("height", "Height", D::Length).req(),
|
||||
positive("thickness", "Wall", D::Length).req(),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def(r#"{"type": "rectangle"}"#);
|
||||
|
||||
pub const SECTION: &[Field] = &[
|
||||
NAME,
|
||||
reference("set", "Region", C::Set).req(),
|
||||
reference("material", "Material", C::Material).req(),
|
||||
variants(
|
||||
"kind",
|
||||
"Kind",
|
||||
&[
|
||||
variant("solid", "Solid", &[]),
|
||||
variant(
|
||||
"shell",
|
||||
"Shell",
|
||||
&[
|
||||
positive("thickness", "Thickness", D::Length).req(),
|
||||
range("offset", "Offset", D::None, -0.5, 0.5).def("0").hint("fraction of the thickness"),
|
||||
int("points", "Integration points", 1, 15).def("5"),
|
||||
],
|
||||
),
|
||||
variant("beam", "Beam", &[PROFILE, xyz("orientation", "Orientation", D::None).req().def("[0, 0, 1]")]),
|
||||
variant("rod", "Rod", &[positive("area", "Area", D::Area).req()]),
|
||||
variant("plane_stress", "Plane stress", &[positive("thickness", "Thickness", D::Length).req().def("1")]),
|
||||
variant("plane_strain", "Plane strain", &[positive("thickness", "Thickness", D::Length).def("1")]),
|
||||
variant("axisymmetric", "Axisymmetric", &[]),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def(r#"{"type": "solid"}"#),
|
||||
group(
|
||||
"formulation",
|
||||
"Formulation",
|
||||
&[
|
||||
choice("integration", "Integration", &[("full", "Full"), ("reduced", "Reduced")]).def("\"full\""),
|
||||
flag("incompatible_modes", "Incompatible modes").def("false"),
|
||||
flag("hybrid", "Hybrid").def("false"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
pub const REFERENCE_POINT: &[Field] = &[
|
||||
NAME,
|
||||
xyz("xyz", "Position", D::Length).req().def("[0, 0, 0]"),
|
||||
int("label", "Node label", 0, u32::MAX as i64).hint("0: the highest free label"),
|
||||
];
|
||||
|
||||
const DOFS: Field = json("dofs", "Degrees of freedom").hint("[1, …, 6], default all");
|
||||
|
||||
pub const COUPLING: &[Field] = &[
|
||||
NAME,
|
||||
reference("reference_point", "Reference point", C::ReferencePoint).req(),
|
||||
reference("set", "Region", C::Set).req(),
|
||||
choice("kind", "Kind", &[("kinematic", "Kinematic"), ("distributing", "Distributing")]).req().def("\"kinematic\""),
|
||||
DOFS,
|
||||
];
|
||||
|
||||
pub const MASS: &[Field] = &[
|
||||
NAME,
|
||||
TARGET,
|
||||
positive("mass", "Mass", D::Mass).hint("per node; [mx, my, mz] in JSON"),
|
||||
vector("inertia", "Inertia", D::Inertia, &["I11", "I22", "I33", "I12", "I13", "I23"]).hint("about the node; needs a reference point"),
|
||||
];
|
||||
|
||||
const SPRING_FIRST: Field = target("first", "First end").req();
|
||||
const SPRING_SECOND: Field = target("second", "Second end").hint("ground when empty");
|
||||
const SPRING_DIRECTION: Field =
|
||||
either("direction", "Direction", &[int("dof", "DOF", 1, 6), xyz("vector", "Vector", D::None), unit("line", "Along the ends")])
|
||||
.req()
|
||||
.def(r#"{"dof": 1}"#);
|
||||
|
||||
pub const SPRING: &[Field] = &[
|
||||
NAME,
|
||||
variants(
|
||||
"",
|
||||
"Type",
|
||||
&[
|
||||
variant("spring", "Spring", &[SPRING_FIRST, SPRING_SECOND, SPRING_DIRECTION, positive("stiffness", "Stiffness", D::Stiffness).req()]),
|
||||
variant("dashpot", "Dashpot", &[SPRING_FIRST, SPRING_SECOND, SPRING_DIRECTION, positive("coefficient", "Coefficient", D::Damping).req()]),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"spring\""),
|
||||
];
|
||||
|
||||
/// The target of a boundary condition, force, moment or temperature.
|
||||
const TARGET: Field = target("target", "Target").req();
|
||||
|
||||
pub const BOUNDARY_CONDITION: &[Field] = &[
|
||||
NAME,
|
||||
variants(
|
||||
"",
|
||||
"Type",
|
||||
&[
|
||||
variant("fixed", "Fixed", &[TARGET]),
|
||||
variant(
|
||||
"displacement",
|
||||
"Displacement",
|
||||
&[
|
||||
TARGET,
|
||||
free_vector("values", "Values", D::Length, &["u1", "u2", "u3", "ur1", "ur2", "ur3"])
|
||||
.req()
|
||||
.def("[0, 0, 0, null, null, null]")
|
||||
.hint("empty: free"),
|
||||
],
|
||||
),
|
||||
variant("symmetry", "Symmetry", &[TARGET, choice("plane", "Plane normal", &[("x", "X"), ("y", "Y"), ("z", "Z")]).req().def("\"x\"")]),
|
||||
variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).req()]),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"fixed\""),
|
||||
];
|
||||
|
||||
const DISTRIBUTION: Field = choice("distribution", "Distribution", &[("total", "Total"), ("per_node", "Per node")]).def("\"total\"");
|
||||
const LOAD_SET: Field = reference("set", "Region", C::Set).req();
|
||||
|
||||
pub const LOAD: &[Field] = &[
|
||||
NAME,
|
||||
variants(
|
||||
"",
|
||||
"Type",
|
||||
&[
|
||||
variant("force", "Force", &[TARGET, xyz("vector", "Force", D::Force).req(), DISTRIBUTION]),
|
||||
variant("moment", "Moment", &[TARGET, xyz("vector", "Moment", D::Moment).req(), DISTRIBUTION]),
|
||||
variant("pressure", "Pressure", &[LOAD_SET, num("magnitude", "Pressure", D::Stress).req().hint("positive pushes in")]),
|
||||
variant("traction", "Traction", &[LOAD_SET, xyz("vector", "Traction", D::Stress).req()]),
|
||||
variant(
|
||||
"gravity",
|
||||
"Gravity",
|
||||
&[xyz("acceleration", "Acceleration", D::Acceleration).req(), reference("set", "Region", C::Set).hint("the whole model")],
|
||||
),
|
||||
variant("body_force", "Body force", &[LOAD_SET, xyz("vector", "Force per volume", D::ForcePerVolume).req()]),
|
||||
variant(
|
||||
"centrifugal",
|
||||
"Centrifugal",
|
||||
&[
|
||||
num("angular_velocity", "Angular velocity", D::AngularVelocity).req(),
|
||||
xyz("point", "Point on axis", D::Length).req().def("[0, 0, 0]"),
|
||||
xyz("axis", "Axis", D::None).req().def("[0, 0, 1]"),
|
||||
reference("set", "Region", C::Set).hint("the whole model"),
|
||||
],
|
||||
),
|
||||
variant("surface_flux", "Surface heat flux", &[LOAD_SET, num("flux", "Flux", D::HeatFlux).req()]),
|
||||
variant(
|
||||
"convection",
|
||||
"Convection",
|
||||
&[LOAD_SET, positive("film", "Film coefficient", D::FilmCoefficient).req(), num("sink", "Sink temperature", D::Temperature).req()],
|
||||
),
|
||||
variant("heat_source", "Heat source", &[LOAD_SET, num("power", "Power per volume", D::None).req()]),
|
||||
variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).req()]),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"force\""),
|
||||
];
|
||||
|
||||
const FACE_CONDITION: &[Variant] = &[
|
||||
variant("wall", "Wall", &[]),
|
||||
variant("moving_wall", "Moving wall", &[xyz("velocity", "Velocity", D::Velocity).req()]),
|
||||
variant("slip", "Slip", &[]),
|
||||
variant(
|
||||
"velocity",
|
||||
"Velocity inlet",
|
||||
&[
|
||||
xyz("velocity", "Velocity", D::Velocity).req(),
|
||||
choice("profile", "Profile", &[("uniform", "Uniform"), ("parabolic", "Parabolic")]).def("\"uniform\""),
|
||||
],
|
||||
),
|
||||
variant("pressure", "Pressure outlet", &[num("pressure", "Pressure", D::Stress).req().def("0")]),
|
||||
variant("periodic", "Periodic", &[]),
|
||||
];
|
||||
|
||||
const fn face(key: &'static str, label: &'static str) -> Field {
|
||||
variants(key, label, FACE_CONDITION).hint("wall")
|
||||
}
|
||||
|
||||
pub const FLUID_DOMAIN: &[Field] = &[
|
||||
NAME,
|
||||
reference("material", "Fluid", C::Material).req(),
|
||||
xyz("min", "Box min", D::Length).req(),
|
||||
xyz("max", "Box max", D::Length).req(),
|
||||
positive("spacing", "Cell size", D::Length).req(),
|
||||
group(
|
||||
"faces",
|
||||
"Box faces",
|
||||
&[
|
||||
face("x_min", "X min"),
|
||||
face("x_max", "X max"),
|
||||
face("y_min", "Y min"),
|
||||
face("y_max", "Y max"),
|
||||
face("z_min", "Z min"),
|
||||
face("z_max", "Z max"),
|
||||
],
|
||||
),
|
||||
references("obstacles", "Obstacles", C::Body),
|
||||
json("surfaces", "Obstacle faces").hint("[{\"face\", \"condition\"}]; walls by default"),
|
||||
xyz("initial_velocity", "Initial velocity", D::Velocity),
|
||||
xyz("acceleration", "Acceleration", D::Acceleration),
|
||||
positive("tolerance", "Obstacle tolerance", D::Length).hint("a tenth of the cell size"),
|
||||
];
|
||||
|
||||
pub const FLUID_BOUNDARY: &[Field] = &[
|
||||
reference("domain", "Domain", C::FluidDomain).req(),
|
||||
json("at", "Boundary").req().def("\"x_min\"").hint("\"x_min\" … \"z_max\", or {\"body\", \"face\"}"),
|
||||
variants("condition", "Condition", FACE_CONDITION).req().def(r#"{"type": "velocity"}"#),
|
||||
];
|
||||
|
||||
const PROCEDURE: Field = variants(
|
||||
"procedure",
|
||||
"Procedure",
|
||||
&[
|
||||
variant("static", "Static, linear", &[]),
|
||||
variant(
|
||||
"nonlinear_static",
|
||||
"Static, nonlinear",
|
||||
&[
|
||||
flag("nonlinear_geometry", "Nonlinear geometry").def("true"),
|
||||
range("initial_increment", "Initial increment", D::None, 0.0, 1.0).def("0.1"),
|
||||
range("min_increment", "Min increment", D::None, 0.0, 1.0).def("1e-5"),
|
||||
range("max_increment", "Max increment", D::None, 0.0, 1.0).def("1.0"),
|
||||
int("max_increments", "Max increments", 1, 1_000_000).def("100"),
|
||||
int("max_iterations", "Iterations per increment", 1, 1000).def("16"),
|
||||
group(
|
||||
"tolerances",
|
||||
"Tolerances",
|
||||
&[
|
||||
positive("force", "Force", D::None).def("1e-6"),
|
||||
positive("displacement", "Displacement", D::None).def("1e-5"),
|
||||
positive("energy", "Energy", D::None).def("1e-10"),
|
||||
],
|
||||
),
|
||||
flag("line_search", "Line search").def("false"),
|
||||
json("load_path", "Load path").hint("[[fraction, factor], …]; a ramp when empty"),
|
||||
group(
|
||||
"imperfection",
|
||||
"Imperfection",
|
||||
&[
|
||||
reference("step", "Buckling step", C::Step).req(),
|
||||
int("mode", "Mode", 1, 100_000).req().def("1"),
|
||||
num("scale", "Largest component", D::Length).req(),
|
||||
],
|
||||
),
|
||||
group(
|
||||
"arc_length",
|
||||
"Arc length",
|
||||
&[
|
||||
choice("constraint", "Constraint", &[("cylindrical", "Cylindrical"), ("spherical", "Spherical")]).def("\"cylindrical\""),
|
||||
positive("max_load_factor", "Max load factor", D::None).def("1"),
|
||||
json("displacement_limit", "Displacement limit").hint("{\"node\": 1, \"direction\": 3, \"displacement\": -10}"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
variant("buckle", "Buckling", &[int("modes", "Modes", 1, 100_000).req().def("4"), num("shift", "Shift", D::None)]),
|
||||
variant(
|
||||
"frequency",
|
||||
"Frequency",
|
||||
&[
|
||||
int("modes", "Modes", 1, 10_000).req().def("10"),
|
||||
positive("min_hz", "Min frequency", D::Hertz),
|
||||
positive("max_hz", "Max frequency", D::Hertz),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
"explicit_dynamic",
|
||||
"Dynamic, explicit",
|
||||
&[
|
||||
positive("end_time", "End time", D::Time).req(),
|
||||
range("dt_scale", "Time step scale", D::None, 0.0, 1.0).def("0.9"),
|
||||
int("max_increments", "Max increments", 1, 10_000_000_000).def("10000000"),
|
||||
positive("output_interval", "Field output every", D::Time),
|
||||
positive("history_interval", "History every", D::Time),
|
||||
reference("history_nodes", "History nodes", C::Set),
|
||||
rows(
|
||||
"initial_velocities",
|
||||
"Initial velocities",
|
||||
&[reference("set", "Set", C::Set).req(), xyz("velocity", "Velocity", D::Velocity).req()],
|
||||
),
|
||||
rows(
|
||||
"rigid_walls",
|
||||
"Rigid walls",
|
||||
&[
|
||||
reference("set", "Set", C::Set).req(),
|
||||
xyz("point", "Point", D::Length).req(),
|
||||
xyz("normal", "Normal", D::None).req(),
|
||||
FRICTION,
|
||||
],
|
||||
),
|
||||
group(
|
||||
"hourglass",
|
||||
"Hourglass control",
|
||||
&[
|
||||
choice("type", "Type", &[("viscous", "Viscous"), ("stiffness", "Stiffness")]).def("\"viscous\""),
|
||||
positive("coefficient", "Coefficient", D::None),
|
||||
],
|
||||
),
|
||||
group("bulk_viscosity", "Bulk viscosity", &[positive("linear", "Linear", D::None), positive("quadratic", "Quadratic", D::None)]),
|
||||
flag("nonlinear_geometry", "Nonlinear geometry").def("true"),
|
||||
choice("stress_rate", "Stress rate", &[("jaumann", "Jaumann"), ("green_naghdi", "Green–Naghdi")]),
|
||||
group(
|
||||
"mass_scaling",
|
||||
"Mass scaling",
|
||||
&[positive("target_increment", "Target increment", D::Time).req(), reference("set", "Set", C::Set)],
|
||||
),
|
||||
],
|
||||
),
|
||||
variant(
|
||||
"heat_transfer",
|
||||
"Heat transfer",
|
||||
&[group(
|
||||
"transient",
|
||||
"Transient",
|
||||
&[
|
||||
positive("end_time", "End time", D::Time).req(),
|
||||
positive("increment", "Increment", D::Time).req(),
|
||||
range("theta", "θ", D::None, 0.0, 1.0),
|
||||
num("initial_temperature", "Initial temperature", D::Temperature),
|
||||
],
|
||||
)
|
||||
.hint("steady state when off")],
|
||||
),
|
||||
variant("coupled_temperature_displacement", "Coupled temperature–displacement", &[]),
|
||||
variant(
|
||||
"fluid",
|
||||
"Fluid (lattice Boltzmann)",
|
||||
&[
|
||||
reference("domain", "Domain", C::FluidDomain).req(),
|
||||
positive("end_time", "End time", D::Time).hint("or Steps"),
|
||||
int("steps", "Steps", 1, 1_000_000_000_000),
|
||||
positive("time_step", "Time step", D::Time).hint("chosen for Mach 0.087"),
|
||||
int("frames", "Frames", 1, 100_000).def("10"),
|
||||
positive("magic", "TRT Λ", D::None).hint("0.1875"),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const OUTPUT: Field = json("output", "Output").hint("[\"displacement\", \"stress\", …]; by procedure when empty");
|
||||
const STEP_BCS: Field = references("boundary_conditions", "Boundary conditions", C::BoundaryCondition);
|
||||
const STEP_LOADS: Field = references("loads", "Loads", C::Load);
|
||||
|
||||
pub const STEP_CREATE: &[Field] =
|
||||
&[NAME, PROCEDURE.req().def(r#"{"type": "static"}"#), OUTPUT, STEP_BCS, STEP_LOADS, reference("before", "Insert before", C::Step).hint("last")];
|
||||
|
||||
pub const STEP_EDIT: &[Field] = &[reference("name", "Step", C::Step).req(), PROCEDURE, OUTPUT, STEP_BCS, STEP_LOADS];
|
||||
|
||||
pub const STEP_ACTIVATE: &[Field] = &[
|
||||
reference("step", "Step", C::Step).req(),
|
||||
references("boundary_conditions", "Boundary conditions", C::BoundaryCondition),
|
||||
references("loads", "Loads", C::Load),
|
||||
flag("active", "Active").def("true"),
|
||||
];
|
||||
|
||||
pub const UNITS: &[Field] = &[choice(
|
||||
"system",
|
||||
"Unit system",
|
||||
&[
|
||||
("mm-t-s-N-MPa", "mm, t, s, N, MPa"),
|
||||
("m-kg-s-N-Pa", "m, kg, s, N, Pa"),
|
||||
("mm-kg-ms-kN-GPa", "mm, kg, ms, kN, GPa"),
|
||||
("mm-g-ms-N-MPa", "mm, g, ms, N, MPa"),
|
||||
("in-lbf-s-psi", "in, lbf·s²/in, s, lbf, psi"),
|
||||
("unspecified", "Unspecified"),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"mm-t-s-N-MPa\"")];
|
||||
|
||||
// ---- Contact ----
|
||||
|
||||
const FRICTION: Field = group(
|
||||
"friction",
|
||||
"Friction",
|
||||
&[
|
||||
positive("static", "Static μ", D::None).hint("0"),
|
||||
positive("kinetic", "Kinetic μ", D::None).hint("the static μ"),
|
||||
positive("decay", "Decay", D::None).hint("0; per slip rate"),
|
||||
],
|
||||
)
|
||||
.hint("frictionless");
|
||||
const PENALTY: Field = range("penalty_scale", "Penalty scale", D::None, 0.0, 1.0).def("1");
|
||||
const PENETRATION: Field =
|
||||
choice("initial_penetration", "Initial penetration", &[("adjust", "Adjust nodes"), ("refuse", "Refuse")]).def("\"adjust\"");
|
||||
|
||||
pub const CONTACT_PAIR: &[Field] = &[
|
||||
NAME,
|
||||
reference("main", "Main surface", C::Set).req(),
|
||||
reference("secondary", "Secondary surface", C::Set).req(),
|
||||
FRICTION,
|
||||
PENALTY,
|
||||
PENETRATION,
|
||||
];
|
||||
pub const CONTACT_GENERAL: &[Field] = &[NAME, reference("surface", "Surface", C::Set).hint("every exterior face"), FRICTION, PENALTY, PENETRATION];
|
||||
pub const CONTACT_EDIT: &[Field] = &[
|
||||
reference("name", "Contact", C::Contact).req(),
|
||||
variants(
|
||||
"surfaces",
|
||||
"Surfaces",
|
||||
&[
|
||||
variant("pair", "Pair", &[reference("main", "Main", C::Set).req(), reference("secondary", "Secondary", C::Set).req()]),
|
||||
variant("general", "General", &[reference("surface", "Surface", C::Set).hint("every exterior face")]),
|
||||
],
|
||||
)
|
||||
.req(),
|
||||
FRICTION,
|
||||
PENALTY,
|
||||
PENETRATION,
|
||||
];
|
||||
pub const CONTACT_FIND: &[Field] = &[
|
||||
positive("tolerance", "Tolerance", D::Length).hint("a tenth of the mean face size"),
|
||||
flag("create", "Create pairs").def("false"),
|
||||
FRICTION,
|
||||
PENALTY,
|
||||
PENETRATION,
|
||||
];
|
||||
|
||||
// ---- Meshing ----
|
||||
|
||||
const BODY: Field = reference("body", "Body", C::Body).hint("every body of the design");
|
||||
const SURFACE: Field = int("surface", "Imported surface", 0, 1_000_000).hint("instead of a body");
|
||||
const TOLERANCE: Field = positive("tolerance", "Chordal tolerance", D::Millimetre).hint("1e-3 of the diagonal");
|
||||
const WELD: Field = range("weld", "Weld", D::None, 0.0, 1e-3).hint("1e-9 of the diagonal");
|
||||
const WELD_DISTANCE: Field = positive("weld_distance", "Weld distance", D::Millimetre).hint("the surface's own");
|
||||
const FEATURE_ANGLE: Field = range("feature_angle", "Feature angle", D::Degree, 0.0, 180.0).hint("45");
|
||||
|
||||
pub const TET: &[Field] = &[
|
||||
BODY,
|
||||
SURFACE,
|
||||
positive("size", "Element size", D::Millimetre).hint("1/20 of the diagonal"),
|
||||
choice("order", "Order", &[("linear", "Linear (tet4)"), ("quadratic", "Quadratic (tet10)")]).def("\"linear\""),
|
||||
TOLERANCE,
|
||||
flag("quality", "Improve quality").def("true"),
|
||||
range("quality_target", "Quality target", D::Degree, 5.0, 60.0).hint("40"),
|
||||
range("curvature_angle", "Curvature angle", D::Degree, 0.0, 90.0).hint("22.5"),
|
||||
range("grading", "Grading", D::None, 0.05, 2.0).hint("0.3"),
|
||||
int("max_passes", "Improvement passes", 0, 200).hint("20"),
|
||||
int("max_points", "Max points", 1, 20_000_000).hint("1000000"),
|
||||
int("max_steiner", "Max Steiner points", 0, 10_000_000).hint("50000"),
|
||||
int("max_tets", "Max tetrahedra", 1, 50_000_000).hint("5000000"),
|
||||
WELD,
|
||||
WELD_DISTANCE,
|
||||
FEATURE_ANGLE,
|
||||
];
|
||||
|
||||
const HEX_SIZE: Field = positive("size", "Element size", D::Millimetre).hint("1/25 of the diagonal");
|
||||
const HEX_ORDER: Field = int("order", "Order", 1, 2).def("1").hint("1: hex8, 2: hex20");
|
||||
|
||||
pub const HEX_GENERATE: &[Field] = &[
|
||||
BODY,
|
||||
SURFACE,
|
||||
HEX_SIZE,
|
||||
HEX_ORDER,
|
||||
choice(
|
||||
"method",
|
||||
"Method",
|
||||
&[("auto", "Automatic"), ("swept", "Any sweep"), ("extrude", "Extrude"), ("sweep", "Sweep"), ("revolve", "Revolve"), ("mapped", "Mapped")],
|
||||
)
|
||||
.def("\"auto\""),
|
||||
TOLERANCE,
|
||||
WELD,
|
||||
WELD_DISTANCE,
|
||||
FEATURE_ANGLE,
|
||||
];
|
||||
|
||||
pub const HEX_SWEEP: &[Field] = &[
|
||||
BODY,
|
||||
SURFACE,
|
||||
HEX_SIZE,
|
||||
HEX_ORDER,
|
||||
choice("method", "Method", &[("swept", "Any sweep"), ("extrude", "Extrude"), ("sweep", "Sweep"), ("revolve", "Revolve")]).def("\"swept\""),
|
||||
TOLERANCE,
|
||||
WELD,
|
||||
WELD_DISTANCE,
|
||||
FEATURE_ANGLE,
|
||||
];
|
||||
|
||||
pub const HEX_MAPPED: &[Field] = &[BODY, SURFACE, HEX_SIZE, HEX_ORDER, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE];
|
||||
|
||||
pub const HEX_BLOCKS: &[Field] =
|
||||
&[references("bodies", "Cells", C::Body).hint("every body of the design"), HEX_SIZE, HEX_ORDER, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE];
|
||||
|
||||
pub const HEX_OCTREE: &[Field] = &[
|
||||
BODY,
|
||||
SURFACE,
|
||||
HEX_SIZE,
|
||||
TOLERANCE,
|
||||
int("max_depth", "Refinement levels", 0, 5).hint("3"),
|
||||
int("max_elements", "Max elements", 1, 10_000_000),
|
||||
int("attempts", "Attempts", 1, 8).hint("4"),
|
||||
int("max_sweeps", "Optimiser sweeps", 0, 10_000).hint("300"),
|
||||
WELD,
|
||||
WELD_DISTANCE,
|
||||
FEATURE_ANGLE,
|
||||
];
|
||||
|
||||
pub const BLOCK: &[Field] = &[
|
||||
text("body", "Body").req().def("\"Block\""),
|
||||
choice(
|
||||
"kind",
|
||||
"Element",
|
||||
&[
|
||||
("hex8", "hex8"),
|
||||
("hex20", "hex20"),
|
||||
("tet4", "tet4"),
|
||||
("tet10", "tet10"),
|
||||
("wedge6", "wedge6"),
|
||||
("quad4", "quad4 (2D)"),
|
||||
("quad8", "quad8 (2D)"),
|
||||
("tri3", "tri3 (2D)"),
|
||||
("tri6", "tri6 (2D)"),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"hex8\""),
|
||||
json("corners", "Corners").req().hint("[[x, y, z] × 8] (2D: × 4 at z = 0)"),
|
||||
json("divisions", "Divisions").req().hint("[n12, n14, n15] (2D: [n12, n14])"),
|
||||
json("bias", "Bias").hint("[1, 1, 1]"),
|
||||
json("edges", "Curved and named edges"),
|
||||
json("faces", "Face names").hint("{\"1\": \"bottom\"}"),
|
||||
json("vertices", "Corner names"),
|
||||
text("face", "2D face name").hint("area"),
|
||||
positive("merge", "Merge distance", D::Length).hint("1e-6 of the diagonal"),
|
||||
];
|
||||
|
||||
pub const PLANAR: &[Field] = &[
|
||||
positive("size", "Element size", D::Length).req(),
|
||||
choice("kind", "Element", &[("quad4", "quad4"), ("quad8", "quad8"), ("tri3", "tri3"), ("tri6", "tri6")]).def("\"quad4\""),
|
||||
BODY,
|
||||
text("face", "Face").hint("a planar face of the body"),
|
||||
TOLERANCE,
|
||||
json("polygon", "Polygon").hint("[[x, y], …] instead of a face"),
|
||||
json("holes", "Holes"),
|
||||
text("name", "Body name").hint("Plane"),
|
||||
int("max_elements", "Max elements", 1, 4_000_000),
|
||||
];
|
||||
|
||||
pub const QUALITY: &[Field] = &[json("elements", "Elements").hint("[labels]; all when empty"), int("bins", "Histogram bins", 1, 1000).def("10")];
|
||||
|
||||
pub const SURFACE_CHECK: &[Field] =
|
||||
&[reference("body", "Body", C::Body).req().hint("or an imported surface"), SURFACE, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE];
|
||||
|
||||
pub const NODE_ADD: &[Field] = &[int("label", "Label", 1, u32::MAX as i64).req(), xyz("xyz", "Position", D::Length).req().def("[0, 0, 0]")];
|
||||
pub const LABEL: &[Field] = &[int("label", "Label", 1, u32::MAX as i64).req()];
|
||||
pub const ELEMENT_ADD: &[Field] = &[
|
||||
int("label", "Label", 1, u32::MAX as i64).req(),
|
||||
choice(
|
||||
"kind",
|
||||
"Kind",
|
||||
&[
|
||||
("rod2", "rod2"),
|
||||
("beam2", "beam2"),
|
||||
("tri3", "tri3"),
|
||||
("tri6", "tri6"),
|
||||
("quad4", "quad4"),
|
||||
("quad8", "quad8"),
|
||||
("tet4", "tet4"),
|
||||
("tet10", "tet10"),
|
||||
("wedge6", "wedge6"),
|
||||
("hex8", "hex8"),
|
||||
("hex20", "hex20"),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"hex8\""),
|
||||
json("nodes", "Nodes").req().hint("[labels]"),
|
||||
];
|
||||
|
||||
pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").req().hint("[[x, y, z], …]")];
|
||||
|
||||
pub const TAG: &[Field] = &[
|
||||
group("geometry", "Geometry", &[reference("body", "Body", C::Body).req(), text("face", "Face"), text("edge", "Edge"), text("vertex", "Vertex")])
|
||||
.req(),
|
||||
json("elements", "Elements"),
|
||||
json("faces", "Element faces"),
|
||||
json("edges", "Element edges"),
|
||||
json("nodes", "Nodes"),
|
||||
];
|
||||
|
||||
// ---- Geometry and files ----
|
||||
|
||||
pub const OPEN_DESIGN: &[Field] = &[path("path", "Design", false).req()];
|
||||
pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).req(), TOLERANCE];
|
||||
pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).req()];
|
||||
pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).req()];
|
||||
pub const FILE_SAVE: &[Field] = &[path("path", "File", true).hint("the model's file")];
|
||||
pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).req()];
|
||||
pub const RECOVERY_LIST: &[Field] = &[path("dir", "Folder", false).hint("the recovery folder")];
|
||||
pub const RECOVER: &[Field] = &[text("id", "Autosave").req(), path("dir", "Folder", false).hint("the recovery folder")];
|
||||
pub const RECOVERY_DISCARD: &[Field] = &[text("id", "Autosave"), flag("all", "All"), path("dir", "Folder", false).hint("the recovery folder")];
|
||||
pub const SAMPLE: &[Field] =
|
||||
&[choice("name", "Sample", &[("cantilever", "Cantilever"), ("impact", "Impact"), ("parts", "Parts")]).req().def("\"cantilever\"")];
|
||||
pub const COUNT: &[Field] = &[int("count", "Count", 1, 1_000_000).def("1")];
|
||||
|
||||
pub const DECK: &[Field] = &[
|
||||
choice("format", "Format", &[("abaqus", "Abaqus input (.inp)"), ("nastran", "Nastran bulk data (.bdf)"), ("lsdyna", "LS-DYNA keyword (.k)")])
|
||||
.req()
|
||||
.def("\"abaqus\""),
|
||||
path("path", "File", true).req(),
|
||||
references("steps", "Steps", C::Step).hint("all"),
|
||||
];
|
||||
|
||||
// ---- Solving ----
|
||||
|
||||
const STEP: Field = reference("step", "Step", C::Step).hint("the only one");
|
||||
const SOLVED: Field = reference("step", "Step", C::SolvedStep).hint("the step shown");
|
||||
const MAX_DOFS: Field = int("max_dofs", "Max DOFs", 1, 6_000_000).hint("6000000");
|
||||
const MAX_FACTOR: Field = int("max_factor_entries", "Max factor entries", 1, 400_000_000).hint("400000000");
|
||||
const THREADS: Field = int("threads", "Threads", 1, 256).hint("the session's");
|
||||
|
||||
pub const SOLVE_STATIC: &[Field] = &[STEP, MAX_DOFS, MAX_FACTOR, THREADS];
|
||||
pub const SOLVE_NONLINEAR: &[Field] =
|
||||
&[STEP, MAX_DOFS, MAX_FACTOR, int("max_newton_iterations", "Max Newton iterations", 1, 100_000).hint("100000"), THREADS];
|
||||
pub const SOLVE_FREQUENCY: &[Field] = &[
|
||||
STEP,
|
||||
choice("mass", "Mass matrix", &[("consistent", "Consistent"), ("lumped", "Lumped")]).def("\"consistent\""),
|
||||
num("shift", "Shift", D::None).hint("0"),
|
||||
MAX_DOFS,
|
||||
MAX_FACTOR,
|
||||
int("max_iterations", "Max Lanczos vectors", 1, 3000).hint("3000"),
|
||||
THREADS,
|
||||
];
|
||||
pub const SOLVE_BUCKLE: &[Field] = &[STEP, MAX_DOFS, MAX_FACTOR, int("max_iterations", "Max Lanczos vectors", 1, 3000).hint("3000"), THREADS];
|
||||
pub const SOLVE_EXPLICIT: &[Field] = &[
|
||||
STEP,
|
||||
MAX_DOFS,
|
||||
int("max_increments", "Max increments", 1, 1_000_000_000).hint("1000000000"),
|
||||
int("max_output_values", "Max output values", 1, 50_000_000).hint("50000000"),
|
||||
int("max_contact_faces", "Max contact faces", 1, 10_000_000).hint("10000000"),
|
||||
THREADS,
|
||||
];
|
||||
pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000)];
|
||||
pub const SOLVE_FLUID: &[Field] = &[
|
||||
STEP,
|
||||
int("max_cells", "Max cells", 1, 16_000_000),
|
||||
int("max_steps", "Max steps", 1, 10_000_000),
|
||||
positive("max_cell_updates", "Max cell updates", D::None),
|
||||
positive("max_frame_bytes", "Max frame bytes", D::None),
|
||||
THREADS,
|
||||
];
|
||||
pub const SOLVE_PROBE: &[Field] = &[
|
||||
SOLVED.req(),
|
||||
json("nodes", "Nodes").hint("[labels]"),
|
||||
reference("set", "Set", C::Set),
|
||||
json("at", "Points").hint("[[x, y, z], …]: nearest nodes"),
|
||||
int("mode", "Mode", 1, 1_000_000),
|
||||
int("frame", "Frame", 0, 1_000_000_000),
|
||||
flag("elements", "Element results"),
|
||||
json("points", "Fluid points").hint("[[x, y, z], …]"),
|
||||
json("forces", "Fluid walls").hint("[\"x_min\", \"F2:top\"]"),
|
||||
];
|
||||
|
||||
// ---- Results ----
|
||||
|
||||
pub const DEFORMED: &[Field] = &[SOLVED, flag("show", "Show").def("true"), json("scale", "Scale").def("\"auto\"").hint("\"auto\" or a factor")];
|
||||
pub const CONTOUR: &[Field] = &[
|
||||
SOLVED,
|
||||
choice(
|
||||
"field",
|
||||
"Field",
|
||||
&[
|
||||
("von_mises", "Von Mises stress"),
|
||||
("displacement", "Displacement"),
|
||||
("displacement_x", "Displacement X"),
|
||||
("displacement_y", "Displacement Y"),
|
||||
("displacement_z", "Displacement Z"),
|
||||
("max_principal", "Max principal"),
|
||||
("mid_principal", "Mid principal"),
|
||||
("min_principal", "Min principal"),
|
||||
("stress_xx", "Stress XX"),
|
||||
("stress_yy", "Stress YY"),
|
||||
("stress_zz", "Stress ZZ"),
|
||||
("stress_xy", "Stress XY"),
|
||||
("stress_xz", "Stress XZ"),
|
||||
("stress_yz", "Stress YZ"),
|
||||
("none", "None"),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"von_mises\""),
|
||||
int("bands", "Bands", 2, 24).def("12"),
|
||||
];
|
||||
pub const MODES: &[Field] = &[SOLVED, int("mode", "Mode", 1, 1_000_000).def("1")];
|
||||
pub const ANIMATE: &[Field] = &[flag("on", "Animate").def("true")];
|
||||
pub const FRAME: &[Field] = &[SOLVED, int("frame", "Frame", 0, 1_000_000_000), positive("time", "Time", D::Time).hint("the last frame")];
|
||||
pub const REACTIONS: &[Field] = &[SOLVED, references("sets", "Sets", C::Set).hint("the step's BC sets"), xyz("about", "About point", D::Length)];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// The form's starting parameters (and those with the required names and references filled
|
||||
/// in from the model) are accepted, or refused only for a required field still empty; never
|
||||
/// for a value the form put there itself. Errors other than parameter errors (no design, no
|
||||
/// results) come after the parameters were read.
|
||||
#[test]
|
||||
fn form_defaults_are_accepted_or_need_only_required_fields() {
|
||||
let (mut checked, mut failures) = (0, Vec::new());
|
||||
for c in crate::commands() {
|
||||
let Some(form) = c.form else { continue };
|
||||
if c.id.starts_with("file.") && c.id != "file.save_as" || c.id == "edit.undo" || c.id == "edit.redo" {
|
||||
continue; // they replace or write the session's model; their forms are paths and counts
|
||||
}
|
||||
for fill_refs in [false, true] {
|
||||
let limits = crate::Limits { max_steiner: 10, max_tets: 100, max_hex_elements: 100, threads: 1 };
|
||||
let mut s = Session { limits, ..Session::default() };
|
||||
crate::cmd::define::tests::defined_bar(&mut s);
|
||||
s.execute("solve.run", &json!({})).unwrap();
|
||||
let mut p = prefill(form, &json!({}));
|
||||
if fill_refs {
|
||||
fill_required(&s, form, &mut p);
|
||||
}
|
||||
let missing: Vec<String> = problems(form, &p).into_iter().filter(|p| p.message == "required").map(|p| p.path).collect();
|
||||
match s.execute(c.id, &p) {
|
||||
// State, not parameters: the meshers' default is the open design's bodies, the
|
||||
// bar has no explicit step; activation and probes need one of several fields.
|
||||
Err(EngineError::Params(e)) if STATE.iter().any(|m| e.contains(m)) => {}
|
||||
Err(EngineError::Params(e)) if !missing.iter().any(|k| e.contains(k.rsplit('/').next().unwrap_or(k))) => {
|
||||
failures.push(format!("{}: {p} refused for `{e}`, but only {missing:?} were missing", c.id));
|
||||
}
|
||||
Err(EngineError::Params(_)) => {}
|
||||
_ => checked += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:#?}");
|
||||
assert!(checked > 20, "{checked}");
|
||||
}
|
||||
|
||||
/// The commands whose form is the JSON text alone (no schema yet). None: keep it that way.
|
||||
#[test]
|
||||
fn every_command_has_a_form() {
|
||||
let without: Vec<&str> = crate::commands().iter().filter(|c| c.form.is_none()).map(|c| c.id).collect();
|
||||
assert_eq!(without, Vec::<&str>::new());
|
||||
// A command that takes nothing has an empty form, and says so.
|
||||
for c in crate::commands() {
|
||||
assert_eq!(c.form.is_some_and(<[Field]>::is_empty), c.params.trim_start().starts_with("{}"), "{}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
const STATE: &[&str] = &["open a design first", "`boundary_conditions` or `loads`", "give one of", "is not an explicit", "has a result"];
|
||||
|
||||
/// Fill top-level required names (a fresh one) and references (the first existing item).
|
||||
fn fill_required(s: &Session, form: &[Field], p: &mut Value) {
|
||||
let Some(obj) = p.as_object_mut() else { return };
|
||||
for f in form {
|
||||
if !f.required || obj.contains_key(f.key) {
|
||||
continue;
|
||||
}
|
||||
match f.kind {
|
||||
Kind::Text => {
|
||||
obj.insert(f.key.into(), json!("Fresh"));
|
||||
}
|
||||
Kind::Ref(c) => {
|
||||
if let Some(n) = s.names(c).first() {
|
||||
obj.insert(f.key.into(), json!(n));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every default is JSON; every variant field's default names a variant.
|
||||
#[test]
|
||||
fn defaults_parse() {
|
||||
fn walk(fields: &[Field]) {
|
||||
for f in fields {
|
||||
if let Some(d) = f.default {
|
||||
assert!(serde_json::from_str::<serde_json::Value>(d).is_ok(), "{}: {d}", f.key);
|
||||
}
|
||||
match f.kind {
|
||||
Kind::Group(g) | Kind::Rows { fields: g, .. } | Kind::Either(g) => walk(g),
|
||||
Kind::Variants { variants, .. } => variants.iter().for_each(|v| walk(v.fields)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in crate::commands() {
|
||||
if let Some(form) = c.form {
|
||||
walk(form);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
form: Some(super::forms::OPEN_DESIGN),
|
||||
kind: CommandKind::Model,
|
||||
run: open,
|
||||
};
|
||||
@@ -20,6 +21,7 @@ pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
run: bodies,
|
||||
};
|
||||
@@ -28,6 +30,7 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
form: Some(super::forms::TESSELLATE),
|
||||
kind: CommandKind::Model,
|
||||
run: tessellate,
|
||||
};
|
||||
@@ -36,13 +39,14 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
id: "geometry.import.mesh",
|
||||
label: "Import Tagged Mesh",
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
form: Some(super::forms::IMPORT_MESH),
|
||||
kind: CommandKind::Model,
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = super::file_param(s, p, "path", Access::Read)?;
|
||||
let bytes = fieldcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?;
|
||||
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
|
||||
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
|
||||
let design = open_design(&bytes)?;
|
||||
let out = json!({
|
||||
@@ -101,7 +105,7 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.sources.meshes.len() >= fieldcraft_io::MAX_MESH_SOURCES {
|
||||
return Err(EngineError::Params(format!("a model imports at most {} meshes", fieldcraft_io::MAX_MESH_SOURCES)));
|
||||
}
|
||||
let bytes = fieldcraft_geometry::read_file(&path, tagged_mesh::MAX_FILE_BYTES)?;
|
||||
let bytes = super::file::read_file(&path, tagged_mesh::MAX_FILE_BYTES as u64)?;
|
||||
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
|
||||
let surfaces = tagged_mesh::read_3mf(&bytes)?;
|
||||
let first = s.surfaces.len();
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.generate",
|
||||
label: "Generate Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
run: hex_generate,
|
||||
};
|
||||
@@ -33,7 +34,7 @@ pub(super) struct Input<'a> {
|
||||
pub(super) name: String,
|
||||
pub(super) mesh: Cow<'a, SurfaceMesh>,
|
||||
surfaces: Vec<Option<Surface>>,
|
||||
face_names: Vec<String>,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) diagonal: f64,
|
||||
}
|
||||
|
||||
@@ -117,6 +118,7 @@ pub const HEX_SWEEP: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.sweep",
|
||||
label: "Sweep Hex Mesh",
|
||||
params: r#"the parameters of mesh.hex.generate, with "method": "swept" (default: extrude, sweep or revolve, whichever fits first) | "extrude" | "sweep" | "revolve" — mesh.hex.generate limited to the sweeping methods"#,
|
||||
form: Some(super::forms::HEX_SWEEP),
|
||||
kind: CommandKind::Model,
|
||||
run: hex_sweep,
|
||||
};
|
||||
@@ -125,6 +127,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.mapped",
|
||||
label: "Mapped Hex Mesh",
|
||||
params: r#"the parameters of mesh.hex.generate without "method" — mesh.hex.generate with the mapped method: a six-sided block (six faces of four edges, three edges at each of eight corners)"#,
|
||||
form: Some(super::forms::HEX_MAPPED),
|
||||
kind: CommandKind::Model,
|
||||
run: hex_mapped,
|
||||
};
|
||||
@@ -133,6 +136,7 @@ pub const HEX_BLOCKS: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.blocks",
|
||||
label: "Block Hex Mesh",
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
form: Some(super::forms::HEX_BLOCKS),
|
||||
kind: CommandKind::Model,
|
||||
run: hex_blocks,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ use fieldcraft_mesh::quality::{self, QualityReport};
|
||||
use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
|
||||
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
|
||||
use fieldcraft_mesh::tet::improve::ImproveOptions;
|
||||
use fieldcraft_mesh::tet::{Order, QualityOptions, TetQuality, quality_mesh, raw_mesh};
|
||||
use fieldcraft_mesh::tet::{Order, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
|
||||
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.add",
|
||||
label: "Add Node",
|
||||
params: r#"{"label": 1, "xyz": [x, y, z]} — adds the node, or moves it if the label exists"#,
|
||||
form: Some(super::forms::NODE_ADD),
|
||||
kind: CommandKind::Model,
|
||||
run: node_add,
|
||||
};
|
||||
@@ -27,6 +28,7 @@ pub const NODE_REMOVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.remove",
|
||||
label: "Remove Node",
|
||||
params: r#"{"label": 1} — only a node no element uses"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
run: node_remove,
|
||||
};
|
||||
@@ -35,17 +37,25 @@ pub const ELEMENT_ADD: CommandSpec = CommandSpec {
|
||||
id: "mesh.element.add",
|
||||
label: "Add Element",
|
||||
params: r#"{"label": 1, "kind": "rod2|beam2|tri3|tri6|quad4|quad8|tet4|tet10|wedge6|hex8|hex20", "nodes": [labels]} — adds or replaces"#,
|
||||
form: Some(super::forms::ELEMENT_ADD),
|
||||
kind: CommandKind::Model,
|
||||
run: element_add,
|
||||
};
|
||||
|
||||
pub const ELEMENT_REMOVE: CommandSpec =
|
||||
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, kind: CommandKind::Model, run: element_remove };
|
||||
pub const ELEMENT_REMOVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.element.remove",
|
||||
label: "Remove Element",
|
||||
params: r#"{"label": 1}"#,
|
||||
form: Some(super::forms::LABEL),
|
||||
kind: CommandKind::Model,
|
||||
run: element_remove,
|
||||
};
|
||||
|
||||
pub const QUALITY: CommandSpec = CommandSpec {
|
||||
id: "mesh.quality",
|
||||
label: "Mesh Quality",
|
||||
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
|
||||
form: Some(super::forms::QUALITY),
|
||||
kind: CommandKind::Model,
|
||||
run: quality_report,
|
||||
};
|
||||
@@ -54,6 +64,7 @@ pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
id: "mesh.delaunay.points",
|
||||
label: "Delaunay of Points",
|
||||
params: r#"{"points": [[x, y, z], ...] (at most 20000)} — the exact Delaunay tetrahedralization (a debugging report; the model is not changed): {"dimension", "vertices", "duplicates": [[point, kept point], ...], "tets": [[i, j, k, l], ...]} with 0-based point indices in tet4 order; collinear or coplanar points give no tets"#,
|
||||
form: Some(super::forms::DELAUNAY_POINTS),
|
||||
kind: CommandKind::Model,
|
||||
run: delaunay_points,
|
||||
};
|
||||
@@ -79,6 +90,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
id: "mesh.surface.check",
|
||||
label: "Check Boundary Surface",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, optional "weld": 1e-9 (0 or 1e-15..=1e-3 of the bounding-box diagonal), "weld_distance": mm (default: the surface's own, twice SolidCraft's linear tolerance for a tessellated body, 0 for an imported mesh), "feature_angle": 45 (degrees, (0, 180]) — welds the tessellated body or imported surface and checks it can bound a volume mesh: closed, manifold, outward, no self-intersections; reports shells, genus, volume and feature edges, or the located defects"#,
|
||||
form: Some(super::forms::SURFACE_CHECK),
|
||||
kind: CommandKind::Model,
|
||||
run: surface_check,
|
||||
};
|
||||
@@ -86,7 +98,8 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.tet.generate",
|
||||
label: "Generate Tet Mesh",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh does not pass the validity gate"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
run: tet_generate,
|
||||
};
|
||||
@@ -199,24 +212,113 @@ fn quality_json(q: &TetQuality) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// One body's tetrahedra, ready to add, with what its report needs of the surface and the CDT.
|
||||
struct BodyTets {
|
||||
name: String,
|
||||
face_names: Vec<String>,
|
||||
mesh: QualityMesh,
|
||||
steiner: usize,
|
||||
cavities: usize,
|
||||
surface_volume: f64,
|
||||
}
|
||||
|
||||
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
|
||||
/// names the body.
|
||||
fn mesh_body(
|
||||
s: &Session,
|
||||
p: &Value,
|
||||
input: super::hex::Input,
|
||||
cdt: &CdtOptions,
|
||||
options: &QualityOptions,
|
||||
quality: bool,
|
||||
several: bool,
|
||||
) -> Result<BodyTets> {
|
||||
let mesh = || -> Result<BodyTets> {
|
||||
// The exact faces of a design body; an imported surface keeps to its triangles.
|
||||
let analytic = match p.get("surface") {
|
||||
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
|
||||
Some(_) => Vec::new(),
|
||||
};
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
mesh,
|
||||
steiner: tets.steiner.len(),
|
||||
cavities: tets.stats.cavities,
|
||||
surface_volume: surface.volume(),
|
||||
})
|
||||
};
|
||||
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (options, quality) = quality_options(p, s.limits.max_tets as u64)?;
|
||||
let cdt_options =
|
||||
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
|
||||
let (surface, body, face_names, analytic) = {
|
||||
let Input { mesh, body, faces } = surface_input(s, p)?;
|
||||
let analytic = s.design.as_deref().and_then(|d| d.body(&body)).filter(|_| p.get("body").is_some()).map(analytic_surfaces).unwrap_or_default();
|
||||
let options = surface_options(p, &mesh)?;
|
||||
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces, analytic)
|
||||
};
|
||||
let cdt = tetrahedralize(&surface, &cdt_options)?;
|
||||
let mesh = if quality { quality_mesh(&surface, &cdt, analytic, &options)? } else { raw_mesh(&surface, &cdt)? };
|
||||
// Every body is meshed before the model changes; with several, an error names its body.
|
||||
let inputs = super::hex::inputs(s, p)?;
|
||||
let several = inputs.len() > 1;
|
||||
let mut meshed = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
|
||||
}
|
||||
|
||||
let mut bodies = Vec::with_capacity(meshed.len());
|
||||
for b in &meshed {
|
||||
let (first_node, first_element) = add_tets(s, &b.name, &b.face_names, &b.mesh)?;
|
||||
let r = &b.mesh.report;
|
||||
bodies.push(json!({
|
||||
"body": b.name,
|
||||
"nodes": b.mesh.points.len(),
|
||||
"elements": b.mesh.elements.len(),
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"steiner": b.steiner,
|
||||
"cavities": b.cavities,
|
||||
"boundary_faces": b.mesh.boundary.len(),
|
||||
"volume": r.volume,
|
||||
"boundary_volume": r.boundary_volume,
|
||||
"surface_volume": b.surface_volume,
|
||||
"size": r.size,
|
||||
"quality": {
|
||||
"cdt": quality_json(&r.input),
|
||||
"mesh": quality_json(&r.output),
|
||||
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
|
||||
"odt_moves": r.odt_moves,
|
||||
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
|
||||
"straightened": r.straightened,
|
||||
"stopped": r.stopped,
|
||||
},
|
||||
}));
|
||||
}
|
||||
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
|
||||
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
|
||||
let first = bodies.first();
|
||||
Ok(json!({
|
||||
"kind": meshed.first().map(|b| b.mesh.kind.name()),
|
||||
"nodes": count(|b| b.mesh.points.len()),
|
||||
"elements": count(|b| b.mesh.elements.len()),
|
||||
"first_node": first.map(|b| b["first_node"].clone()),
|
||||
"first_element": first.map(|b| b["first_element"].clone()),
|
||||
"boundary_faces": count(|b| b.mesh.boundary.len()),
|
||||
"volume": sum(|b| b.mesh.report.volume),
|
||||
"boundary_volume": sum(|b| b.mesh.report.boundary_volume),
|
||||
"surface_volume": sum(|b| b.surface_volume),
|
||||
"bodies": bodies,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add one body's elements after the model's largest labels, tagging them with the body and each
|
||||
/// boundary element face with its geometry face; returns the first node and element labels.
|
||||
fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
|
||||
for (i, xyz) in mesh.points.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body)?)?;
|
||||
for (i, e) in mesh.elements.iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
|
||||
@@ -229,7 +331,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
|
||||
let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?;
|
||||
let t = s.model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
|
||||
if let Some(slot) = face_tags.get_mut(face) {
|
||||
*slot = Some(t);
|
||||
}
|
||||
@@ -239,37 +341,14 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
|
||||
s.model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
}
|
||||
let r = &mesh.report;
|
||||
Ok(json!({
|
||||
"body": body,
|
||||
"kind": mesh.kind.name(),
|
||||
"nodes": mesh.points.len(),
|
||||
"elements": mesh.elements.len(),
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"steiner": cdt.steiner.len(),
|
||||
"cavities": cdt.stats.cavities,
|
||||
"boundary_faces": mesh.boundary.len(),
|
||||
"volume": r.volume,
|
||||
"boundary_volume": r.boundary_volume,
|
||||
"surface_volume": surface.volume(),
|
||||
"size": r.size,
|
||||
"quality": {
|
||||
"cdt": quality_json(&r.input),
|
||||
"mesh": quality_json(&r.output),
|
||||
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
|
||||
"odt_moves": r.odt_moves,
|
||||
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
|
||||
"straightened": r.straightened,
|
||||
"stopped": r.stopped,
|
||||
},
|
||||
}))
|
||||
Ok((first_node, first_element))
|
||||
}
|
||||
|
||||
pub const PLANAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.planar",
|
||||
label: "2D Mesh",
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
form: Some(super::forms::PLANAR),
|
||||
kind: CommandKind::Model,
|
||||
run: planar,
|
||||
};
|
||||
@@ -464,6 +543,7 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"summary": quality_summary(&r),
|
||||
"measured": r.measured,
|
||||
"skipped": skipped,
|
||||
"valid": r.valid,
|
||||
@@ -475,21 +555,33 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// A boundary to mesh: the surface, with the body name and the geometry name of each face index
|
||||
/// for tagging.
|
||||
struct Input<'a> {
|
||||
mesh: Cow<'a, SurfaceMesh>,
|
||||
body: String,
|
||||
faces: Vec<String>,
|
||||
/// A quality report in a line, for the status bar and the CLI: the elements measured, how many
|
||||
/// are invalid or undetermined, and the scaled Jacobian's minimum and mean.
|
||||
fn quality_summary(r: &QualityReport) -> String {
|
||||
if r.measured == 0 {
|
||||
return "no elements to measure".into();
|
||||
}
|
||||
let mut text = format!("{} elements", r.measured);
|
||||
if r.invalid.is_empty() && r.undetermined.is_empty() {
|
||||
text.push_str(", all valid");
|
||||
}
|
||||
if !r.invalid.is_empty() {
|
||||
text.push_str(&format!(", {} invalid", r.invalid.len()));
|
||||
}
|
||||
if !r.undetermined.is_empty() {
|
||||
text.push_str(&format!(", {} undetermined", r.undetermined.len()));
|
||||
}
|
||||
let jacobian = r.metrics.iter().find(|m| m.metric == quality::Metric::ScaledJacobian && m.count > 0);
|
||||
if let Some((min, mean)) = jacobian.and_then(|m| m.mean.map(|mean| (m.min, mean))) {
|
||||
text.push_str(&format!("; scaled Jacobian min {min:.3}, mean {mean:.3}"));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// The surface named by `body` (tessellated at `tolerance`) or `surface` (an imported index).
|
||||
fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Input<'a>> {
|
||||
fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Cow<'a, SurfaceMesh>> {
|
||||
match (p.get("body"), p.get("surface")) {
|
||||
(Some(_), None) => {
|
||||
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
|
||||
Ok(Input { mesh: Cow::Owned(mesh), body: shape.name.clone(), faces: shape.faces().iter().map(|f| f.name.clone()).collect() })
|
||||
}
|
||||
(Some(_), None) => Ok(Cow::Owned(super::geometry::body_mesh(s, p)?.1)),
|
||||
(None, Some(i)) => {
|
||||
let n = s.surfaces.len();
|
||||
let t = i
|
||||
@@ -497,7 +589,7 @@ fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Input<'a>> {
|
||||
.and_then(|i| usize::try_from(i).ok())
|
||||
.and_then(|i| s.surfaces.get(i))
|
||||
.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
|
||||
Ok(Input { mesh: Cow::Borrowed(&t.mesh), body: t.name.clone(), faces: (0..t.faces).map(|f| f.to_string()).collect() })
|
||||
Ok(Cow::Borrowed(&t.mesh))
|
||||
}
|
||||
_ => Err(EngineError::Params("give either `body` (with `tolerance`) or `surface`".into())),
|
||||
}
|
||||
@@ -508,7 +600,7 @@ fn defect_json(d: &Defect) -> Value {
|
||||
}
|
||||
|
||||
fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mesh = surface_input(s, p)?.mesh;
|
||||
let mesh = surface_input(s, p)?;
|
||||
let options = surface_options(p, &mesh)?;
|
||||
let surface = match BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options) {
|
||||
Ok(surface) => surface,
|
||||
@@ -709,6 +801,7 @@ mod tests {
|
||||
// The certified quality report agrees.
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements);
|
||||
assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}");
|
||||
// A second run appends after the existing labels.
|
||||
let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap();
|
||||
assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1);
|
||||
@@ -723,16 +816,60 @@ mod tests {
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 10}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": "many"}),
|
||||
json!({"body": "Body1"}),
|
||||
json!({"body": "Body1", "tolerance": -1}),
|
||||
json!({"body": "Nope", "tolerance": 0.5}),
|
||||
json!({"body": "Body1", "surface": 0}),
|
||||
json!({"surface": 0}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("mesh.tet.generate", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing");
|
||||
}
|
||||
|
||||
/// Without parameters every body of the open design is meshed, each at a tolerance from its
|
||||
/// size; an error says which body it is about.
|
||||
#[test]
|
||||
fn tet_generate_defaults_to_every_body() {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}},
|
||||
{"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("open a design first"));
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
|
||||
let bodies = r["bodies"].as_array().unwrap();
|
||||
assert_eq!(bodies.len(), 2, "{r}");
|
||||
assert_eq!(r["kind"], "tet4");
|
||||
assert!((r["volume"].as_f64().unwrap() - 2000.0).abs() < 1e-6, "{r}");
|
||||
assert_eq!(s.model.elements().len() as u64, r["elements"].as_u64().unwrap());
|
||||
assert_eq!(s.model.classification().elements().len(), s.model.elements().len(), "every element is on its body");
|
||||
// The second body's labels follow the first's.
|
||||
assert_eq!(bodies[1]["first_element"].as_u64(), Some(bodies[0]["elements"].as_u64().unwrap() + 1));
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
// A body named alone takes the same default tolerance.
|
||||
let one = s.execute("mesh.tet.generate", &json!({"body": bodies[0]["body"]})).unwrap();
|
||||
assert_eq!(one["elements"], bodies[0]["elements"]);
|
||||
let e = s.execute("mesh.tet.generate", &json!({"max_tets": 1})).unwrap_err().to_string();
|
||||
assert!(e.starts_with("body `Box"), "{e}");
|
||||
}
|
||||
|
||||
/// The quality report's one-line summary counts the inverted elements.
|
||||
#[test]
|
||||
fn quality_summary_counts_invalid_elements() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.execute("mesh.quality", &json!({})).unwrap()["summary"], "no elements to measure");
|
||||
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
|
||||
}
|
||||
s.execute("mesh.element.add", &json!({"label": 1, "kind": "tet4", "nodes": [1, 2, 3, 4]})).unwrap();
|
||||
s.execute("mesh.element.add", &json!({"label": 2, "kind": "tet4", "nodes": [1, 3, 2, 4]})).unwrap();
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert!(q["summary"].as_str().unwrap().starts_with("2 elements, 1 invalid; scaled Jacobian min -"), "{q}");
|
||||
}
|
||||
|
||||
/// The session's caps are the defaults of `max_steiner` and `max_tets`; a parameter overrides them.
|
||||
#[test]
|
||||
fn tet_generate_honours_the_session_caps() {
|
||||
@@ -766,7 +903,7 @@ mod tests {
|
||||
let design = Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap());
|
||||
let mut s = Session { design: Some(design.clone()), ..Session::default() };
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6})).unwrap();
|
||||
let q = &r["quality"];
|
||||
let q = &r["bodies"][0]["quality"];
|
||||
assert_eq!(q["mesh"]["inadmissible"], json!(0), "{r}");
|
||||
assert_eq!(q["mesh"]["slivers"], json!(0), "{r}");
|
||||
assert!(q["mesh"]["min_dihedral"].as_f64().unwrap() > 15.0, "{r}");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
mod block;
|
||||
mod contact;
|
||||
pub(crate) mod deck;
|
||||
pub(crate) mod define;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod forms;
|
||||
mod geometry;
|
||||
mod hex;
|
||||
pub(crate) mod mesh;
|
||||
@@ -67,6 +69,11 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::COUPLING_CREATE,
|
||||
define::COUPLING_EDIT,
|
||||
define::COUPLING_DELETE,
|
||||
contact::PAIR,
|
||||
contact::GENERAL,
|
||||
contact::EDIT,
|
||||
contact::DELETE,
|
||||
contact::FIND,
|
||||
define::MASS_CREATE,
|
||||
define::MASS_EDIT,
|
||||
define::MASS_DELETE,
|
||||
@@ -102,6 +109,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
solve::STATIC,
|
||||
solve::NONLINEAR,
|
||||
solve::FREQUENCY,
|
||||
solve::BUCKLE,
|
||||
solve::HEAT,
|
||||
solve::EXPLICIT,
|
||||
solve::HISTORY,
|
||||
|
||||
@@ -4,22 +4,26 @@ use serde_json::{Value, json};
|
||||
use super::{listed, name};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", kind: CommandKind::Model, run: info };
|
||||
pub const INFO: CommandSpec =
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: info };
|
||||
|
||||
pub const UNITS: CommandSpec = CommandSpec {
|
||||
id: "model.units",
|
||||
label: "Unit System",
|
||||
params: r#"{"system": "m-kg-s-N-Pa|mm-t-s-N-MPa|mm-kg-ms-kN-GPa|mm-g-ms-N-MPa|in-lbf-s-psi|unspecified"} — labels the system the model's numbers are in; nothing is converted"#,
|
||||
form: Some(super::forms::UNITS),
|
||||
kind: CommandKind::Model,
|
||||
run: units,
|
||||
};
|
||||
|
||||
pub const CHECK: CommandSpec = CommandSpec { id: "model.check", label: "Check Model", params: "{}", kind: CommandKind::Model, run: check };
|
||||
pub const CHECK: CommandSpec =
|
||||
CommandSpec { id: "model.check", label: "Check Model", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: check };
|
||||
|
||||
pub const SET_RESOLVE: CommandSpec = CommandSpec {
|
||||
id: "model.set.resolve",
|
||||
label: "Resolve Set",
|
||||
params: r#"{"name": "root"} — the set's nodes, elements and element faces on the current mesh (the first 100 of each); fails naming every member that is not on the mesh"#,
|
||||
form: Some(super::forms::SET_RESOLVE),
|
||||
kind: CommandKind::Model,
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.octree",
|
||||
label: "Octree Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
form: Some(super::forms::HEX_OCTREE),
|
||||
kind: CommandKind::Model,
|
||||
run: hex_octree,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
id: "results.deformed",
|
||||
label: "Deformed Shape",
|
||||
params: r#"{"step": "Load", "show": true, "scale": "auto"} — shows a solved step's deformed shape (`show: false` the undeformed mesh); `scale` is a factor (0 draws the undeformed shape, so a sequence from 0 swings it out) or "auto" (the largest displacement drawn a tenth of the model's size); `step` defaults to the step shown or the first solved one"#,
|
||||
form: Some(super::forms::DEFORMED),
|
||||
kind: CommandKind::Session,
|
||||
run: deformed,
|
||||
};
|
||||
@@ -20,6 +21,7 @@ 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); "none" turns them off; 2 to 24 bands"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
run: contour,
|
||||
};
|
||||
@@ -28,6 +30,7 @@ pub const MODES: CommandSpec = CommandSpec {
|
||||
id: "results.modes",
|
||||
label: "Mode Shapes",
|
||||
params: r#"{"step": "Modes", "mode": 1} — shows a mode shape of a solved frequency step (from 1) and lists every mode's frequency"#,
|
||||
form: Some(super::forms::MODES),
|
||||
kind: CommandKind::Session,
|
||||
run: modes,
|
||||
};
|
||||
@@ -36,6 +39,7 @@ pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
id: "results.animate",
|
||||
label: "Animate",
|
||||
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
|
||||
form: Some(super::forms::ANIMATE),
|
||||
kind: CommandKind::Session,
|
||||
run: animate,
|
||||
};
|
||||
@@ -44,6 +48,7 @@ pub const FRAME: CommandSpec = CommandSpec {
|
||||
id: "results.frame",
|
||||
label: "Field Frame",
|
||||
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
|
||||
form: Some(super::forms::FRAME),
|
||||
kind: CommandKind::Session,
|
||||
run: frame,
|
||||
};
|
||||
@@ -52,6 +57,7 @@ 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"#,
|
||||
form: Some(super::forms::REACTIONS),
|
||||
kind: CommandKind::Session,
|
||||
run: reactions,
|
||||
};
|
||||
@@ -60,6 +66,7 @@ pub const CLEAR: CommandSpec = CommandSpec {
|
||||
id: "results.clear",
|
||||
label: "Hide Results",
|
||||
params: "{} — back to the model: hides the deformed shape, contours and modes",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Session,
|
||||
run: clear,
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ use fieldcraft_mesh::surface::{BoundarySurface, SurfaceOptions};
|
||||
use fieldcraft_model::FluidDomain;
|
||||
use fieldcraft_parallel as parallel;
|
||||
use fieldcraft_solver::{
|
||||
FrequencyOptions, Limits, Location, MassKind, StaticResult, StepResult as ImplicitResult, solve_frequency, solve_heat, solve_nonlinear,
|
||||
solve_static,
|
||||
FrequencyOptions, Limits, Location, MassKind, StaticResult, StepResult as ImplicitResult, solve_buckle, solve_frequency, solve_heat,
|
||||
solve_nonlinear, solve_static,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const RUN: CommandSpec = CommandSpec {
|
||||
id: "solve.run",
|
||||
label: "Solve All Steps",
|
||||
params: r#"{} — solves every step in order with its procedure's solver (solve.static, solve.frequency, solve.explicit, solve.fluid with their default caps) and keeps the results; stops at the first step that fails, naming it"#,
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
run: run_all,
|
||||
};
|
||||
@@ -27,6 +28,7 @@ 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"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
run: run_static,
|
||||
};
|
||||
@@ -35,6 +37,7 @@ 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; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_NONLINEAR),
|
||||
kind: CommandKind::Model,
|
||||
run: run_nonlinear,
|
||||
};
|
||||
@@ -43,6 +46,7 @@ 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"#,
|
||||
form: Some(super::forms::SOLVE_STATIC),
|
||||
kind: CommandKind::Model,
|
||||
run: run_heat,
|
||||
};
|
||||
@@ -51,14 +55,25 @@ 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"#,
|
||||
form: Some(super::forms::SOLVE_FREQUENCY),
|
||||
kind: CommandKind::Model,
|
||||
run: run_frequency,
|
||||
};
|
||||
|
||||
pub const BUCKLE: CommandSpec = CommandSpec {
|
||||
id: "solve.buckle",
|
||||
label: "Solve Buckling Step",
|
||||
params: r#"{"step": "Buckle", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_iterations": 3000, "threads": 8} — linear buckling of a buckling step (the only one when `step` is left out): the step's loads as a linear preload, then the lowest load factors λ with (K + λ K_σ) φ = 0 and their mode shapes (largest component 1), or those above the step's shift (whose LDLᵀ factor counts the load factors below it); negative load factors found are reported; the result stays in the session and its modes display like frequency modes; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_BUCKLE),
|
||||
kind: CommandKind::Model,
|
||||
run: run_buckle,
|
||||
};
|
||||
|
||||
pub const EXPLICIT: CommandSpec = CommandSpec {
|
||||
id: "solve.explicit",
|
||||
label: "Solve Explicit Dynamic Step",
|
||||
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences; keeps its histories and field frames in the session; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Impact", "max_dofs": 6000000, "max_increments": 1000000000, "max_output_values": 50000000, "max_contact_faces": 10000000, "threads": 8} — transient response of an explicit dynamic step (the only one when `step` is left out) by central differences, with the model's contacts (interaction.contact.*) and the step's rigid walls; keeps its histories and field frames in the session and reports each contact's peak force, first and last contact, deepest penetration, friction dissipated and the nodes adjusted at the start; the caps may only be lowered (the step's own increment cap applies too); `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_EXPLICIT),
|
||||
kind: CommandKind::Model,
|
||||
run: run_explicit,
|
||||
};
|
||||
@@ -66,7 +81,8 @@ pub const EXPLICIT: CommandSpec = CommandSpec {
|
||||
pub const HISTORY: CommandSpec = CommandSpec {
|
||||
id: "solve.history",
|
||||
label: "Result Histories",
|
||||
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
params: r#"{"step": "Impact", "every": 1} — the time histories of a solved explicit step: time, increment, energies, momentum, constraint force, the normal force across each contact (in `contacts` order) and the history nodes' displacement and velocity; `every` keeps one sample in so many (by default enough to return at most 10000)"#,
|
||||
form: Some(super::forms::SOLVE_HISTORY),
|
||||
kind: CommandKind::Model,
|
||||
run: history,
|
||||
};
|
||||
@@ -75,6 +91,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
id: "solve.fluid",
|
||||
label: "Solve Fluid Step",
|
||||
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FLUID),
|
||||
kind: CommandKind::Model,
|
||||
run: run_fluid,
|
||||
};
|
||||
@@ -83,6 +100,7 @@ pub const PROBE: CommandSpec = CommandSpec {
|
||||
id: "solve.probe",
|
||||
label: "Probe Results",
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
form: Some(super::forms::SOLVE_PROBE),
|
||||
kind: CommandKind::Model,
|
||||
run: probe,
|
||||
};
|
||||
@@ -150,6 +168,7 @@ fn run_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Procedure::Static {} | Procedure::CoupledTemperatureDisplacement {} => run_static,
|
||||
Procedure::HeatTransfer { .. } => run_heat,
|
||||
Procedure::Frequency { .. } => run_frequency,
|
||||
Procedure::Buckle { .. } => run_buckle,
|
||||
Procedure::ExplicitDynamic(_) => run_explicit,
|
||||
Procedure::Fluid { .. } => run_fluid,
|
||||
Procedure::NonlinearStatic(_) => run_nonlinear,
|
||||
@@ -218,6 +237,8 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"increments": history.map(|h| &h.increments),
|
||||
"iterations": history.map(|h| h.iterations),
|
||||
"frames": history.map(|h| h.load_factors()),
|
||||
"load_factor": history.and_then(|h| h.frames.last()).map(|f| f.load_factor),
|
||||
"critical_points": history.map(|h| &h.critical_points),
|
||||
"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)))),
|
||||
@@ -283,12 +304,31 @@ fn run_frequency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
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 summary = json!({
|
||||
"step": r.step,
|
||||
"dofs": r.stats.dofs,
|
||||
"equations": r.stats.equations,
|
||||
"lanczos_vectors": r.stats.lanczos_vectors,
|
||||
"modes": r.modes.iter().enumerate().map(|(i, m)| json!({"mode": i + 1, "load_factor": m.eigenvalue})).collect::<Vec<_>>(),
|
||||
"buckling": r.buckling,
|
||||
"warnings": r.warnings,
|
||||
});
|
||||
s.store_result(StepResult::Implicit(ImplicitResult::Frequency(r)));
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn run_explicit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use fieldcraft_explicit::Limits as ExplicitLimits;
|
||||
let limits = ExplicitLimits {
|
||||
max_dofs: cap(p, "max_dofs", ExplicitLimits::MAX_DOFS)?,
|
||||
max_increments: cap(p, "max_increments", ExplicitLimits::MAX_INCREMENTS)?,
|
||||
max_output_values: cap(p, "max_output_values", ExplicitLimits::MAX_OUTPUT_VALUES)?,
|
||||
max_contact_faces: cap(p, "max_contact_faces", ExplicitLimits::MAX_CONTACT_FACES)?,
|
||||
};
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let model = &s.model;
|
||||
@@ -314,6 +354,8 @@ fn explicit_summary(r: &ExplicitResult) -> Value {
|
||||
"frames": r.frames.len(),
|
||||
"history_samples": r.history.time.len(),
|
||||
"eroded": r.eroded,
|
||||
"contacts": r.contacts,
|
||||
"mass_scaling": r.mass_scaling,
|
||||
"warnings": r.warnings,
|
||||
})
|
||||
}
|
||||
@@ -355,6 +397,8 @@ fn history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"energies": pick(&|i| json!(h.energies.get(i))),
|
||||
"momentum": pick(&|i| json!(h.momentum.get(i))),
|
||||
"constraint_force": pick(&|i| json!(h.constraint_force.get(i))),
|
||||
"contact_force": pick(&|i| json!(h.contact_force.get(i))),
|
||||
"contacts": r.contacts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
|
||||
"nodes": nodes,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ pub const TAG: CommandSpec = CommandSpec {
|
||||
id: "mesh.tag",
|
||||
label: "Tag Mesh with Geometry",
|
||||
params: r#"{"geometry": {"body": "Body1"} | {"body", "face"} | {"body", "edge"} | {"body", "vertex"}, "elements": [labels] (filling a body, face or edge), "faces": [{"element": 1, "face": 3}] (on a face), "edges": [{"element": 1, "edge": 2}] (on an edge), "nodes": [labels] (on a vertex)} — records where the mesh lies on the geometry, so sets named by geometry resolve"#,
|
||||
form: Some(super::forms::TAG),
|
||||
kind: CommandKind::Model,
|
||||
run: tag,
|
||||
};
|
||||
|
||||
pub const TAG_CLEAR: CommandSpec =
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", kind: CommandKind::Model, run: tag_clear };
|
||||
CommandSpec { id: "mesh.tag.clear", label: "Clear Geometry Tags", params: "{}", form: Some(&[]), kind: CommandKind::Model, run: tag_clear };
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -20,6 +20,7 @@ pub use cmd::analytic_surfaces;
|
||||
pub mod history;
|
||||
pub mod results;
|
||||
pub mod sample;
|
||||
pub mod schema;
|
||||
pub mod view;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -108,6 +109,9 @@ pub struct CommandSpec {
|
||||
pub label: &'static str,
|
||||
/// The JSON parameters, documented for scripts, `fieldcraft-cli commands` and agents.
|
||||
pub params: &'static str,
|
||||
/// The parameters field by field, for forms and JSON Schema; `None` when not described yet
|
||||
/// (the form is then the JSON text alone).
|
||||
pub form: Option<&'static [schema::Field]>,
|
||||
pub kind: CommandKind,
|
||||
pub run: fn(&mut Session, &Value) -> Result<Value>,
|
||||
}
|
||||
@@ -117,6 +121,19 @@ pub fn commands() -> &'static [CommandSpec] {
|
||||
cmd::ALL
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
/// The command as JSON for listings (`fieldcraft-cli commands --json`, the control channel's
|
||||
/// `engine.commands`, MCP `list_commands`): id, label, the parameter doc and, when described,
|
||||
/// the parameters as JSON Schema.
|
||||
pub fn listing(&self) -> Value {
|
||||
let mut v = serde_json::json!({"id": self.id, "label": self.label, "params": self.params});
|
||||
if let Some(form) = self.form {
|
||||
v["schema"] = schema::json_schema(form);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
commands().iter().find(|c| c.id == id)
|
||||
}
|
||||
@@ -477,6 +494,18 @@ mod tests {
|
||||
json!({"name": "A", "procedure": {"type": "heat_transfer", "transient": {"end_time": -1, "increment": 0}}}),
|
||||
json!({"name": "A", "procedure": {"type": "coupled_temperature_displacement", "extra": 1}}),
|
||||
json!({"step": "Heat", "frame": u64::MAX, "nodes": [1]}),
|
||||
// Contact.
|
||||
json!({"name": "C", "main": 7, "secondary": null}),
|
||||
json!({"name": "C", "main": "end", "secondary": "root", "friction": {"static": 1e308, "kinetic": -1, "decay": -1e308}}),
|
||||
json!({"name": "C", "main": "end", "secondary": "end"}),
|
||||
json!({"name": "C", "main": "x".repeat(10_000), "secondary": "root", "penalty_scale": 1e308}),
|
||||
json!({"name": "C", "surface": "root", "penalty_scale": 0, "initial_penetration": "ignore"}),
|
||||
json!({"name": "C", "surface": 3}),
|
||||
json!({"name": "C", "surfaces": {"type": "pair", "main": "end"}}),
|
||||
json!({"name": "C", "surfaces": {"type": "general", "surface": "nope"}, "friction": "sticky"}),
|
||||
json!({"tolerance": 1e300, "create": true, "friction": {"static": 0.1}}),
|
||||
json!({"tolerance": -0.0, "create": true}),
|
||||
json!({"tolerance": 1e-300, "create": 1}),
|
||||
json!({"geometry": {"body": "Bar"}, "elements": [1, 99], "faces": [{"element": 1, "face": 200}]}),
|
||||
json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
//! Typed parameter schemas: what a command's JSON parameters are, field by field.
|
||||
//!
|
||||
//! A command's [`CommandSpec::form`](crate::CommandSpec::form) lists its [`Field`]s: name, kind,
|
||||
//! default, whether it is required, its unit dimension, its choices, the model collection it
|
||||
//! names, nested groups and one-of variants. The schema describes the JSON the command already
|
||||
//! takes; it never changes it. The UI draws its command forms from it, `fieldcraft-cli commands
|
||||
//! --json` and the MCP `list_commands` tool give it as JSON Schema ([`json_schema`]).
|
||||
//!
|
||||
//! The schema is descriptive: the command's `run` stays the judge of its parameters. What the
|
||||
//! schema cannot express (a mass given per direction, a table of load-path points) stays reachable
|
||||
//! through the form's JSON view.
|
||||
|
||||
use fieldcraft_model::UnitSystem;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Rows a list field holds at most in a form.
|
||||
pub const MAX_ROWS: usize = 1000;
|
||||
/// Names a reference list offers at most.
|
||||
pub const MAX_NAMES: usize = 10_000;
|
||||
|
||||
/// One parameter of a command.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Field {
|
||||
/// Its JSON key. Empty for [`Kind::Variants`] whose tag and fields sit in the parent object
|
||||
/// (an internally tagged definition such as a load: `{"name", "type": "force", "vector"}`).
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub kind: Kind,
|
||||
pub required: bool,
|
||||
/// The value the form starts with, as JSON text; `None` leaves the field empty.
|
||||
pub default: Option<&'static str>,
|
||||
/// What an empty field means ("every body", "1/25 of the diagonal"), or a short note.
|
||||
pub hint: &'static str,
|
||||
}
|
||||
|
||||
/// What a field holds.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Kind {
|
||||
/// A number in the model's unit system (or a fixed unit such as degrees).
|
||||
Number {
|
||||
dim: Dim,
|
||||
min: Option<f64>,
|
||||
max: Option<f64>,
|
||||
},
|
||||
/// A whole number in `min..=max`.
|
||||
Integer {
|
||||
min: i64,
|
||||
max: i64,
|
||||
},
|
||||
Text,
|
||||
/// A file path; `save` when the command writes it.
|
||||
Path {
|
||||
save: bool,
|
||||
},
|
||||
Bool,
|
||||
/// One of these strings: (value, label).
|
||||
Choice(&'static [(&'static str, &'static str)]),
|
||||
/// A fixed-length list of numbers, one per label (`["x", "y", "z"]`); `nullable` entries may
|
||||
/// be null (a free degree of freedom).
|
||||
Vector {
|
||||
dim: Dim,
|
||||
labels: &'static [&'static str],
|
||||
nullable: bool,
|
||||
},
|
||||
/// The name of an item of the model or the open design.
|
||||
Ref(Collection),
|
||||
/// A list of such names.
|
||||
RefList(Collection),
|
||||
/// A nested object.
|
||||
Group(&'static [Field]),
|
||||
/// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value
|
||||
/// picks the variant whose fields apply.
|
||||
Variants {
|
||||
tag: &'static str,
|
||||
variants: &'static [Variant],
|
||||
},
|
||||
/// An object with exactly one of these keys (`{"set": …}` or `{"reference_point": …}`), or a
|
||||
/// bare string for a [`Kind::Unit`] option.
|
||||
Either(&'static [Field]),
|
||||
/// An option of [`Kind::Either`] written as its key alone (`"line"`).
|
||||
Unit,
|
||||
/// A list of objects with these fields, at most `max` long.
|
||||
Rows {
|
||||
fields: &'static [Field],
|
||||
max: usize,
|
||||
},
|
||||
/// Any JSON: what the schema does not describe.
|
||||
Json,
|
||||
}
|
||||
|
||||
/// One variant of [`Kind::Variants`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Variant {
|
||||
pub tag: &'static str,
|
||||
pub label: &'static str,
|
||||
pub fields: &'static [Field],
|
||||
}
|
||||
|
||||
/// Where a reference field's names come from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Collection {
|
||||
Set,
|
||||
Material,
|
||||
Section,
|
||||
ReferencePoint,
|
||||
Coupling,
|
||||
Contact,
|
||||
Mass,
|
||||
Spring,
|
||||
BoundaryCondition,
|
||||
Load,
|
||||
FluidDomain,
|
||||
Step,
|
||||
/// A solved step.
|
||||
SolvedStep,
|
||||
/// A body of the open design.
|
||||
Body,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Collection::Set => "set",
|
||||
Collection::Material => "material",
|
||||
Collection::Section => "section",
|
||||
Collection::ReferencePoint => "reference point",
|
||||
Collection::Coupling => "coupling",
|
||||
Collection::Contact => "contact",
|
||||
Collection::Mass => "point mass",
|
||||
Collection::Spring => "spring",
|
||||
Collection::BoundaryCondition => "boundary condition",
|
||||
Collection::Load => "load",
|
||||
Collection::FluidDomain => "fluid domain",
|
||||
Collection::Step => "step",
|
||||
Collection::SolvedStep => "solved step",
|
||||
Collection::Body => "body",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The names a reference field may take, in order (at most [`MAX_NAMES`]).
|
||||
pub fn names(&self, c: Collection) -> Vec<String> {
|
||||
let m = &self.model;
|
||||
let names: Box<dyn Iterator<Item = &str>> = match c {
|
||||
Collection::Set => Box::new(m.sets().keys().map(String::as_str)),
|
||||
Collection::Material => Box::new(m.materials().keys().map(String::as_str)),
|
||||
Collection::Section => Box::new(m.sections().keys().map(String::as_str)),
|
||||
Collection::ReferencePoint => Box::new(m.reference_points().keys().map(String::as_str)),
|
||||
Collection::Coupling => Box::new(m.couplings().keys().map(String::as_str)),
|
||||
Collection::Contact => Box::new(m.contacts().keys().map(String::as_str)),
|
||||
Collection::Mass => Box::new(m.masses().keys().map(String::as_str)),
|
||||
Collection::Spring => Box::new(m.springs().keys().map(String::as_str)),
|
||||
Collection::BoundaryCondition => Box::new(m.boundary_conditions().keys().map(String::as_str)),
|
||||
Collection::Load => Box::new(m.loads().keys().map(String::as_str)),
|
||||
Collection::FluidDomain => Box::new(m.fluid_domains().keys().map(String::as_str)),
|
||||
Collection::Step => Box::new(m.steps().iter().map(|s| s.name.as_str())),
|
||||
Collection::SolvedStep => Box::new(self.solved_steps()),
|
||||
Collection::Body => Box::new(self.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.as_str()))),
|
||||
};
|
||||
names.take(MAX_NAMES).map(str::to_string).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A physical dimension: its unit follows the model's unit system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dim {
|
||||
/// No unit (a ratio, a count, a factor).
|
||||
None,
|
||||
Length,
|
||||
Area,
|
||||
Stress,
|
||||
Density,
|
||||
Force,
|
||||
Moment,
|
||||
Mass,
|
||||
Inertia,
|
||||
Time,
|
||||
Velocity,
|
||||
Acceleration,
|
||||
AngularVelocity,
|
||||
Stiffness,
|
||||
Damping,
|
||||
Viscosity,
|
||||
ForcePerVolume,
|
||||
Power,
|
||||
HeatFlux,
|
||||
FilmCoefficient,
|
||||
Conductivity,
|
||||
SpecificHeat,
|
||||
Expansion,
|
||||
Temperature,
|
||||
/// Always millimetres (geometry from SolidCraft).
|
||||
Millimetre,
|
||||
Degree,
|
||||
Hertz,
|
||||
}
|
||||
|
||||
impl Dim {
|
||||
/// Its unit in `system`, or `None` (no unit, or the system is unspecified).
|
||||
pub fn unit(self, system: UnitSystem) -> Option<String> {
|
||||
let fixed = match self {
|
||||
Dim::Millimetre => Some("mm"),
|
||||
Dim::Degree => Some("°"),
|
||||
Dim::Hertz => Some("Hz"),
|
||||
Dim::Expansion => Some("1/K"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fixed {
|
||||
return Some(f.into());
|
||||
}
|
||||
let (l, m, t, f, s) = match system {
|
||||
UnitSystem::Unspecified => return None,
|
||||
UnitSystem::MKgS => ("m", "kg", "s", "N", "Pa"),
|
||||
UnitSystem::MmTonneS => ("mm", "t", "s", "N", "MPa"),
|
||||
UnitSystem::MmKgMs => ("mm", "kg", "ms", "kN", "GPa"),
|
||||
UnitSystem::MmGMs => ("mm", "g", "ms", "N", "MPa"),
|
||||
UnitSystem::InLbfS => ("in", "lbf·s²/in", "s", "lbf", "psi"),
|
||||
};
|
||||
let inch = system == UnitSystem::InLbfS;
|
||||
Some(match self {
|
||||
Dim::Length => l.into(),
|
||||
Dim::Area => format!("{l}²"),
|
||||
Dim::Stress => s.into(),
|
||||
Dim::Density if inch => "lbf·s²/in⁴".into(),
|
||||
Dim::Density => format!("{m}/{l}³"),
|
||||
Dim::Force => f.into(),
|
||||
Dim::Moment => format!("{f}·{l}"),
|
||||
Dim::Mass => m.into(),
|
||||
Dim::Inertia if inch => "lbf·s²·in".into(),
|
||||
Dim::Inertia => format!("{m}·{l}²"),
|
||||
Dim::Time => t.into(),
|
||||
Dim::Velocity => format!("{l}/{t}"),
|
||||
Dim::Acceleration => format!("{l}/{t}²"),
|
||||
Dim::AngularVelocity => format!("rad/{t}"),
|
||||
Dim::Stiffness => format!("{f}/{l}"),
|
||||
Dim::Damping => format!("{f}·{t}/{l}"),
|
||||
Dim::Viscosity => format!("{s}·{t}"),
|
||||
Dim::ForcePerVolume => format!("{f}/{l}³"),
|
||||
Dim::Power => format!("{f}·{l}/{t}"),
|
||||
Dim::HeatFlux => format!("{f}/({l}·{t})"),
|
||||
Dim::FilmCoefficient => format!("{f}/({l}·{t}·K)"),
|
||||
Dim::Conductivity => format!("{f}/({t}·K)"),
|
||||
Dim::SpecificHeat => format!("{l}²/({t}²·K)"),
|
||||
Dim::None | Dim::Temperature | Dim::Millimetre | Dim::Degree | Dim::Hertz | Dim::Expansion => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Building fields (const, so schemas are static tables) ----
|
||||
|
||||
const fn field(key: &'static str, label: &'static str, kind: Kind) -> Field {
|
||||
Field { key, label, kind, required: false, default: None, hint: "" }
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Required.
|
||||
pub const fn req(self) -> Field {
|
||||
Field { required: true, ..self }
|
||||
}
|
||||
/// Starts with this JSON value.
|
||||
pub const fn def(self, json: &'static str) -> Field {
|
||||
Field { default: Some(json), ..self }
|
||||
}
|
||||
/// What an empty field means.
|
||||
pub const fn hint(self, hint: &'static str) -> Field {
|
||||
Field { hint, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn num(key: &'static str, label: &'static str, dim: Dim) -> Field {
|
||||
field(key, label, Kind::Number { dim, min: None, max: None })
|
||||
}
|
||||
pub const fn range(key: &'static str, label: &'static str, dim: Dim, min: f64, max: f64) -> Field {
|
||||
field(key, label, Kind::Number { dim, min: Some(min), max: Some(max) })
|
||||
}
|
||||
pub const fn positive(key: &'static str, label: &'static str, dim: Dim) -> Field {
|
||||
field(key, label, Kind::Number { dim, min: Some(0.0), max: None })
|
||||
}
|
||||
pub const fn int(key: &'static str, label: &'static str, min: i64, max: i64) -> Field {
|
||||
field(key, label, Kind::Integer { min, max })
|
||||
}
|
||||
pub const fn text(key: &'static str, label: &'static str) -> Field {
|
||||
field(key, label, Kind::Text)
|
||||
}
|
||||
pub const fn path(key: &'static str, label: &'static str, save: bool) -> Field {
|
||||
field(key, label, Kind::Path { save })
|
||||
}
|
||||
pub const fn flag(key: &'static str, label: &'static str) -> Field {
|
||||
field(key, label, Kind::Bool)
|
||||
}
|
||||
pub const fn choice(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field {
|
||||
field(key, label, Kind::Choice(options))
|
||||
}
|
||||
pub const fn xyz(key: &'static str, label: &'static str, dim: Dim) -> Field {
|
||||
field(key, label, Kind::Vector { dim, labels: &["x", "y", "z"], nullable: false })
|
||||
}
|
||||
/// A vector whose entries may be null (left free).
|
||||
pub const fn free_vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field {
|
||||
field(key, label, Kind::Vector { dim, labels, nullable: true })
|
||||
}
|
||||
pub const fn vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field {
|
||||
field(key, label, Kind::Vector { dim, labels, nullable: false })
|
||||
}
|
||||
pub const fn reference(key: &'static str, label: &'static str, c: Collection) -> Field {
|
||||
field(key, label, Kind::Ref(c))
|
||||
}
|
||||
pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field {
|
||||
field(key, label, Kind::RefList(c))
|
||||
}
|
||||
pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
|
||||
field(key, label, Kind::Group(fields))
|
||||
}
|
||||
pub const fn variants(key: &'static str, label: &'static str, variants: &'static [Variant]) -> Field {
|
||||
field(key, label, Kind::Variants { tag: "type", variants })
|
||||
}
|
||||
pub const fn either(key: &'static str, label: &'static str, options: &'static [Field]) -> Field {
|
||||
field(key, label, Kind::Either(options))
|
||||
}
|
||||
pub const fn unit(key: &'static str, label: &'static str) -> Field {
|
||||
field(key, label, Kind::Unit)
|
||||
}
|
||||
pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
|
||||
field(key, label, Kind::Rows { fields, max: MAX_ROWS })
|
||||
}
|
||||
pub const fn json(key: &'static str, label: &'static str) -> Field {
|
||||
field(key, label, Kind::Json)
|
||||
}
|
||||
pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [Field]) -> Variant {
|
||||
Variant { tag, label, fields }
|
||||
}
|
||||
|
||||
/// The `name` of a new or edited definition.
|
||||
pub const NAME: Field = text("name", "Name").req();
|
||||
|
||||
impl Kind {
|
||||
/// The variant of `tag`, for [`Kind::Variants`].
|
||||
pub fn variant(&self, tag: &str) -> Option<&'static Variant> {
|
||||
match self {
|
||||
Kind::Variants { variants, .. } => variants.iter().find(|v| v.tag == tag),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Prefilling, switching variants, checking ----
|
||||
|
||||
/// `params` with the defaults of the fields it leaves out filled in (required groups created,
|
||||
/// the active variant's defaults too). What it already has is kept.
|
||||
pub fn prefill(fields: &[Field], params: &Value) -> Value {
|
||||
let mut obj = params.as_object().cloned().unwrap_or_default();
|
||||
fill(fields, &mut obj, 0);
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
/// Nesting deeper than this is not followed (schemas are shallow; this bounds the recursion).
|
||||
pub const MAX_DEPTH: usize = 8;
|
||||
|
||||
fn fill(fields: &[Field], obj: &mut Map<String, Value>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for f in fields {
|
||||
match f.kind {
|
||||
Kind::Variants { tag, .. } if f.key.is_empty() => {
|
||||
if !obj.contains_key(tag)
|
||||
&& let Some(d) = parse_default(f)
|
||||
{
|
||||
obj.insert(tag.into(), d);
|
||||
}
|
||||
if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
||||
fill(v.fields, obj, depth + 1);
|
||||
}
|
||||
}
|
||||
Kind::Group(_) | Kind::Variants { .. } => {
|
||||
if !obj.contains_key(f.key) {
|
||||
match parse_default(f) {
|
||||
Some(d) => {
|
||||
obj.insert(f.key.into(), d);
|
||||
}
|
||||
None if f.required => {
|
||||
obj.insert(f.key.into(), Value::Object(Map::new()));
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
if let Some(Value::Object(child)) = obj.get_mut(f.key) {
|
||||
fill_nested(f, child, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !obj.contains_key(f.key)
|
||||
&& let Some(d) = parse_default(f)
|
||||
{
|
||||
obj.insert(f.key.into(), d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill a group's or a (keyed) variant field's own object.
|
||||
fn fill_nested(f: &Field, child: &mut Map<String, Value>, depth: usize) {
|
||||
match f.kind {
|
||||
Kind::Group(fields) => fill(fields, child, depth),
|
||||
Kind::Variants { tag, variants } => {
|
||||
if !child.contains_key(tag)
|
||||
&& let Some(first) = variants.first()
|
||||
{
|
||||
child.insert(tag.into(), json!(first.tag));
|
||||
}
|
||||
if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
||||
fill(v.fields, child, depth);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_default(f: &Field) -> Option<Value> {
|
||||
f.default.and_then(|d| serde_json::from_str(d).ok())
|
||||
}
|
||||
|
||||
/// The object for variant `tag` of variants field `f`, from `old`: the tag, the values of `old`
|
||||
/// the new variant (or `common`, the fields beside the variants) also has, and the new variant's
|
||||
/// defaults.
|
||||
pub fn switch_variant(f: &Field, common: &[Field], old: &Map<String, Value>, tag: &str) -> Map<String, Value> {
|
||||
let Kind::Variants { tag: tag_key, .. } = f.kind else { return old.clone() };
|
||||
let Some(v) = f.kind.variant(tag) else { return old.clone() };
|
||||
let keep = |k: &str| v.fields.iter().chain(common).any(|g| g.key == k && g.key != tag_key);
|
||||
let mut out: Map<String, Value> = old.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
out.insert(tag_key.into(), json!(tag));
|
||||
fill(v.fields, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A problem with a field's value, for the form to show beside it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Problem {
|
||||
/// The JSON path of the field (`elastic/young`).
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// What is missing or out of range in `params`: required fields left out, numbers outside
|
||||
/// their range, values of the wrong kind. An empty list does not promise the command accepts
|
||||
/// them (it checks more); a problem means it would not.
|
||||
pub fn problems(fields: &[Field], params: &Value) -> Vec<Problem> {
|
||||
match params.as_object() {
|
||||
Some(obj) => object_problems(fields, obj),
|
||||
None => vec![Problem { path: String::new(), message: "the parameters must be a JSON object".into() }],
|
||||
}
|
||||
}
|
||||
|
||||
/// [`problems`] of an object's fields.
|
||||
pub fn object_problems(fields: &[Field], obj: &Map<String, Value>) -> Vec<Problem> {
|
||||
let mut out = Vec::new();
|
||||
check(fields, obj, "", &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// The path of field `key` inside `prefix` (`elastic/young`).
|
||||
pub fn join(prefix: &str, key: &str) -> String {
|
||||
if prefix.is_empty() {
|
||||
key.to_string()
|
||||
} else if key.is_empty() {
|
||||
prefix.to_string()
|
||||
} else {
|
||||
format!("{prefix}/{key}")
|
||||
}
|
||||
}
|
||||
|
||||
fn check(fields: &[Field], obj: &Map<String, Value>, prefix: &str, out: &mut Vec<Problem>, depth: usize) {
|
||||
if depth > MAX_DEPTH || out.len() > MAX_ROWS {
|
||||
return;
|
||||
}
|
||||
for f in fields {
|
||||
if let Kind::Variants { tag, .. } = f.kind
|
||||
&& f.key.is_empty()
|
||||
{
|
||||
match obj.get(tag).and_then(Value::as_str) {
|
||||
Some(t) => match f.kind.variant(t) {
|
||||
Some(v) => check(v.fields, obj, prefix, out, depth + 1),
|
||||
None => out.push(Problem { path: join(prefix, tag), message: format!("`{t}` is not a {}", f.label.to_lowercase()) }),
|
||||
},
|
||||
None if f.required => out.push(Problem { path: join(prefix, tag), message: "required".into() }),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let path = join(prefix, f.key);
|
||||
match obj.get(f.key) {
|
||||
None | Some(Value::Null) if f.required => out.push(Problem { path, message: "required".into() }),
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) => {
|
||||
if let Some(message) = value_problem(f, v, &path, out, depth) {
|
||||
out.push(Problem { path, message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is wrong with `v` as the value of `f`; nested problems go to `out`.
|
||||
fn value_problem(f: &Field, v: &Value, path: &str, out: &mut Vec<Problem>, depth: usize) -> Option<String> {
|
||||
match f.kind {
|
||||
Kind::Number { min, max, .. } => match (v.as_f64().filter(|x| x.is_finite()), min, max) {
|
||||
(None, ..) => Some("a number".into()),
|
||||
(Some(x), Some(lo), _) if x < lo => Some(format!("at least {lo}")),
|
||||
(Some(x), _, Some(hi)) if x > hi => Some(format!("at most {hi}")),
|
||||
_ => None,
|
||||
},
|
||||
Kind::Integer { min, max } => match v.as_i64() {
|
||||
Some(x) if (min..=max).contains(&x) => None,
|
||||
_ => Some(format!("a whole number in {min}..={max}")),
|
||||
},
|
||||
Kind::Text | Kind::Path { .. } | Kind::Ref(_) => match v.as_str() {
|
||||
Some(s) if f.required && s.trim().is_empty() => Some("required".into()),
|
||||
Some(_) => None,
|
||||
None => Some("text".into()),
|
||||
},
|
||||
Kind::Bool => (!v.is_boolean()).then(|| "true or false".into()),
|
||||
Kind::Choice(options) => match v.as_str() {
|
||||
Some(s) if options.iter().any(|(o, _)| *o == s) => None,
|
||||
_ => Some(format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", "))),
|
||||
},
|
||||
Kind::Vector { labels, nullable, .. } => match v.as_array() {
|
||||
Some(a) if a.len() == labels.len() && a.iter().all(|x| x.as_f64().is_some_and(f64::is_finite) || (nullable && x.is_null())) => None,
|
||||
_ => Some(format!("{} numbers", labels.len())),
|
||||
},
|
||||
Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then(|| "a list of names".into()),
|
||||
Kind::Group(fields) => match v.as_object() {
|
||||
Some(o) => {
|
||||
check(fields, o, path, out, depth + 1);
|
||||
None
|
||||
}
|
||||
None => Some("an object".into()),
|
||||
},
|
||||
Kind::Variants { tag, .. } => match v.as_object() {
|
||||
Some(o) => match o.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
|
||||
Some(var) => {
|
||||
check(var.fields, o, path, out, depth + 1);
|
||||
None
|
||||
}
|
||||
None => Some(format!("needs a known `{tag}`")),
|
||||
},
|
||||
None => Some("an object".into()),
|
||||
},
|
||||
Kind::Either(options) => {
|
||||
let ok = match v {
|
||||
Value::String(s) => options.iter().any(|o| o.key == s && matches!(o.kind, Kind::Unit)),
|
||||
Value::Object(o) if o.len() == 1 => o.iter().all(|(k, inner)| {
|
||||
options.iter().find(|opt| opt.key == k).is_some_and(|opt| value_problem(opt, inner, path, out, depth + 1).is_none())
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
(!ok).then(|| format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")))
|
||||
}
|
||||
Kind::Unit | Kind::Json => None,
|
||||
Kind::Rows { fields, max } => match v.as_array() {
|
||||
Some(a) if a.len() <= max => {
|
||||
for (i, row) in a.iter().enumerate() {
|
||||
match row.as_object() {
|
||||
Some(o) => check(fields, o, &format!("{path}/{i}"), out, depth + 1),
|
||||
None => out.push(Problem { path: format!("{path}/{i}"), message: "an object".into() }),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => Some(format!("a list of at most {max} rows")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JSON Schema ----
|
||||
|
||||
/// The fields as a JSON Schema (draft 2020-12) object schema, units and hints in descriptions.
|
||||
pub fn json_schema(fields: &[Field]) -> Value {
|
||||
object_schema(fields, 0)
|
||||
}
|
||||
|
||||
fn object_schema(fields: &[Field], depth: usize) -> Value {
|
||||
let mut properties = Map::new();
|
||||
let mut required = Vec::new();
|
||||
let mut one_of = None;
|
||||
for f in fields {
|
||||
if let Kind::Variants { tag, variants } = f.kind
|
||||
&& f.key.is_empty()
|
||||
{
|
||||
one_of = Some(variant_schemas(tag, variants, depth));
|
||||
if f.required {
|
||||
required.push(json!(tag));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
properties.insert(f.key.into(), field_schema(f, depth));
|
||||
if f.required {
|
||||
required.push(json!(f.key));
|
||||
}
|
||||
}
|
||||
let mut s = json!({"type": "object", "properties": properties});
|
||||
if !required.is_empty() {
|
||||
s["required"] = Value::Array(required);
|
||||
}
|
||||
if let Some(o) = one_of {
|
||||
s["oneOf"] = o;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn variant_schemas(tag: &str, variants: &[Variant], depth: usize) -> Value {
|
||||
Value::Array(
|
||||
variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut s = if depth > MAX_DEPTH { json!({"type": "object"}) } else { object_schema(v.fields, depth + 1) };
|
||||
s["properties"][tag] = json!({"const": v.tag, "title": v.label});
|
||||
let mut req = s.get("required").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
req.push(json!(tag));
|
||||
s["required"] = Value::Array(req);
|
||||
s
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn field_schema(f: &Field, depth: usize) -> Value {
|
||||
let mut s = match f.kind {
|
||||
Kind::Number { dim, min, max } => {
|
||||
let mut s = json!({"type": "number"});
|
||||
if let Some(lo) = min {
|
||||
s["minimum"] = json!(lo);
|
||||
}
|
||||
if let Some(hi) = max {
|
||||
s["maximum"] = json!(hi);
|
||||
}
|
||||
if dim != Dim::None {
|
||||
s["x-dimension"] = json!(format!("{dim:?}").to_lowercase());
|
||||
}
|
||||
s
|
||||
}
|
||||
Kind::Integer { min, max } => json!({"type": "integer", "minimum": min, "maximum": max}),
|
||||
Kind::Text | Kind::Path { .. } => json!({"type": "string"}),
|
||||
Kind::Bool => json!({"type": "boolean"}),
|
||||
Kind::Choice(options) => json!({"enum": options.iter().map(|(o, _)| *o).collect::<Vec<_>>()}),
|
||||
Kind::Vector { labels, nullable, .. } => {
|
||||
let item = if nullable { json!({"type": ["number", "null"]}) } else { json!({"type": "number"}) };
|
||||
json!({"type": "array", "items": item, "minItems": labels.len(), "maxItems": labels.len()})
|
||||
}
|
||||
Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}),
|
||||
Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}),
|
||||
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 {
|
||||
Kind::Unit => json!({"const": o.key}),
|
||||
_ => json!({"type": "object", "properties": {o.key: field_schema(o, depth + 1)}, "required": [o.key], "additionalProperties": false}),
|
||||
}).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}),
|
||||
_ => json!({}),
|
||||
};
|
||||
s["title"] = json!(f.label);
|
||||
if !f.hint.is_empty() {
|
||||
s["description"] = json!(f.hint);
|
||||
}
|
||||
if let Some(d) = parse_default(f) {
|
||||
s["default"] = d;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn units_follow_the_system() {
|
||||
assert_eq!(Dim::Stress.unit(UnitSystem::MmTonneS).as_deref(), Some("MPa"));
|
||||
assert_eq!(Dim::Density.unit(UnitSystem::MmTonneS).as_deref(), Some("t/mm³"));
|
||||
assert_eq!(Dim::Moment.unit(UnitSystem::MKgS).as_deref(), Some("N·m"));
|
||||
assert_eq!(Dim::Density.unit(UnitSystem::InLbfS).as_deref(), Some("lbf·s²/in⁴"));
|
||||
assert_eq!(Dim::Stress.unit(UnitSystem::Unspecified), None);
|
||||
assert_eq!(Dim::Millimetre.unit(UnitSystem::Unspecified).as_deref(), Some("mm"));
|
||||
assert_eq!(Dim::None.unit(UnitSystem::MKgS), None);
|
||||
}
|
||||
|
||||
const LOADISH: &[Field] = &[
|
||||
NAME,
|
||||
variants(
|
||||
"",
|
||||
"Type",
|
||||
&[
|
||||
variant(
|
||||
"force",
|
||||
"Force",
|
||||
&[xyz("vector", "Vector", Dim::Force).req(), choice("distribution", "Spread", &[("total", "Total")]).def("\"total\"")],
|
||||
),
|
||||
variant("pressure", "Pressure", &[num("magnitude", "Magnitude", Dim::Stress).req(), reference("set", "Set", Collection::Set).req()]),
|
||||
],
|
||||
)
|
||||
.req()
|
||||
.def("\"force\""),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn prefill_switch_and_check() {
|
||||
let p = prefill(LOADISH, &json!({}));
|
||||
assert_eq!(p, json!({"type": "force", "distribution": "total"}));
|
||||
let problems = problems(LOADISH, &p);
|
||||
let paths: Vec<&str> = problems.iter().map(|p| p.path.as_str()).collect();
|
||||
assert_eq!(paths, ["name", "vector"]);
|
||||
let root = &LOADISH[1];
|
||||
let switched = switch_variant(
|
||||
root,
|
||||
LOADISH,
|
||||
&json!({"name": "a", "type": "force", "vector": [1, 2, 3]}).as_object().cloned().unwrap_or_default(),
|
||||
"pressure",
|
||||
);
|
||||
assert_eq!(Value::Object(switched), json!({"name": "a", "type": "pressure"}));
|
||||
assert!(super::problems(LOADISH, &json!({"name": "a", "type": "force", "vector": [1, "x", 3]})).iter().any(|p| p.path == "vector"));
|
||||
assert!(super::problems(LOADISH, &json!([1])).len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_schema_has_variants_and_requirements() {
|
||||
let s = json_schema(LOADISH);
|
||||
assert_eq!(s["required"], json!(["name", "type"]));
|
||||
assert_eq!(s["oneOf"][1]["properties"]["type"]["const"], "pressure");
|
||||
assert_eq!(s["oneOf"][1]["required"], json!(["magnitude", "set", "type"]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
//! Penalty contact between element faces, with Coulomb friction.
|
||||
//!
|
||||
//! **Kinematics: segment to segment, both ways.** The normal contact pressure is integrated over
|
||||
//! the overlap of each pair of facing faces: a secondary face and a main face it faces are
|
||||
//! projected onto the secondary face's plane, each split into triangles, clipped against each
|
||||
//! other (Sutherland–Hodgman), and the overlap is integrated with a three-point rule per
|
||||
//! sub-triangle; at each point the gap g = (x_s − x_m) · n_m between the two faces (n_m the main
|
||||
//! face's outward normal) gives a penalty pressure p = ε (−g) where they overlap, which acts on
|
||||
//! the secondary face's nodes with weights N_i^s and on the main face's with −N_j^m. This is the
|
||||
//! segmentation and integration of mortar contact (M. A. Puso, T. A. Laursen, *A mortar
|
||||
//! segment-to-segment contact method for large deformation solid mechanics*, CMAME
|
||||
//! 193:601–629, 2004) with the penalty applied at the integration points (K. A. Fischer, P.
|
||||
//! Wriggers, *Frictionless 2D contact formulations for finite deformations based on the mortar
|
||||
//! method*, Comput. Mech. 36:226–244, 2005): for a uniform pressure across flat faces both sides
|
||||
//! receive exactly the consistent nodal forces ∫ p N dA, whatever the meshes, so the contact
|
||||
//! patch test is passed — node-to-segment contact fails it on non-matching meshes (R. L. Taylor,
|
||||
//! P. Papadopoulos, *On a patch test for contact problems in two dimensions*, 1991). Every
|
||||
//! contact runs both ways — a pair with each surface as the secondary in turn, single-surface
|
||||
//! (general and self) contact with every face against every other one that shares no node with
|
||||
//! it — at half the penalty each, so neither side is privileged and no corner of either surface
|
||||
//! sinks into the other. Two faces are in contact where they face each other (their normals more
|
||||
//! than [`FACING`] from parallel, which keeps the faces of one body that meet at an edge apart)
|
||||
//! and the gap is negative but no deeper than half the main face's element is thick (deeper, the
|
||||
//! surfaces have passed through each other). Faces of eroded elements leave the contact.
|
||||
//!
|
||||
//! **Search.** Every increment: each surface's faces go into a bucket grid with their boxes grown
|
||||
//! by their depth limit ([`fieldcraft_fem::contact::Grid`]: D. J. Benson, J. O. Hallquist, *A
|
||||
//! single surface contact algorithm for the post-buckling analysis of shell structures*, CMAME
|
||||
//! 78:141–163, 1990); each secondary face takes the main faces listed in the cells its box
|
||||
//! overlaps. The faces are integrated in parallel, each on its own, and their forces added face by
|
||||
//! face in a fixed order, so the result does not depend on how the faces are split among threads.
|
||||
//!
|
||||
//! **Penalty.** The penalty per area comes from the masses and the stable increment Δt:
|
||||
//! ε = s γ min_i (m_i / A_i) / Δt², with m_i the lumped mass of node i and A_i the area that
|
||||
//! bounds its contact stiffness, over the nodes of the contact. The stiffness entries of node i
|
||||
//! in direction d sum to at most 2 ε Σ_f a_f |n_f,d| Σ_d′ |n_f,d′| over its faces f (a_f its
|
||||
//! share of the face, a quarter of a quadrilateral or a third of a triangle, n_f the unit normal;
|
||||
//! its weights over a face sum to one on both sides), so A_i = κ_i max_d Σ_f a_f |n_f,d| with
|
||||
//! κ_i = max_f Σ_d |n_f,d| ∈ [1, √3]: the node's area on a flat surface in any orientation, and
|
||||
//! no more than the largest of its areas facing one way at an edge or a corner. By Gershgorin's
|
||||
//! theorem the contact then adds at most 2 s γ / Δt² to the square of any frequency (Weyl's
|
||||
//! inequality adds it to the elements'); with γ = [`GAMMA`] and the
|
||||
//! default increment scale (0.9 of the critical increment, ω Δt ≤ 1.8) the step stays under the
|
||||
//! stability limit ω Δt = 2 (1.8² + 2γ ≤ 4) and contact never shortens the increment. The penalty
|
||||
//! scale s ≤ 1 only softens it. The pressure stiffness this gives is about a fifth of the
|
||||
//! smallest element's M/h (M the constrained modulus): stiffer than the element-based penalty
|
||||
//! f K A²/V with its usual f = 0.1 (J. O. Hallquist, G. L. Goudreau, D. J. Benson, *Sliding
|
||||
//! interfaces with contact-impact in large-scale Lagrangian computations*, CMAME 51:107–137,
|
||||
//! 1985), and a uniform ε keeps a uniform pressure uniform.
|
||||
//!
|
||||
//! **Friction** at the secondary nodes by a regularised return map (P. Wriggers, *Computational
|
||||
//! Contact Mechanics*, 2nd ed., Springer 2006, §5.2 and §10.3; J. C. Simo, T. A. Laursen, *An
|
||||
//! augmented Lagrangian treatment of contact problems involving friction*, Comput. Struct.
|
||||
//! 42(1):97–116, 1992): a node's normal force f_n is its share of the pressure, its tangential
|
||||
//! force an elastic predictor, the previous force moved onto the current tangent plane less
|
||||
//! k_t times the tangential motion of the node relative to the closest point of the main face that
|
||||
//! pressed it most over the increment, t* = P t_n − k_t Δu_T with k_t the node's normal stiffness ε A_i / 2; if
|
||||
//! |t*| ≤ μ f_n the node sticks (t = t*), otherwise it slips and the force returns to the cone,
|
||||
//! t = μ f_n t*/|t*|, dissipating μ f_n (|t*| − μ f_n)/k_t. The reaction goes to the main face's
|
||||
//! nodes with −N_j. The coefficient μ = μ_k + (μ_s − μ_k) e^{−d γ̇} follows the slip rate
|
||||
//! γ̇ = |Δu_T|/Δt ([`fieldcraft_model::Friction`]). A node that leaves contact forgets its
|
||||
//! tangential force.
|
||||
//!
|
||||
//! **Energy.** The contact forces enter the equations of motion like external forces; their work
|
||||
//! (the penalty energy stored, plus what friction dissipates) is integrated with the trapezoidal
|
||||
//! rule like the internal forces' (see [`crate::integrate`]), and the friction dissipation is
|
||||
//! reported too.
|
||||
//!
|
||||
//! **Initial penetration.** Before the analysis each node of a secondary surface is projected
|
||||
//! onto the faces of the main surface ([`fieldcraft_fem::contact::closest_point`]); a node behind
|
||||
//! a face it lies over (within a fiftieth of the face's size), within the depth limit and with its
|
||||
//! own faces facing it, is moved onto it (strain-free: the reference coordinates change before the
|
||||
//! elements are built) or the analysis is refused with the node, the face and the depth, as the
|
||||
//! contact's `initial_penetration` says.
|
||||
//!
|
||||
//! **Erosion.** The faces of eroded elements leave the contact; the faces their removal exposes
|
||||
//! do not join it (eroding contact is later work).
|
||||
|
||||
use fieldcraft_fem::contact::{Aabb, Grid, Projection, bounds, closest_point};
|
||||
use fieldcraft_fem::frame::{cross, dot, norm, sub};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, ElementKind, Friction, InitialPenetration, Model, Resolved};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::elements::{State, add_at};
|
||||
use crate::system::NODE_DOFS;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The penalty's share of the stability budget: ε = s γ min(m/A) / Δt² (see the module docs).
|
||||
pub const GAMMA: f64 = 0.3;
|
||||
|
||||
/// A node lies over a face when its lateral offset is at most this fraction of the face's size
|
||||
/// (the initial-penetration check).
|
||||
pub const EDGE_TOLERANCE: f64 = 0.02;
|
||||
|
||||
/// A point deeper than this fraction of the thickness of a face's element has passed through it.
|
||||
pub const DEPTH_FRACTION: f64 = 0.5;
|
||||
|
||||
/// Facing faces: the dot product of their unit normals is below minus this.
|
||||
pub const FACING: f64 = 0.1;
|
||||
|
||||
/// Faces per parallel task.
|
||||
const BLOCK: usize = 64;
|
||||
|
||||
/// The three-point rule on a triangle, exact for quadratics: barycentric points (of the second
|
||||
/// and third corners), weights summing to one.
|
||||
const TRIANGLE_RULE: [([f64; 2], f64); 3] =
|
||||
[([1.0 / 6.0, 1.0 / 6.0], 1.0 / 3.0), ([2.0 / 3.0, 1.0 / 6.0], 1.0 / 3.0), ([1.0 / 6.0, 2.0 / 3.0], 1.0 / 3.0)];
|
||||
|
||||
type Vec3 = [f64; 3];
|
||||
|
||||
/// A face of a contact surface.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Face {
|
||||
/// The corner nodes (system indices), in the face's order; the last unused on a triangle.
|
||||
pub nodes: [usize; 4],
|
||||
pub count: usize,
|
||||
/// The element's label, its position in [`crate::system::System::elements`] and the face
|
||||
/// number.
|
||||
pub element: u32,
|
||||
pub index: usize,
|
||||
pub face: u8,
|
||||
/// The nodes of the face's element (a node of it is never in contact with the face).
|
||||
pub owner: Vec<usize>,
|
||||
/// The penetration beyond which a point has passed through: [`DEPTH_FRACTION`] of the
|
||||
/// element's thickness under the face.
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
fn corners(&self) -> &[usize] {
|
||||
self.nodes.get(..self.count).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces and nodes of one surface.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Surface {
|
||||
pub faces: Vec<Face>,
|
||||
/// Its nodes, sorted (system indices).
|
||||
pub nodes: Vec<usize>,
|
||||
/// Per node (in `nodes` order), the faces that hold it.
|
||||
node_faces: Vec<Vec<usize>>,
|
||||
/// Per node, the area of the surface that bounds its contact stiffness (undeformed; see
|
||||
/// [`Interface::forces`]).
|
||||
area: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A node's friction state in one pass.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct Slip {
|
||||
/// The tangential force on the node.
|
||||
force: Vec3,
|
||||
}
|
||||
|
||||
/// One pass: the faces of a surface (secondary) against the faces of a surface (main).
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pass {
|
||||
secondary: usize,
|
||||
main: usize,
|
||||
/// Per node of the secondary surface.
|
||||
slip: Vec<Slip>,
|
||||
}
|
||||
|
||||
/// A contact as the solver runs it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Interface {
|
||||
pub name: String,
|
||||
pub friction: Friction,
|
||||
pub scale: f64,
|
||||
pub surfaces: Vec<Surface>,
|
||||
passes: Vec<Pass>,
|
||||
/// Nodes moved onto the other surface before the analysis.
|
||||
pub adjusted: Vec<Adjustment>,
|
||||
/// min over the contact's nodes of mass / area, once the masses are known.
|
||||
mass_per_area: Option<f64>,
|
||||
}
|
||||
|
||||
/// A node moved onto a face before the analysis.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct Adjustment {
|
||||
pub node: u32,
|
||||
pub element: u32,
|
||||
pub face: u8,
|
||||
/// How far it started inside.
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
/// What one evaluation of the contact forces found, per contact.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Evaluation {
|
||||
/// The normal force across the contact (the integrated pressure, both ways).
|
||||
pub normal_force: f64,
|
||||
/// The deepest penetration.
|
||||
pub penetration: f64,
|
||||
/// Integration points in contact.
|
||||
pub active: usize,
|
||||
/// Friction dissipated over the increment that led here.
|
||||
pub dissipation: f64,
|
||||
}
|
||||
|
||||
fn invalid(m: impl Into<String>) -> Error {
|
||||
Error::Invalid(m.into())
|
||||
}
|
||||
|
||||
/// The volume of a solid element of corners `x` (hex8 or tet4), for the thickness under a face.
|
||||
fn volume(kind: ElementKind, x: &[Vec3]) -> Option<f64> {
|
||||
match kind {
|
||||
ElementKind::Hex8 => <[Vec3; 8]>::try_from(x).ok().and_then(|a| fieldcraft_fem::uniform::Hex8::hex8(&a).ok()).map(|k| k.volume),
|
||||
ElementKind::Tet4 => <[Vec3; 4]>::try_from(x).ok().and_then(|a| fieldcraft_fem::uniform::Tet4::tet4(&a).ok()).map(|k| k.volume),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's area vector (its outward normal times its area, flat or bilinear).
|
||||
fn area_vector(x: &[Vec3]) -> Vec3 {
|
||||
match x {
|
||||
[a, b, c] => cross(sub(*b, *a), sub(*c, *a)).map(|v| 0.5 * v),
|
||||
[a, b, c, d] => cross(sub(*c, *a), sub(*d, *b)).map(|v| 0.5 * v),
|
||||
_ => [0.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest side of a face's bounding box.
|
||||
fn size(x: &[Vec3]) -> f64 {
|
||||
let b = bounds(x, 0.0);
|
||||
(0..3).map(|k| b[1][k] - b[0][k]).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Where a node is: its rest position plus its displacement.
|
||||
fn position(xyz: &[Vec3], u: &[f64], node: usize) -> Vec3 {
|
||||
let x = xyz.get(node).copied().unwrap_or_default();
|
||||
[0, 1, 2].map(|k| x[k] + u.get(NODE_DOFS * node + k).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// A node's displacement increment.
|
||||
fn shift(du: &[f64], node: usize) -> Vec3 {
|
||||
[0, 1, 2].map(|k| du.get(NODE_DOFS * node + k).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// The surface of element faces `faces`, its nodes, their faces and areas. `node` and
|
||||
/// `element` give a label's index among the analysed nodes and elements.
|
||||
fn new(
|
||||
model: &Model,
|
||||
faces: &[ElementFace],
|
||||
xyz: &[Vec3],
|
||||
node: &impl Fn(u32) -> Option<usize>,
|
||||
element: &impl Fn(u32) -> Option<usize>,
|
||||
) -> Result<Surface> {
|
||||
let mut out = Surface::default();
|
||||
for f in faces {
|
||||
let at = |what: &str| invalid(format!("element {} face {}: {what}", f.element, f.face));
|
||||
let el = model.element(f.element).ok_or_else(|| at("the element is missing"))?;
|
||||
if !matches!(el.kind, ElementKind::Hex8 | ElementKind::Tet4) {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"contact on the faces of {} elements (element {}); explicit contact acts on hex8 and tet4 faces",
|
||||
el.kind.name(),
|
||||
f.element
|
||||
)));
|
||||
}
|
||||
let labels = model.face_nodes(*f).ok_or_else(|| at("no such face"))?;
|
||||
let corners: Vec<usize> = labels.iter().map(|l| node(*l).ok_or_else(|| at("a node is not analysed"))).collect::<Result<_>>()?;
|
||||
let owner: Vec<usize> = el.nodes.iter().filter_map(|l| node(*l)).collect();
|
||||
let index = element(f.element).ok_or_else(|| at("the element is not analysed"))?;
|
||||
let x: Vec<Vec3> = corners.iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let ex: Vec<Vec3> = owner.iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let area = norm(area_vector(&x));
|
||||
// Thickness V/A; a face of no area or an element of no volume falls back on its size.
|
||||
let thickness = volume(el.kind, &ex).filter(|v| *v > 0.0 && area > 0.0).map_or(size(&x), |v| v / area);
|
||||
let mut nodes = [usize::MAX; 4];
|
||||
for (slot, n) in nodes.iter_mut().zip(&corners) {
|
||||
*slot = *n;
|
||||
}
|
||||
out.faces.push(Face { nodes, count: corners.len(), element: f.element, index, face: f.face, owner, depth: DEPTH_FRACTION * thickness });
|
||||
}
|
||||
out.nodes = out.faces.iter().flat_map(|f| f.corners().iter().copied()).collect();
|
||||
out.nodes.sort_unstable();
|
||||
out.nodes.dedup();
|
||||
out.node_faces = vec![Vec::new(); out.nodes.len()];
|
||||
// Per node and direction, Σ_f A_f |n_f,d| / count, and the largest Σ_d |n_d| of its faces.
|
||||
let mut directional = vec![[0.0; 3]; out.nodes.len()];
|
||||
let mut spread = vec![1.0f64; out.nodes.len()];
|
||||
for (i, f) in out.faces.iter().enumerate() {
|
||||
let x: Vec<Vec3> = f.corners().iter().map(|n| xyz.get(*n).copied().unwrap_or_default()).collect();
|
||||
let a = area_vector(&x);
|
||||
let l = norm(a);
|
||||
let share = a.map(|c| c.abs() / f.count as f64);
|
||||
let kappa = if l > 0.0 { a.iter().map(|c| c.abs()).sum::<f64>() / l } else { 1.0 };
|
||||
for n in f.corners() {
|
||||
if let Ok(k) = out.nodes.binary_search(n) {
|
||||
if let Some(list) = out.node_faces.get_mut(k) {
|
||||
list.push(i);
|
||||
}
|
||||
if let (Some(d), Some(s)) = (directional.get_mut(k), spread.get_mut(k)) {
|
||||
for c in 0..3 {
|
||||
d[c] += share[c];
|
||||
}
|
||||
*s = s.max(kappa);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.area = directional.iter().zip(&spread).map(|(d, s)| s * d.iter().copied().fold(0.0, f64::max)).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The faces' corner positions at displacements `u`.
|
||||
fn positions(&self, xyz: &[Vec3], u: &[f64]) -> Vec<Vec<Vec3>> {
|
||||
self.faces.iter().map(|f| f.corners().iter().map(|n| position(xyz, u, *n)).collect()).collect()
|
||||
}
|
||||
|
||||
/// The nodes' outward normals (the sum of their faces' area vectors; not normalised).
|
||||
fn node_normals(&self, faces: &[Vec<Vec3>]) -> Vec<Vec3> {
|
||||
self.node_faces
|
||||
.iter()
|
||||
.map(|list| {
|
||||
let mut n = [0.0; 3];
|
||||
for a in list.iter().filter_map(|i| faces.get(*i)).map(|x| area_vector(x)) {
|
||||
for k in 0..3 {
|
||||
n[k] += a[k];
|
||||
}
|
||||
}
|
||||
n
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces' grid for the global search at positions `positions`.
|
||||
fn grid(surface: &Surface, positions: &[Vec<Vec3>]) -> Grid {
|
||||
let boxes: Vec<Aabb> = surface.faces.iter().zip(positions).map(|(f, x)| bounds(x, f.depth)).collect();
|
||||
Grid::new(&boxes, 4 * boxes.len() + 64)
|
||||
}
|
||||
|
||||
/// The face a node at `x` (index `node`, outward normal `normal`) starts inside, among the grid's
|
||||
/// candidates: over it, behind it within its depth limit, facing it; the nearest.
|
||||
fn inside(surface: &Surface, positions: &[Vec<Vec3>], grid: &Grid, node: usize, x: Vec3, normal: Vec3) -> Option<(usize, Projection)> {
|
||||
let mut best: Option<(usize, Projection)> = None;
|
||||
for &c in grid.candidates(x) {
|
||||
let c = c as usize;
|
||||
let (Some(face), Some(fx)) = (surface.faces.get(c), positions.get(c)) else { continue };
|
||||
if face.owner.contains(&node) {
|
||||
continue;
|
||||
}
|
||||
let Some(q) = closest_point(fx, x) else { continue };
|
||||
let over = q.lateral <= EDGE_TOLERANCE * size(fx);
|
||||
let n = norm(normal);
|
||||
let facing = n == 0.0 || dot(normal, q.normal) < -FACING * n;
|
||||
if q.gap < 0.0 && -q.gap <= face.depth && over && facing && best.is_none_or(|b| q.distance < b.1.distance) {
|
||||
best = Some((c, q));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// A point of a plane in its own 2D coordinates.
|
||||
type Point = [f64; 2];
|
||||
|
||||
fn cross2(o: Point, a: Point, b: Point) -> f64 {
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
}
|
||||
|
||||
/// The signed area of a polygon (counter-clockwise positive).
|
||||
fn signed_area(p: &[Point]) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for (i, a) in p.iter().enumerate() {
|
||||
let b = p.get((i + 1) % p.len()).copied().unwrap_or(*a);
|
||||
s += a[0] * b[1] - a[1] * b[0];
|
||||
}
|
||||
0.5 * s
|
||||
}
|
||||
|
||||
/// `subject` clipped by the convex counter-clockwise polygon `clip` (Sutherland–Hodgman).
|
||||
fn clip(subject: &[Point], clip: &[Point]) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = subject.to_vec();
|
||||
for (i, a) in clip.iter().enumerate() {
|
||||
let Some(b) = clip.get((i + 1) % clip.len()).copied() else { break };
|
||||
let input = std::mem::take(&mut out);
|
||||
let side = |p: Point| cross2(*a, b, p);
|
||||
for (j, p) in input.iter().enumerate() {
|
||||
let Some(q) = input.get((j + 1) % input.len()).copied() else { break };
|
||||
let (sp, sq) = (side(*p), side(q));
|
||||
if sp >= 0.0 {
|
||||
out.push(*p);
|
||||
}
|
||||
if (sp >= 0.0) != (sq >= 0.0) {
|
||||
let t = sp / (sp - sq);
|
||||
out.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The corners `t` of `points`, counter-clockwise.
|
||||
fn counter_clockwise(t: &[usize; 3], points: &[Point]) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = t.iter().filter_map(|i| points.get(*i).copied()).collect();
|
||||
if signed_area(&out) < 0.0 {
|
||||
out.reverse();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The triangles a face splits into (corner positions in its order).
|
||||
fn triangles(count: usize) -> &'static [[usize; 3]] {
|
||||
if count == 3 { &[[0, 1, 2]] } else { &[[0, 1, 2], [0, 2, 3]] }
|
||||
}
|
||||
|
||||
/// What the integration needs of each main face, worked out once per increment.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct FaceInfo {
|
||||
/// Area vector and its length.
|
||||
area: Vec3,
|
||||
length: f64,
|
||||
/// The lateral offset beyond which a point is past the face's edge.
|
||||
edge: f64,
|
||||
alive: bool,
|
||||
}
|
||||
|
||||
fn face_info(surface: &Surface, positions: &[Vec<Vec3>], alive: &dyn Fn(&Face) -> bool) -> Vec<FaceInfo> {
|
||||
surface
|
||||
.faces
|
||||
.iter()
|
||||
.zip(positions)
|
||||
.map(|(f, x)| {
|
||||
let area = area_vector(x);
|
||||
FaceInfo { area, length: norm(area), edge: EDGE_TOLERANCE * size(x), alive: alive(f) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One secondary face against the main surface: its contributions to the nodal forces and to
|
||||
/// each secondary node's normal force.
|
||||
#[derive(Debug, Default)]
|
||||
struct FaceForces {
|
||||
/// (node, force) contributions, in order.
|
||||
forces: Vec<(usize, Vec3)>,
|
||||
/// (secondary node, normal force share, main face) of each integration point.
|
||||
normal: Vec<(usize, f64, usize)>,
|
||||
penetration: f64,
|
||||
points: usize,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
/// The penalty pressure integrated over the overlaps of secondary face `f` (corners `fx`) with
|
||||
/// the main faces facing it, at penalty `eps` per area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn integrate_face(
|
||||
f: &Face,
|
||||
fx: &[Vec3],
|
||||
alive: bool,
|
||||
main: &Surface,
|
||||
mx: &[Vec<Vec3>],
|
||||
info: &[FaceInfo],
|
||||
grid: &Grid,
|
||||
eps: f64,
|
||||
found: &mut Vec<u32>,
|
||||
) -> FaceForces {
|
||||
let mut out = FaceForces::default();
|
||||
if !alive {
|
||||
return out;
|
||||
}
|
||||
let area = area_vector(fx);
|
||||
let a = norm(area);
|
||||
if !(a > 0.0 && a.is_finite()) {
|
||||
return out;
|
||||
}
|
||||
let normal = area.map(|c| c / a);
|
||||
let n = fx.len() as f64;
|
||||
let center: Vec3 = [0, 1, 2].map(|k| fx.iter().map(|p| p[k]).sum::<f64>() / n);
|
||||
let Some(first) = fx.first().copied() else { return out };
|
||||
// A basis of the face's plane, (e1, e2, normal) right-handed.
|
||||
let r = sub(first, center);
|
||||
let r = sub(r, normal.map(|c| c * dot(r, normal)));
|
||||
let rl = norm(r);
|
||||
if !(rl > 0.0) {
|
||||
return out;
|
||||
}
|
||||
let e1 = r.map(|c| c / rl);
|
||||
let e2 = cross(normal, e1);
|
||||
let flat = |x: Vec3| -> Point {
|
||||
let d = sub(x, center);
|
||||
[dot(d, e1), dot(d, e2)]
|
||||
};
|
||||
let lift = |p: Point| -> Vec3 { [0, 1, 2].map(|k| center[k] + p[0] * e1[k] + p[1] * e2[k]) };
|
||||
let own: Vec<Point> = fx.iter().map(|x| flat(*x)).collect();
|
||||
let own_triangles: Vec<Vec<Point>> = triangles(f.count).iter().map(|t| counter_clockwise(t, &own)).collect();
|
||||
grid.candidates_in(&bounds(fx, 0.0), found);
|
||||
for &c in found.iter() {
|
||||
let c = c as usize;
|
||||
let (Some(m), Some(x), Some(mi)) = (main.faces.get(c), mx.get(c), info.get(c)) else { continue };
|
||||
if !mi.alive || m.corners().iter().any(|k| f.corners().contains(k)) {
|
||||
continue;
|
||||
}
|
||||
if !(mi.length > 0.0) || dot(mi.area, normal) >= -FACING * mi.length {
|
||||
continue;
|
||||
}
|
||||
let theirs: Vec<Point> = x.iter().map(|p| flat(*p)).collect();
|
||||
for s in &own_triangles {
|
||||
for tm in triangles(m.count) {
|
||||
let t = counter_clockwise(tm, &theirs);
|
||||
let overlap = clip(&t, s);
|
||||
if overlap.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let Some(o) = overlap.first().copied() else { continue };
|
||||
for w in overlap.windows(2).skip(1) {
|
||||
let (p, q) = (w[0], w[1]);
|
||||
let tri_area = 0.5 * cross2(o, p, q);
|
||||
if !(tri_area > 0.0) {
|
||||
continue;
|
||||
}
|
||||
for (bary, weight) in TRIANGLE_RULE {
|
||||
let y = [0, 1].map(|k| o[k] + bary[0] * (p[k] - o[k]) + bary[1] * (q[k] - o[k]));
|
||||
let point = lift(y);
|
||||
let (Some(qs), Some(qm)) = (closest_point(fx, point), closest_point(x, point)) else { continue };
|
||||
let gap = dot(sub(qs.point, qm.point), qm.normal);
|
||||
// Over the main face (its foot not clamped to an edge), behind it, within
|
||||
// its depth.
|
||||
if !(gap < 0.0 && -gap <= m.depth && qm.lateral <= mi.edge) {
|
||||
continue;
|
||||
}
|
||||
let push = eps * (-gap) * weight * tri_area;
|
||||
if !push.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let force = qm.normal.map(|k| push * k);
|
||||
for (node, w) in f.corners().iter().zip(qs.weights) {
|
||||
out.forces.push((*node, force.map(|k| w * k)));
|
||||
out.normal.push((*node, w * push, c));
|
||||
}
|
||||
for (node, w) in m.corners().iter().zip(qm.weights) {
|
||||
out.forces.push((*node, force.map(|k| -w * k)));
|
||||
}
|
||||
out.penetration = out.penetration.max(-gap);
|
||||
out.points += 1;
|
||||
out.total += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
/// The contact `name` of the model on the analysed nodes and elements: its surfaces, and the
|
||||
/// initial penetrations adjusted in `xyz` (or refused).
|
||||
pub fn new(
|
||||
model: &Model,
|
||||
resolved: &Resolved,
|
||||
name: &str,
|
||||
contact: &Contact,
|
||||
xyz: &mut [Vec3],
|
||||
labels: &[u32],
|
||||
elements: &[u32],
|
||||
) -> Result<Interface> {
|
||||
let context = |e: Error| match e {
|
||||
Error::Invalid(m) => invalid(format!("contact `{name}`: {m}")),
|
||||
other => other,
|
||||
};
|
||||
let node = |l: u32| labels.binary_search(&l).ok();
|
||||
let element = |l: u32| elements.binary_search(&l).ok();
|
||||
let faces_of = |set: &str| resolved.sets.get(set).map(|s| s.faces.clone()).unwrap_or_default();
|
||||
let (surfaces, passes) = match &contact.surfaces {
|
||||
ContactSurfaces::Pair { main, secondary } => {
|
||||
let main = Surface::new(model, &faces_of(main), xyz, &node, &element).map_err(context)?;
|
||||
let secondary = Surface::new(model, &faces_of(secondary), xyz, &node, &element).map_err(context)?;
|
||||
(vec![main, secondary], vec![(1, 0), (0, 1)])
|
||||
}
|
||||
ContactSurfaces::General { surface } => {
|
||||
let faces = match surface {
|
||||
Some(s) => faces_of(s),
|
||||
None => model.exterior_faces(),
|
||||
};
|
||||
(vec![Surface::new(model, &faces, xyz, &node, &element).map_err(context)?], vec![(0, 0)])
|
||||
}
|
||||
};
|
||||
let passes = passes
|
||||
.into_iter()
|
||||
.map(|(secondary, main)| Pass { secondary, main, slip: vec![Slip::default(); surfaces.get(secondary).map_or(0, |s| s.nodes.len())] })
|
||||
.collect();
|
||||
let mut interface = Interface {
|
||||
name: name.to_string(),
|
||||
friction: contact.friction,
|
||||
scale: contact.penalty_scale,
|
||||
surfaces,
|
||||
passes,
|
||||
adjusted: Vec::new(),
|
||||
mass_per_area: None,
|
||||
};
|
||||
interface.adjust(xyz, labels, contact.initial_penetration)?;
|
||||
Ok(interface)
|
||||
}
|
||||
|
||||
/// Move the nodes that start inside a face onto it, or refuse them.
|
||||
fn adjust(&mut self, xyz: &mut [Vec3], labels: &[u32], mode: InitialPenetration) -> Result<()> {
|
||||
let zero: Vec<f64> = Vec::new();
|
||||
for pass in &self.passes {
|
||||
let (Some(nodes), Some(faces)) = (self.surfaces.get(pass.secondary), self.surfaces.get(pass.main)) else { continue };
|
||||
let positions = faces.positions(xyz, &zero);
|
||||
let grid = grid(faces, &positions);
|
||||
let own = nodes.positions(xyz, &zero);
|
||||
let normals = nodes.node_normals(&own);
|
||||
// Each move is decided on the positions before any move of this pass.
|
||||
let mut moves: Vec<(usize, Vec3, Adjustment)> = Vec::new();
|
||||
for (i, &n) in nodes.nodes.iter().enumerate() {
|
||||
let x = xyz.get(n).copied().unwrap_or_default();
|
||||
let normal = normals.get(i).copied().unwrap_or_default();
|
||||
let Some((c, q)) = inside(faces, &positions, &grid, n, x, normal) else { continue };
|
||||
let Some(face) = faces.faces.get(c) else { continue };
|
||||
// Coordinates are exact to a few units in the last place: a node a hair behind a
|
||||
// face it was meshed on is touching, not inside.
|
||||
let scale = x.iter().fold(1.0f64, |m, c| m.max(c.abs()));
|
||||
if -q.gap <= 1e-9 * scale {
|
||||
continue;
|
||||
}
|
||||
let label = labels.get(n).copied().unwrap_or(0);
|
||||
let adjustment = Adjustment { node: label, element: face.element, face: face.face, depth: -q.gap };
|
||||
if mode == InitialPenetration::Refuse {
|
||||
return Err(invalid(format!(
|
||||
"contact `{}`: node {label} starts {:.4e} inside face {} of element {} (move it out, or let the contact adjust it: initial_penetration \"adjust\")",
|
||||
self.name, -q.gap, face.face, face.element
|
||||
)));
|
||||
}
|
||||
moves.push((n, q.point, adjustment));
|
||||
}
|
||||
for (n, point, a) in moves {
|
||||
if let Some(x) = xyz.get_mut(n) {
|
||||
*x = point;
|
||||
}
|
||||
self.adjusted.push(a);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of faces.
|
||||
pub fn faces(&self) -> usize {
|
||||
self.surfaces.iter().map(|s| s.faces.len()).sum()
|
||||
}
|
||||
|
||||
/// Add the contact forces at displacements `u` into `force` (per degree of freedom), with
|
||||
/// `du` the displacement increment that led to `u` over `dt_prev`, the stable increment
|
||||
/// `dt` and the lumped masses per degree of freedom `mass`; update the friction state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn forces(
|
||||
&mut self,
|
||||
xyz: &[Vec3],
|
||||
u: &[f64],
|
||||
du: &[f64],
|
||||
dt_prev: f64,
|
||||
dt: f64,
|
||||
mass: &[f64],
|
||||
state: &State,
|
||||
force: &mut [f64],
|
||||
) -> Evaluation {
|
||||
let mut e = Evaluation::default();
|
||||
let node_mass = |n: usize| (0..3).map(|k| mass.get(NODE_DOFS * n + k).copied().unwrap_or(0.0)).fold(f64::INFINITY, f64::min);
|
||||
let mass_per_area = *self.mass_per_area.get_or_insert_with(|| {
|
||||
self.surfaces
|
||||
.iter()
|
||||
.flat_map(|s| s.nodes.iter().zip(&s.area))
|
||||
.filter(|(_, a)| **a > 0.0)
|
||||
.map(|(n, a)| node_mass(*n) / a)
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
});
|
||||
// Every contact runs both ways, at half the penalty each.
|
||||
let eps = 0.5 * self.scale * GAMMA * mass_per_area / (dt * dt);
|
||||
if !(eps > 0.0 && eps.is_finite()) {
|
||||
for pass in &mut self.passes {
|
||||
pass.slip.fill(Slip::default());
|
||||
}
|
||||
return e;
|
||||
}
|
||||
let alive = |f: &Face| state.elements.get(f.index).is_none_or(|s| s.failed.is_none());
|
||||
let positions: Vec<Vec<Vec<Vec3>>> = self.surfaces.iter().map(|s| s.positions(xyz, u)).collect();
|
||||
let grids: Vec<Grid> = self.surfaces.iter().zip(&positions).map(|(s, p)| grid(s, p)).collect();
|
||||
let friction = self.friction;
|
||||
for pass in &mut self.passes {
|
||||
let (Some(secondary), Some(main), Some(sx), Some(mx), Some(grid)) = (
|
||||
self.surfaces.get(pass.secondary),
|
||||
self.surfaces.get(pass.main),
|
||||
positions.get(pass.secondary),
|
||||
positions.get(pass.main),
|
||||
grids.get(pass.main),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let info = face_info(main, mx, &alive);
|
||||
let blocks = fieldcraft_parallel::map_grained(secondary.faces.len().div_ceil(BLOCK), 1, |b| {
|
||||
let mut found = Vec::new();
|
||||
(b * BLOCK..((b + 1) * BLOCK).min(secondary.faces.len()))
|
||||
.map(|i| match (secondary.faces.get(i), sx.get(i)) {
|
||||
(Some(f), Some(fx)) => integrate_face(f, fx, alive(f), main, mx, &info, grid, eps, &mut found),
|
||||
_ => FaceForces::default(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
// Per secondary node: its normal force, and the main face that gave it the most.
|
||||
let mut normal = vec![0.0; secondary.nodes.len()];
|
||||
let mut partner: Vec<Option<(usize, f64)>> = vec![None; secondary.nodes.len()];
|
||||
for face in blocks.iter().flatten() {
|
||||
for (node, f) in &face.forces {
|
||||
add_at(force, *node, f);
|
||||
}
|
||||
for (node, share, c) in &face.normal {
|
||||
if let Ok(k) = secondary.nodes.binary_search(node)
|
||||
&& let (Some(slot), Some(best)) = (normal.get_mut(k), partner.get_mut(k))
|
||||
{
|
||||
*slot += share;
|
||||
if best.is_none_or(|(_, s)| *share > s) {
|
||||
*best = Some((*c, *share));
|
||||
}
|
||||
}
|
||||
}
|
||||
e.normal_force += face.total;
|
||||
e.penetration = e.penetration.max(face.penetration);
|
||||
e.active += face.points;
|
||||
}
|
||||
if friction.is_frictionless() {
|
||||
continue;
|
||||
}
|
||||
// Friction at the secondary nodes, in node order, against the point under the node
|
||||
// of the main face that pressed it most.
|
||||
for (k, (&n, slip)) in secondary.nodes.iter().zip(pass.slip.iter_mut()).enumerate() {
|
||||
let fn_ = normal.get(k).copied().unwrap_or(0.0);
|
||||
let kt = eps * secondary.area.get(k).copied().unwrap_or(0.0);
|
||||
let hit = partner
|
||||
.get(k)
|
||||
.copied()
|
||||
.flatten()
|
||||
.filter(|_| fn_ > 0.0 && kt > 0.0)
|
||||
.and_then(|(c, _)| mx.get(c).and_then(|x| closest_point(x, position(xyz, u, n))).map(|q| (c, q)));
|
||||
let Some((c, q)) = hit else {
|
||||
*slip = Slip::default();
|
||||
continue;
|
||||
};
|
||||
let Some(face) = main.faces.get(c) else { continue };
|
||||
let mut rel = shift(du, n);
|
||||
for (m, w) in face.corners().iter().zip(q.weights) {
|
||||
let d = shift(du, *m);
|
||||
for c in 0..3 {
|
||||
rel[c] -= w * d[c];
|
||||
}
|
||||
}
|
||||
let normal = q.normal;
|
||||
let rn = dot(rel, normal);
|
||||
let slide = [0, 1, 2].map(|c| rel[c] - rn * normal[c]);
|
||||
let old = slip.force;
|
||||
let on = dot(old, normal);
|
||||
let trial: Vec3 = std::array::from_fn(|c| old[c] - on * normal[c] - kt * slide[c]);
|
||||
let size = norm(trial);
|
||||
let rate = if dt_prev > 0.0 { norm(slide) / dt_prev } else { 0.0 };
|
||||
let limit = friction.coefficient(rate) * fn_;
|
||||
let tangential = if size <= limit {
|
||||
trial
|
||||
} else {
|
||||
e.dissipation += limit * (size - limit) / kt;
|
||||
trial.map(|c| c * limit / size)
|
||||
};
|
||||
slip.force = tangential;
|
||||
add_at(force, n, &tangential);
|
||||
for (m, w) in face.corners().iter().zip(q.weights) {
|
||||
add_at(force, *m, &tangential.map(|c| -w * c));
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
/// Every contact of the model, built on the analysed nodes and elements (with the initial
|
||||
/// adjustments made in `xyz`), and no more than `max_faces` faces in all.
|
||||
pub fn interfaces(
|
||||
model: &Model,
|
||||
resolved: &Resolved,
|
||||
xyz: &mut [Vec3],
|
||||
labels: &[u32],
|
||||
elements: &[u32],
|
||||
max_faces: usize,
|
||||
) -> Result<Vec<Interface>> {
|
||||
let mut out = Vec::with_capacity(model.contacts().len());
|
||||
let mut faces = 0usize;
|
||||
for (name, c) in model.contacts() {
|
||||
let i = Interface::new(model, resolved, name, c, xyz, labels, elements)?;
|
||||
faces = faces.saturating_add(i.faces());
|
||||
if faces > max_faces {
|
||||
return Err(Error::Limit(format!(
|
||||
"the contacts hold more than {max_faces} faces (contact `{name}` passes the cap); name smaller surfaces"
|
||||
)));
|
||||
}
|
||||
out.push(i);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipping_two_triangles_gives_their_overlap() {
|
||||
let a = [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0]];
|
||||
let b = [[1.0, -1.0], [1.0, 3.0], [-1.0, 1.0]];
|
||||
// b clockwise: reversed it is counter-clockwise.
|
||||
let mut b2 = b.to_vec();
|
||||
if signed_area(&b2) < 0.0 {
|
||||
b2.reverse();
|
||||
}
|
||||
let o = clip(&a, &b2);
|
||||
// The overlap: the part of a with x ≤ 1 and y ≤ x + 2… here the triangle's left half,
|
||||
// area 2 − ½ = 1.5.
|
||||
assert!((signed_area(&o) - 1.5).abs() < 1e-12, "{o:?} {}", signed_area(&o));
|
||||
assert!(clip(&a, &[[5.0, 5.0], [6.0, 5.0], [5.0, 6.0]]).is_empty());
|
||||
let same = clip(&a, &a);
|
||||
assert!((signed_area(&same) - 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
//! reaches its material's erosion plastic strain is removed: it carries no stress from then on,
|
||||
//! its nodes keep their mass and momentum, and its internal energy at that moment is reported.
|
||||
|
||||
use fieldcraft_fem::constitutive::MaterialModel;
|
||||
use fieldcraft_fem::material::stress;
|
||||
use fieldcraft_fem::plasticity::{Increment, PointState};
|
||||
use fieldcraft_fem::rotation::{self, IDENTITY, Mat3, Polar};
|
||||
@@ -70,10 +71,16 @@ pub struct Workspace {
|
||||
pub stress_force: Vec<f64>,
|
||||
pub hourglass_force: Vec<f64>,
|
||||
pub viscous_force: Vec<f64>,
|
||||
/// Contact forces on the model per degree of freedom (empty without contact).
|
||||
pub contact_force: Vec<f64>,
|
||||
/// Per node: the volume-weighted volumetric strain increments of its tetrahedra, and their
|
||||
/// volume.
|
||||
theta: Vec<f64>,
|
||||
tet_volume: Vec<f64>,
|
||||
/// Per node, for hyperelastic tetrahedra: the current and the rest volume of its
|
||||
/// tetrahedra, then their ratio.
|
||||
tet_ratio: Vec<f64>,
|
||||
tet_rest: Vec<f64>,
|
||||
/// Per tetrahedron (in [`System::tets`] order): its increment between the two passes.
|
||||
tet_increment: Vec<Option<TetIncrement>>,
|
||||
}
|
||||
@@ -82,12 +89,16 @@ impl Workspace {
|
||||
pub fn new(sys: &System) -> Workspace {
|
||||
let n = sys.labels.len();
|
||||
let dofs = sys.dofs();
|
||||
let hyper_tets = sys.tets.iter().any(|t| sys.materials.get(t.material).is_some_and(|m| m.hyperelastic.is_some()));
|
||||
Workspace {
|
||||
stress_force: vec![0.0; dofs],
|
||||
hourglass_force: vec![0.0; dofs],
|
||||
viscous_force: vec![0.0; dofs],
|
||||
contact_force: if sys.contacts.is_empty() { Vec::new() } else { vec![0.0; dofs] },
|
||||
theta: vec![0.0; n],
|
||||
tet_volume: vec![0.0; n],
|
||||
tet_ratio: if hyper_tets { vec![1.0; n] } else { Vec::new() },
|
||||
tet_rest: if hyper_tets { vec![0.0; n] } else { Vec::new() },
|
||||
tet_increment: vec![None; sys.tets.len()],
|
||||
}
|
||||
}
|
||||
@@ -311,6 +322,52 @@ fn update(mat: &Material, es: &mut ElementState, q: &Mat3, d: &[f64; 6], vol: f6
|
||||
}
|
||||
}
|
||||
|
||||
/// The hyperelastic update of an element at deformation gradient `f` (the hexahedron's mean,
|
||||
/// or the tetrahedron's with its averaged volume ratio): the material point's Kirchhoff stress τ
|
||||
/// gives the stress of the nodal forces τ/`j` (`j` the element's own volume ratio, so the forces
|
||||
/// are ∫ τ ∇ₓN dV₀), the logarithmic strain is kept for output, and the stress work over the
|
||||
/// increment `d` (on the midpoint volume `volume`) is added to the element's energy. Returns the
|
||||
/// ratio of the tangent's largest normal stiffness (with the stress) to the material's
|
||||
/// small-strain one, at least 1, which scales the element's frequency bound.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hyperelastic(
|
||||
model: &MaterialModel,
|
||||
mat: &Material,
|
||||
es: &mut ElementState,
|
||||
f: &Mat3,
|
||||
j: f64,
|
||||
pressure: Option<f64>,
|
||||
d: &[f64; 6],
|
||||
volume: f64,
|
||||
) -> Result<f64> {
|
||||
let unstable = |e: fieldcraft_fem::Error| Error::Unstable(e.to_string());
|
||||
if !(j > 0.0 && j.is_finite()) {
|
||||
return Err(Error::Unstable(format!("the material is inverted (volume ratio {j:.3e})")));
|
||||
}
|
||||
let r = model.response(f, None, &[], &mut []).map_err(unstable)?;
|
||||
let before = es.point.stress;
|
||||
es.point.stress = match pressure {
|
||||
// The isochoric part of the element's own F with the averaged pressure.
|
||||
Some(p) => {
|
||||
let iso = model.isochoric(f, None, &[], &mut []).map_err(unstable)?;
|
||||
let mut s = iso.stress.map(|t| t / j);
|
||||
for v in s.iter_mut().take(3) {
|
||||
*v += p;
|
||||
}
|
||||
s
|
||||
}
|
||||
None => r.stress.map(|t| t / j),
|
||||
};
|
||||
let work: f64 = (0..6).map(|k| 0.5 * (before[k] + es.point.stress[k]) * d[k]).sum();
|
||||
es.energy += work * volume;
|
||||
if let Some(e) = fieldcraft_fem::constitutive::logarithmic_strain(f) {
|
||||
es.strain = e;
|
||||
}
|
||||
let normal = (0..3).map(|k| r.tangent[k][k]).fold(0.0, f64::max) / j;
|
||||
let stress = es.point.stress.iter().fold(0.0f64, |m, s| m.max(s.abs()));
|
||||
Ok(((normal + stress) / mat.stiffest).max(1.0))
|
||||
}
|
||||
|
||||
/// The mechanical part of a strain increment: less the thermal strain increment `thermal` in
|
||||
/// every normal direction (isotropic expansion, the same in any frame).
|
||||
fn mechanical(mut d: [f64; 6], thermal: f64) -> [f64; 6] {
|
||||
@@ -490,7 +547,22 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
|
||||
let (d, _) = rotation::split(&g);
|
||||
let d = mechanical(d, h.thermal_rate * m.dt);
|
||||
let (rho, c) = current(mat, h.kinematics.volume, now.volume);
|
||||
update(mat, es, &q, &d, d[0] + d[1] + d[2], m.dt, rho, mid.volume);
|
||||
// Mass scaling raises the density the dynamics see (not the one adiabatic heating uses).
|
||||
let (rho_s, c_s) = (rho * h.mass_factor, c / h.mass_factor.sqrt());
|
||||
let stiffer = match &mat.hyperelastic {
|
||||
Some(model) => {
|
||||
let mut f = h.kinematics.gradient(&e.u);
|
||||
for k in 0..3 {
|
||||
f[k][k] += 1.0;
|
||||
}
|
||||
let j = fieldcraft_fem::dense::det3(&f);
|
||||
hyperelastic(model, mat, es, &f, j, None, &d, mid.volume).map_err(|_| inverted(h.label, m.time))?
|
||||
}
|
||||
None => {
|
||||
update(mat, es, &q, &d, d[0] + d[1] + d[2], m.dt, rho, mid.volume);
|
||||
1.0
|
||||
}
|
||||
};
|
||||
if let Some(failure) = es.failed {
|
||||
return Ok(Outcome::Eroded(failure));
|
||||
}
|
||||
@@ -498,7 +570,7 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
|
||||
// geometry).
|
||||
let (gamma, scales) = if sys.nonlinear {
|
||||
let gamma = now.hourglass_vectors(&x_now);
|
||||
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho, hourglass);
|
||||
let scales = hex_scales(&now, &gamma, &h.mass, mat, rho_s, hourglass);
|
||||
(gamma, scales)
|
||||
} else {
|
||||
(h.gamma, h.rest)
|
||||
@@ -523,7 +595,7 @@ fn hexahedron(sys: &System, h: &crate::system::Hex, m: &Motion, es: &mut Element
|
||||
}
|
||||
}
|
||||
}
|
||||
let element = Element { now: &now, mid: &mid, density: rho, wave_speed: c, length: 2.0 * c / scales.omega2.sqrt() };
|
||||
let element = Element { now: &now, mid: &mid, density: rho_s, wave_speed: c_s, length: 2.0 * c_s / (stiffer * scales.omega2).sqrt() };
|
||||
let mut forces = element.finish(&es.point.stress, &e.v, m.bulk);
|
||||
forces.hourglass = Some(f);
|
||||
Ok(Outcome::Forces(forces))
|
||||
@@ -551,6 +623,7 @@ fn tetrahedron(
|
||||
t: &crate::system::Tet,
|
||||
inc: Option<TetIncrement>,
|
||||
theta: &[f64],
|
||||
ratio: &[f64],
|
||||
m: &Motion,
|
||||
es: &mut ElementState,
|
||||
) -> Result<Outcome<4>> {
|
||||
@@ -561,12 +634,36 @@ fn tetrahedron(
|
||||
let thermal = t.thermal_rate * m.dt;
|
||||
let d = mechanical(d, thermal);
|
||||
let (rho, c) = current(mat, t.kinematics.volume, inc.now.volume);
|
||||
update(mat, es, &q, &d, averaged - 3.0 * thermal, m.dt, rho, inc.mid.volume);
|
||||
let (rho_s, c_s) = (rho * t.mass_factor, c / t.mass_factor.sqrt());
|
||||
let stiffer = match &mat.hyperelastic {
|
||||
// The average nodal pressure of a decoupled material (Bonet & Burton 1998, at finite
|
||||
// strain): each node's pressure U′(J_a) from its tetrahedra's volume ratio, the element's
|
||||
// the mean of its nodes', with the isochoric stress of its own F.
|
||||
Some(model) => {
|
||||
let u = gather(m.u, &t.nodes);
|
||||
let mut f = t.kinematics.gradient(&u);
|
||||
for k in 0..3 {
|
||||
f[k][k] += 1.0;
|
||||
}
|
||||
let j = fieldcraft_fem::dense::det3(&f);
|
||||
let pressure = if model.is_decoupled() {
|
||||
let p: Option<f64> = t.nodes.iter().map(|n| model.volumetric(ratio.get(*n).copied().unwrap_or(1.0), false).map(|(p, _)| p)).sum();
|
||||
p.map(|p| p / 4.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
hyperelastic(model, mat, es, &f, j, pressure, &d, inc.mid.volume).map_err(|_| inverted(t.label, m.time))?
|
||||
}
|
||||
None => {
|
||||
update(mat, es, &q, &d, averaged - 3.0 * thermal, m.dt, rho, inc.mid.volume);
|
||||
1.0
|
||||
}
|
||||
};
|
||||
if let Some(failure) = es.failed {
|
||||
return Ok(Outcome::Eroded(failure));
|
||||
}
|
||||
let omega2 = if sys.nonlinear { frequency_bound(&inc.now, inc.now.gradient_norm(), mat.modulus, &t.mass) } else { t.omega2 };
|
||||
let element = Element { now: &inc.now, mid: &inc.mid, density: rho, wave_speed: c, length: 2.0 * c / omega2.sqrt() };
|
||||
let omega2 = stiffer * if sys.nonlinear { frequency_bound(&inc.now, inc.now.gradient_norm(), mat.modulus, &t.mass) } else { t.omega2 };
|
||||
let element = Element { now: &inc.now, mid: &inc.mid, density: rho_s, wave_speed: c_s, length: 2.0 * c_s / omega2.sqrt() };
|
||||
Ok(Outcome::Forces(element.finish(&es.point.stress, &inc.v, m.bulk)))
|
||||
}
|
||||
|
||||
@@ -629,16 +726,32 @@ pub fn internal_forces(sys: &System, m: &Motion, state: &mut State, ws: &mut Wor
|
||||
*x /= w;
|
||||
}
|
||||
}
|
||||
if !ws.tet_ratio.is_empty() {
|
||||
ws.tet_ratio.fill(0.0);
|
||||
ws.tet_rest.fill(0.0);
|
||||
for (t, inc) in tets.iter().zip(&ws.tet_increment) {
|
||||
let Some(inc) = inc else { continue };
|
||||
for node in t.nodes {
|
||||
if let (Some(x), Some(w)) = (ws.tet_ratio.get_mut(node), ws.tet_rest.get_mut(node)) {
|
||||
*x += inc.now.volume;
|
||||
*w += t.kinematics.volume;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (x, w) in ws.tet_ratio.iter_mut().zip(&ws.tet_rest) {
|
||||
*x = if *w > 0.0 { *x / w } else { 1.0 };
|
||||
}
|
||||
}
|
||||
// Pass 2: each tetrahedron's update.
|
||||
let State { elements, eroded } = state;
|
||||
let (theta, increments) = (&ws.theta, &ws.tet_increment);
|
||||
let (theta, ratio, increments) = (&ws.theta, &ws.tet_ratio, &ws.tet_increment);
|
||||
let mut forces: Vec<(usize, Forces<4>)> = Vec::new();
|
||||
run(
|
||||
elements,
|
||||
tets.len(),
|
||||
|k| tets.get(k).map_or(usize::MAX, |t| t.index),
|
||||
|k, es| match tets.get(k) {
|
||||
Some(t) => tetrahedron(sys, t, increments.get(k).copied().flatten(), theta, m, es),
|
||||
Some(t) => tetrahedron(sys, t, increments.get(k).copied().flatten(), theta, ratio, m, es),
|
||||
None => Ok(Outcome::Skipped),
|
||||
},
|
||||
|k, outcome| {
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
//! 1. u^n = u^{n−1} + Δt^{n−½} v^{n−½}; t^n = t^{n−1} + Δt^{n−½};
|
||||
//! 2. internal forces f^n from u^n (stresses, hourglass stiffness) and v^{n−½} (hourglass and
|
||||
//! bulk viscosity), and the next increment Δt^{n+½} = scale · min_e Δt_e;
|
||||
//! 3. v^{n+½} = v^{n−½} + Δt^n M⁻¹(f_ext − f^n), then the constraints: rigid walls set the normal
|
||||
//! velocity of a node that would cross its wall so it lands on it (a frictionless kinematic
|
||||
//! contact condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W.
|
||||
//! Kanoknukulchai, *A finite element method for a class of contact-impact problems*, CMAME
|
||||
//! 8(3):249–276, 1976), and prescribed degrees of freedom take their velocity. The change a
|
||||
//! constraint makes is the impulse of its reaction, r^n = M Δv / Δt^n;
|
||||
//! 4. energies: the works of the internal and external forces by the trapezoidal rule,
|
||||
//! 3. the contact forces c^n at u^n ([`crate::contact`], with the stable increment Δt^{n+½});
|
||||
//! v^{n+½} = v^{n−½} + Δt^n M⁻¹(f_ext + c^n − f^n), then the constraints: rigid walls set the
|
||||
//! normal velocity of a node that would cross its wall so it lands on it (a kinematic contact
|
||||
//! condition: T. J. R. Hughes, R. L. Taylor, J. L. Sackman, A. Curnier, W. Kanoknukulchai,
|
||||
//! *A finite element method for a class of contact-impact problems*, CMAME 8(3):249–276,
|
||||
//! 1976), with Coulomb friction as a tangential impulse at most μ times the normal one (the
|
||||
//! tangential velocity stops, or loses μ Δv_n when it slides), and prescribed degrees of
|
||||
//! freedom take their velocity. The change a constraint makes is the impulse of its reaction,
|
||||
//! r^n = M Δv / Δt^n;
|
||||
//! 4. energies: the works of the internal, contact and external forces by the trapezoidal rule,
|
||||
//! W += ½ Δu · (f^{n−1} + f^n) (exactly the strain energy for a linear elastic material),
|
||||
//! the constraints' work Δt^n r^n · ½(v^{n−½} + v^{n+½}) (exactly the kinetic energy their
|
||||
//! impulses add or remove), and the kinetic energy of v^n = v^{n−½} + ½Δt^{n−½} a^n.
|
||||
@@ -22,8 +25,9 @@
|
||||
|
||||
use fieldcraft_model::{ExplicitDynamic, Model, OutputVariable, Procedure, Step};
|
||||
|
||||
use crate::contact::{Evaluation, Interface};
|
||||
use crate::elements::{Motion, State, Workspace, internal_forces};
|
||||
use crate::result::{ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
use crate::result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
use crate::system::{NODE_DOFS, System};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
@@ -70,8 +74,10 @@ struct Output {
|
||||
}
|
||||
|
||||
/// Velocity constraints over an increment of length `dt` (rigid walls when dt > 0, then
|
||||
/// prescribed degrees of freedom).
|
||||
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
/// prescribed degrees of freedom); returns the kinetic energy the walls' friction took (`mass`
|
||||
/// per degree of freedom).
|
||||
fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64, mass: &[f64]) -> f64 {
|
||||
let mut dissipated = 0.0;
|
||||
if dt > 0.0 {
|
||||
for w in &sys.walls {
|
||||
for &node in &w.nodes {
|
||||
@@ -86,6 +92,9 @@ fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
*c += change * w.normal[k];
|
||||
}
|
||||
}
|
||||
if !w.friction.is_frictionless() {
|
||||
dissipated += wall_friction(v, node, w, change, mass.get(at(0)).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,14 +104,75 @@ fn constrain(sys: &System, u: &[f64], v: &mut [f64], dt: f64) {
|
||||
*c = *p;
|
||||
}
|
||||
}
|
||||
dissipated
|
||||
}
|
||||
|
||||
/// v_out = v_in + dt M⁻¹ (f_ext − f_int).
|
||||
/// Coulomb friction of a node the wall `w` stopped with a normal velocity change `change`: its
|
||||
/// tangential velocity changes by at most μ · change (stick when that stops it); returns the
|
||||
/// kinetic energy taken, ½ m (|v_t|² − |v_t′|²).
|
||||
fn wall_friction(v: &mut [f64], node: usize, w: &crate::system::Wall, change: f64, mass: f64) -> f64 {
|
||||
let at = |k: usize| NODE_DOFS * node + k;
|
||||
let vel = [0, 1, 2].map(|k| v.get(at(k)).copied().unwrap_or(0.0));
|
||||
let vn: f64 = (0..3).map(|k| vel[k] * w.normal[k]).sum();
|
||||
let tangential = [0, 1, 2].map(|k| vel[k] - vn * w.normal[k]);
|
||||
let slide = tangential.iter().map(|c| c * c).sum::<f64>().sqrt();
|
||||
if slide == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let cap = w.friction.coefficient(slide) * change;
|
||||
let keep = if slide <= cap { 0.0 } else { 1.0 - cap / slide };
|
||||
for k in 0..3 {
|
||||
if let Some(c) = v.get_mut(at(k)) {
|
||||
*c = vn * w.normal[k] + keep * tangential[k];
|
||||
}
|
||||
}
|
||||
0.5 * mass * slide * slide * (1.0 - keep * keep)
|
||||
}
|
||||
|
||||
/// v_out = v_in + dt M⁻¹ (f_ext + f_contact − f_int).
|
||||
fn accelerate(sys: &System, ws: &Workspace, mass: &[f64], v_in: &[f64], dt: f64, v_out: &mut [f64]) {
|
||||
let internal = ws.stress_force.iter().zip(&ws.hourglass_force).zip(&ws.viscous_force);
|
||||
for ((((out, v), m), e), ((s, h), q)) in v_out.iter_mut().zip(v_in).zip(mass).zip(&sys.external).zip(internal) {
|
||||
*out = v + dt * ((e - (s + h + q)) / m);
|
||||
}
|
||||
// Contact adds its forces (a separate pass, so a model without contact runs the same sums).
|
||||
if !ws.contact_force.is_empty() {
|
||||
for ((out, m), c) in v_out.iter_mut().zip(mass).zip(&ws.contact_force) {
|
||||
*out += dt * (c / m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The contacts' forces at `u` (with the increment `du` over `dt_prev` that led there, and the
|
||||
/// stable increment `dt`) into `ws.contact_force`, and what each found.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn contact_forces(
|
||||
contacts: &mut [Interface],
|
||||
sys: &System,
|
||||
u: &[f64],
|
||||
du: &[f64],
|
||||
dt_prev: f64,
|
||||
dt: f64,
|
||||
mass: &[f64],
|
||||
state: &State,
|
||||
ws: &mut Workspace,
|
||||
) -> Vec<Evaluation> {
|
||||
ws.contact_force.fill(0.0);
|
||||
contacts.iter_mut().map(|c| c.forces(&sys.xyz, u, du, dt_prev, dt, mass, state, &mut ws.contact_force)).collect()
|
||||
}
|
||||
|
||||
/// A contact's record over the run.
|
||||
fn track(summary: &mut ContactSummary, e: &Evaluation, t: f64) {
|
||||
if e.active > 0 {
|
||||
summary.first_contact.get_or_insert(t);
|
||||
summary.last_contact = Some(t);
|
||||
}
|
||||
if e.normal_force > summary.peak_force {
|
||||
summary.peak_force = e.normal_force;
|
||||
summary.peak_time = t;
|
||||
}
|
||||
summary.max_penetration = summary.max_penetration.max(e.penetration);
|
||||
summary.friction += e.dissipation;
|
||||
}
|
||||
|
||||
/// The constraint reactions M (v_constrained − v_free) / Δt, into `r`.
|
||||
@@ -120,7 +190,7 @@ fn per_node(x: &[f64]) -> Vec<[f64; 3]> {
|
||||
pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Result<ExplicitResult> {
|
||||
let resolved = model.resolve()?;
|
||||
let (step, settings) = select_step(model, step)?;
|
||||
let sys = System::new(model, &resolved, step, settings, limits.max_dofs)?;
|
||||
let mut sys = System::new(model, &resolved, step, settings, limits)?;
|
||||
let end = settings.end_time;
|
||||
let scale = settings.dt_scale;
|
||||
let bulk = settings.bulk_viscosity;
|
||||
@@ -131,10 +201,14 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let mut critical: Vec<ControllingElement> = sys
|
||||
.hexes
|
||||
.iter()
|
||||
.map(|h| (h.label, h.length, h.material))
|
||||
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material)))
|
||||
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.material)))
|
||||
.map(|(label, length, m)| ControllingElement { label, increment: length / sys.materials.get(m).map_or(f64::INFINITY, |m| m.wave_speed) })
|
||||
.map(|h| (h.label, h.length, h.material, h.mass_factor))
|
||||
.chain(sys.tets.iter().map(|t| (t.label, t.length, t.material, t.mass_factor)))
|
||||
.chain(sys.rods.iter().map(|r| (r.label, r.length, r.material, 1.0)))
|
||||
.map(|(label, length, m, f)| ControllingElement {
|
||||
label,
|
||||
// Mass scaling slows the element's waves by √f.
|
||||
increment: length * f.sqrt() / sys.materials.get(m).map_or(f64::INFINITY, |m| m.wave_speed),
|
||||
})
|
||||
.collect();
|
||||
critical.sort_by(|a, b| a.increment.total_cmp(&b.increment).then(a.label.cmp(&b.label)));
|
||||
critical.truncate(CONTROLLING);
|
||||
@@ -192,6 +266,9 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let total_mass: f64 = sys.mass.iter().sum::<f64>() + sys.discrete.mass.iter().sum::<f64>() / 3.0;
|
||||
let mut ws = Workspace::new(&sys);
|
||||
let mut previous = Workspace::new(&sys);
|
||||
let mut contacts = std::mem::take(&mut sys.contacts);
|
||||
let mut summaries: Vec<ContactSummary> =
|
||||
contacts.iter().map(|c| ContactSummary { name: c.name.clone(), adjusted: c.adjusted.clone(), ..ContactSummary::default() }).collect();
|
||||
let mut state = State::new(&sys);
|
||||
let mut eroded: Vec<ErodedElement> = Vec::new();
|
||||
let mut u = vec![0.0; n];
|
||||
@@ -205,10 +282,14 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let crit = internal_forces(&sys, &start, &mut state, &mut ws)?;
|
||||
let mut dt_half = (scale * crit).min(end);
|
||||
let initial_increment = dt_half;
|
||||
let mut found = contact_forces(&mut contacts, &sys, &u, &du, 0.0, scale * crit, &mass, &state, &mut ws);
|
||||
for (summary, e) in summaries.iter_mut().zip(&found) {
|
||||
track(summary, e, 0.0);
|
||||
}
|
||||
let mut v_half = vec![0.0; n];
|
||||
accelerate(&sys, &ws, &mass, &v0, 0.5 * dt_half, &mut v_half);
|
||||
let mut free = v_half.clone();
|
||||
constrain(&sys, &u, &mut v_half, dt_half);
|
||||
let wall_friction = constrain(&sys, &u, &mut v_half, dt_half, &mass);
|
||||
let mut reaction = vec![0.0; n];
|
||||
impulses(&mass, &free, &v_half, 0.5 * dt_half, &mut reaction);
|
||||
// The constraints' work is that of their impulses on the mean of the velocities before
|
||||
@@ -217,12 +298,18 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let impulse_work = |r: &[f64], before: &[f64], after: &[f64], dt: f64| -> f64 {
|
||||
dt * r.iter().zip(before.iter().zip(after)).map(|(r, (a, b))| r * 0.5 * (a + b)).sum::<f64>()
|
||||
};
|
||||
let mut energy = Energies { kinetic: initial_kinetic, constraint: impulse_work(&reaction, &v0, &v_half, 0.5 * dt_half), ..Energies::default() };
|
||||
let mut energy = Energies {
|
||||
kinetic: initial_kinetic,
|
||||
constraint: impulse_work(&reaction, &v0, &v_half, 0.5 * dt_half),
|
||||
friction: wall_friction,
|
||||
..Energies::default()
|
||||
};
|
||||
|
||||
let mut history = History { nodes: sys.history_nodes.iter().filter_map(|i| sys.labels.get(*i).copied()).collect(), ..History::default() };
|
||||
let mut out_frames = Vec::new();
|
||||
let sample = |history: &mut History, t: f64, dt: f64, e: Energies, u: &[f64], v: &[f64], r: &[f64]| {
|
||||
let sample = |history: &mut History, t: f64, dt: f64, e: Energies, u: &[f64], v: &[f64], r: &[f64], found: &[Evaluation]| {
|
||||
history.time.push(t);
|
||||
history.contact_force.push(found.iter().map(|e| e.normal_force).collect());
|
||||
history.increment.push(dt);
|
||||
history.energies.push(e);
|
||||
let sum = |x: &[f64], weight: &dyn Fn(usize, usize) -> f64| {
|
||||
@@ -260,7 +347,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let plastic = |state: &State| state.elements.iter().map(|e| e.plastic_work).sum::<f64>();
|
||||
let mut histories = Schedule { interval: settings.history_interval(), next: settings.history_interval() };
|
||||
let mut frames_due = Schedule { interval: settings.output_interval(), next: settings.output_interval() };
|
||||
sample(&mut history, 0.0, dt_half, energy, &u, &v0, &reaction);
|
||||
sample(&mut history, 0.0, dt_half, energy, &u, &v0, &reaction, &found);
|
||||
out_frames.push(frame(0.0, &u, &v0, &state));
|
||||
|
||||
let mut t = 0.0;
|
||||
@@ -294,9 +381,16 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
let last = remaining <= 1e-12 * end;
|
||||
let dt_next = if last { 0.0 } else { (scale * crit).min(remaining) };
|
||||
let dt_n = 0.5 * (dt_half + dt_next);
|
||||
if !contacts.is_empty() {
|
||||
found = contact_forces(&mut contacts, &sys, &u, &du, dt_half, scale * crit, &mass, &state, &mut ws);
|
||||
for (summary, e) in summaries.iter_mut().zip(&found) {
|
||||
track(summary, e, t);
|
||||
energy.friction += e.dissipation;
|
||||
}
|
||||
}
|
||||
accelerate(&sys, &ws, &mass, &v_half, dt_n, &mut v_new);
|
||||
free.clone_from(&v_new);
|
||||
constrain(&sys, &u, &mut v_new, dt_next);
|
||||
energy.friction += constrain(&sys, &u, &mut v_new, dt_next, &mass);
|
||||
impulses(&mass, &free, &v_new, dt_n, &mut reaction);
|
||||
// One pass over the degrees of freedom: v^n = v^{n−½} + ½Δt^{n−½} a^n with the constrained
|
||||
// a^n = (v^{n+½} − v^{n−½}) / Δt^n, and the works (trapezoidal over the displacement for
|
||||
@@ -321,15 +415,26 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
energy.external += external;
|
||||
energy.constraint += dt_n * constraint;
|
||||
energy.kinetic = 0.5 * kinetic;
|
||||
if !sys.added_mass.is_empty() {
|
||||
let per_node = v_now.as_chunks::<NODE_DOFS>().0.iter().zip(&sys.added_mass);
|
||||
energy.added_kinetic = 0.5 * per_node.map(|(v, m)| m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2])).sum::<f64>();
|
||||
}
|
||||
if !ws.contact_force.is_empty() {
|
||||
let work: f64 = du.iter().zip(ws.contact_force.iter().zip(&previous.contact_force)).map(|(d, (c, c0))| d * (c0 + c)).sum();
|
||||
energy.contact -= 0.5 * work;
|
||||
}
|
||||
peak_internal = peak_internal.max(energy.internal.abs());
|
||||
energy.balance = energy.kinetic + energy.internal + energy.hourglass + energy.viscous - energy.external - energy.constraint - initial_kinetic;
|
||||
energy.balance = energy.kinetic + energy.internal + energy.hourglass + energy.viscous + energy.contact
|
||||
- energy.external
|
||||
- energy.constraint
|
||||
- initial_kinetic;
|
||||
// Every element eroded leaves an infinite critical increment: the nodes fly freely.
|
||||
if !(energy.balance.is_finite() && !crit.is_nan()) {
|
||||
return Err(Error::Unstable(format!("the solution is not finite at time {t:.6e} (increment {increments})")));
|
||||
}
|
||||
if histories.due(t) || last {
|
||||
energy.plastic = plastic(&state);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction, &found);
|
||||
}
|
||||
if frames_due.due(t) || last {
|
||||
out_frames.push(frame(t, &u, &v_now, &state));
|
||||
@@ -342,7 +447,7 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
}
|
||||
if !completed {
|
||||
if history.time.last() != Some(&t) {
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction);
|
||||
sample(&mut history, t, dt_half, energy, &u, &v_now, &reaction, &found);
|
||||
}
|
||||
if out_frames.last().map(|f| f.time) != Some(t) {
|
||||
out_frames.push(frame(t, &u, &v_now, &state));
|
||||
@@ -385,6 +490,8 @@ pub fn solve_explicit(model: &Model, step: Option<&str>, limits: &Limits) -> Res
|
||||
energy,
|
||||
initial_kinetic,
|
||||
eroded,
|
||||
contacts: summaries,
|
||||
mass_scaling: sys.mass_scaling.clone(),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -14,8 +14,12 @@
|
||||
//! hourglass control (viscous or stiffness), the Bonet–Burton average nodal pressure
|
||||
//! tetrahedron, von Neumann–Richtmyer/Wilkins bulk viscosity, and the element critical time
|
||||
//! increments.
|
||||
//! - **Integration** ([`integrate`]): the time loop, kinematic constraints, energy accounting
|
||||
//! with a balance check, history samples and field frames ([`result`]).
|
||||
//! - **Contact** ([`contact`]): penalty contact between element faces (pairs both ways, single
|
||||
//! surface and general contact) with Coulomb friction by a regularised return map, a bucket
|
||||
//! grid search, and the stiffness from the masses and the stable increment.
|
||||
//! - **Integration** ([`integrate`]): the time loop, kinematic constraints (rigid walls with
|
||||
//! friction), energy accounting with a balance check, history samples and field frames
|
||||
//! ([`result`]).
|
||||
//!
|
||||
//! With nonlinear geometry (the default) the elements are updated Lagrangian and the material
|
||||
//! state is integrated in rate form with an objective stress rate (Jaumann by Hughes–Winget,
|
||||
@@ -33,6 +37,7 @@
|
||||
// Small fixed-size vector kernels read as the textbook formulas with index loops.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod contact;
|
||||
pub mod discrete;
|
||||
pub mod elements;
|
||||
pub mod integrate;
|
||||
@@ -41,7 +46,7 @@ pub mod system;
|
||||
|
||||
pub use elements::Failure;
|
||||
pub use integrate::solve_explicit;
|
||||
pub use result::{ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History};
|
||||
pub use result::{ContactSummary, ControllingElement, Energies, ErodedElement, ExplicitResult, Frame, History, MassScalingSummary};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
@@ -82,16 +87,24 @@ pub struct Limits {
|
||||
pub max_increments: u64,
|
||||
/// Numbers kept in field frames and histories together (8 bytes each).
|
||||
pub max_output_values: usize,
|
||||
/// Element faces of every contact surface together.
|
||||
pub max_contact_faces: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const MAX_DOFS: usize = 6_000_000;
|
||||
pub const MAX_INCREMENTS: u64 = fieldcraft_model::steps::MAX_INCREMENTS;
|
||||
pub const MAX_OUTPUT_VALUES: usize = 50_000_000;
|
||||
pub const MAX_CONTACT_FACES: usize = 10_000_000;
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits { max_dofs: Limits::MAX_DOFS, max_increments: Limits::MAX_INCREMENTS, max_output_values: Limits::MAX_OUTPUT_VALUES }
|
||||
Limits {
|
||||
max_dofs: Limits::MAX_DOFS,
|
||||
max_increments: Limits::MAX_INCREMENTS,
|
||||
max_output_values: Limits::MAX_OUTPUT_VALUES,
|
||||
max_contact_faces: Limits::MAX_CONTACT_FACES,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,21 +22,36 @@ pub struct Energies {
|
||||
pub external: f64,
|
||||
/// Work of the boundary conditions and rigid walls on the model.
|
||||
pub constraint: f64,
|
||||
/// Energy taken by contact: the work the model does against the contact forces (the penalty
|
||||
/// energy stored in the contact springs, plus what friction dissipates there).
|
||||
pub contact: f64,
|
||||
/// The kinetic energy of the mass added by mass scaling (part of `kinetic`).
|
||||
pub added_kinetic: f64,
|
||||
/// Friction dissipation, in contact and on rigid walls (part of `contact` and of
|
||||
/// `constraint`).
|
||||
pub friction: f64,
|
||||
/// Plastic work: the part of `internal` dissipated by plastic flow.
|
||||
pub plastic: f64,
|
||||
/// Internal energy of the eroded elements when they eroded (part of `internal`).
|
||||
pub eroded: f64,
|
||||
/// kinetic + internal + hourglass + viscous − external − constraint − the initial kinetic
|
||||
/// energy: zero when energy is conserved.
|
||||
/// kinetic + internal + hourglass + viscous + contact − external − constraint − the initial
|
||||
/// kinetic energy: zero when energy is conserved.
|
||||
pub balance: f64,
|
||||
}
|
||||
|
||||
impl Energies {
|
||||
/// The balance relative to the largest energy in the account.
|
||||
pub fn relative_error(&self, initial_kinetic: f64) -> f64 {
|
||||
let scale = [initial_kinetic, self.kinetic, self.internal + self.hourglass + self.viscous, self.external.abs(), self.constraint.abs()]
|
||||
.into_iter()
|
||||
.fold(0.0, f64::max);
|
||||
let scale = [
|
||||
initial_kinetic,
|
||||
self.kinetic,
|
||||
self.internal + self.hourglass + self.viscous,
|
||||
self.external.abs(),
|
||||
self.constraint.abs(),
|
||||
self.contact.abs(),
|
||||
]
|
||||
.into_iter()
|
||||
.fold(0.0, f64::max);
|
||||
if scale > 0.0 { self.balance.abs() / scale } else { 0.0 }
|
||||
}
|
||||
}
|
||||
@@ -84,12 +99,46 @@ pub struct History {
|
||||
pub momentum: Vec<[f64; 3]>,
|
||||
/// Sum of the forces the boundary conditions and rigid walls exert on the model.
|
||||
pub constraint_force: Vec<[f64; 3]>,
|
||||
/// Per contact (in [`ExplicitResult::contacts`] order): the normal force across it (the sum
|
||||
/// of its constraints' normal forces).
|
||||
pub contact_force: Vec<Vec<f64>>,
|
||||
/// The nodes with histories, and per sample their displacement and velocity (in this order).
|
||||
pub nodes: Vec<u32>,
|
||||
pub displacement: Vec<Vec<[f64; 3]>>,
|
||||
pub velocity: Vec<Vec<[f64; 3]>>,
|
||||
}
|
||||
|
||||
/// What mass scaling added to a run.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct MassScalingSummary {
|
||||
pub target_increment: f64,
|
||||
/// Elements whose density was raised.
|
||||
pub elements: usize,
|
||||
pub added_mass: f64,
|
||||
/// The added mass over the model's mass (with it).
|
||||
pub fraction: f64,
|
||||
/// The added mass per section, by name.
|
||||
pub sections: Vec<(String, f64)>,
|
||||
}
|
||||
|
||||
/// What a contact did over the run.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct ContactSummary {
|
||||
pub name: String,
|
||||
/// Nodes moved onto the other surface before the analysis.
|
||||
pub adjusted: Vec<crate::contact::Adjustment>,
|
||||
/// The largest normal force across it, and when.
|
||||
pub peak_force: f64,
|
||||
pub peak_time: f64,
|
||||
/// The first and the last time it carried a force (none when it never did).
|
||||
pub first_contact: Option<f64>,
|
||||
pub last_contact: Option<f64>,
|
||||
/// The deepest penetration of a node into a face.
|
||||
pub max_penetration: f64,
|
||||
/// Friction dissipated in it.
|
||||
pub friction: f64,
|
||||
}
|
||||
|
||||
/// An element that limits the stable time increment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
pub struct ControllingElement {
|
||||
@@ -122,5 +171,9 @@ pub struct ExplicitResult {
|
||||
pub initial_kinetic: f64,
|
||||
/// Elements eroded during the run, in the order they eroded.
|
||||
pub eroded: Vec<ErodedElement>,
|
||||
/// Per contact of the model, in name order.
|
||||
pub contacts: Vec<ContactSummary>,
|
||||
/// What mass scaling added, when the step asks for it.
|
||||
pub mass_scaling: Option<MassScalingSummary>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -3,17 +3,19 @@
|
||||
//! ([`crate::discrete`]), prescribed velocities, rigid walls, initial velocities and the load
|
||||
//! vector of an explicit dynamic step.
|
||||
|
||||
use fieldcraft_fem::constitutive::MaterialModel;
|
||||
use fieldcraft_fem::material::Elasticity;
|
||||
use fieldcraft_fem::plasticity::J2;
|
||||
use fieldcraft_fem::uniform::{Hex8, Tet4, modulus_bound};
|
||||
use fieldcraft_fem::{DofKind, DofLayout, DofSet, face};
|
||||
use fieldcraft_model::{
|
||||
Elastic, ElementKind, ExplicitDynamic, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
|
||||
Elastic, ElementKind, ExplicitDynamic, Friction, HourglassKind, Integration, Load, Model, Resolved, SectionKind, Step, StressRate, Target,
|
||||
};
|
||||
|
||||
use crate::contact::Interface;
|
||||
use crate::discrete::{Discrete, Rod};
|
||||
use crate::elements::{HexScales, add_at, frequency_bound, hex_scales};
|
||||
use crate::{Error, Result};
|
||||
use crate::{Error, Limits, Result};
|
||||
|
||||
/// Degrees of freedom per node: the three translations, node-major (see [`System`]).
|
||||
pub const NODE_DOFS: usize = 3;
|
||||
@@ -37,6 +39,9 @@ pub struct Material {
|
||||
/// The hypoelastic(–plastic) update of an isotropic material; `None` for orthotropic
|
||||
/// elasticity, updated with [`Material::elasticity`].
|
||||
pub j2: Option<J2>,
|
||||
/// The finite-strain material point of a hyperelastic material (its stress from the
|
||||
/// deformation gradient; `None` for the rate-form materials).
|
||||
pub hyperelastic: Option<MaterialModel>,
|
||||
/// The equivalent plastic strain at which elements erode.
|
||||
pub erosion: Option<f64>,
|
||||
/// The (isotropic) expansion coefficient and reference temperature, when the material
|
||||
@@ -63,6 +68,8 @@ pub struct Hex {
|
||||
pub length: f64,
|
||||
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
|
||||
pub thermal_rate: f64,
|
||||
/// Its density's factor from mass scaling (1 without).
|
||||
pub mass_factor: f64,
|
||||
}
|
||||
|
||||
/// A nodal-pressure tetrahedron.
|
||||
@@ -79,15 +86,18 @@ pub struct Tet {
|
||||
pub length: f64,
|
||||
/// The rate of its thermal strain, α ΔT / end time (every normal direction).
|
||||
pub thermal_rate: f64,
|
||||
/// Its density's factor from mass scaling (1 without).
|
||||
pub mass_factor: f64,
|
||||
}
|
||||
|
||||
/// A fixed, frictionless rigid plane.
|
||||
/// A fixed rigid plane.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wall {
|
||||
pub point: [f64; 3],
|
||||
/// Unit normal, towards the side the nodes stay on.
|
||||
pub normal: [f64; 3],
|
||||
pub nodes: Vec<usize>,
|
||||
pub friction: Friction,
|
||||
}
|
||||
|
||||
/// The discrete system of an explicit dynamic step.
|
||||
@@ -114,9 +124,14 @@ pub struct System {
|
||||
/// The springs' and dashpots' stable increment on the lumped mass, and a link that sets it,
|
||||
/// when there are any.
|
||||
pub discrete_increment: Option<(f64, String)>,
|
||||
/// What mass scaling added: per node, and per section (in name order).
|
||||
pub added_mass: Vec<f64>,
|
||||
pub mass_scaling: Option<crate::result::MassScalingSummary>,
|
||||
/// Prescribed velocity per degree of freedom (`None` = free).
|
||||
pub prescribed: Vec<Option<f64>>,
|
||||
pub walls: Vec<Wall>,
|
||||
/// The model's contacts ([`crate::contact`]).
|
||||
pub contacts: Vec<Interface>,
|
||||
/// Velocity per degree of freedom at the start (prescribed velocities included).
|
||||
pub initial_velocity: Vec<f64>,
|
||||
/// Constant external force per degree of freedom.
|
||||
@@ -139,9 +154,6 @@ fn invalid(m: impl Into<String>) -> Error {
|
||||
fn material(model: &Model, name: &str) -> Result<Material> {
|
||||
let m = model.material(name).ok_or_else(|| invalid(format!("material `{name}` is missing")))?;
|
||||
let elastic = m.elastic.as_ref().ok_or_else(|| invalid(format!("material `{name}` has no elastic constants")))?;
|
||||
if let Some(h) = m.hyperelastic {
|
||||
return Err(Error::NotSupported(format!("{} hyperelastic material `{name}` in explicit dynamics", h.name())));
|
||||
}
|
||||
let density = m
|
||||
.density
|
||||
.filter(|d| *d > 0.0 && d.is_finite())
|
||||
@@ -170,6 +182,12 @@ fn material(model: &Model, name: &str) -> Result<Material> {
|
||||
stiffest,
|
||||
j2,
|
||||
erosion: m.erosion.map(|e| e.plastic_strain),
|
||||
hyperelastic: m
|
||||
.hyperelastic
|
||||
.as_ref()
|
||||
.map(|h| MaterialModel::from_model(elastic, Some(h)))
|
||||
.transpose()
|
||||
.map_err(|e| invalid(format!("material `{name}`: {e}")))?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,10 +210,12 @@ fn check_formulation(kind: ElementKind, section_name: &str, f: &fieldcraft_model
|
||||
}
|
||||
|
||||
impl System {
|
||||
pub fn new(model: &Model, resolved: &Resolved, step: &Step, settings: &ExplicitDynamic, max_dofs: usize) -> Result<System> {
|
||||
pub fn new(model: &Model, resolved: &Resolved, step: &Step, settings: &ExplicitDynamic, limits: &Limits) -> Result<System> {
|
||||
let max_dofs = limits.max_dofs;
|
||||
if let Some(name) = model.couplings().keys().next() {
|
||||
return Err(Error::NotSupported(format!("couplings (coupling `{name}`) in explicit dynamics")));
|
||||
}
|
||||
|
||||
// Elements by label, with their section's material.
|
||||
let mut materials: Vec<Material> = Vec::new();
|
||||
let mut chosen: Vec<(u32, ElementKind, Vec<u32>, usize, Option<f64>)> = Vec::new();
|
||||
@@ -264,10 +284,13 @@ impl System {
|
||||
tets: Vec::new(),
|
||||
rods: Vec::new(),
|
||||
mass: vec![0.0; n],
|
||||
added_mass: Vec::new(),
|
||||
mass_scaling: None,
|
||||
discrete: Discrete::default(),
|
||||
discrete_increment: None,
|
||||
prescribed: vec![None; dofs],
|
||||
walls: Vec::new(),
|
||||
contacts: Vec::new(),
|
||||
initial_velocity: vec![0.0; dofs],
|
||||
external: vec![0.0; dofs],
|
||||
history_nodes: Vec::new(),
|
||||
@@ -279,6 +302,20 @@ impl System {
|
||||
labels,
|
||||
xyz,
|
||||
};
|
||||
// Contact first: nodes that start inside a surface are moved before the elements are
|
||||
// built on the coordinates.
|
||||
sys.contacts = crate::contact::interfaces(model, resolved, &mut sys.xyz, &sys.labels, &sys.elements, limits.max_contact_faces)?;
|
||||
for c in &sys.contacts {
|
||||
if let Some(deepest) = c.adjusted.iter().max_by(|a, b| a.depth.total_cmp(&b.depth)) {
|
||||
sys.warnings.push(format!(
|
||||
"contact `{}`: {} nodes started inside the other surface and were moved onto it (the deepest, node {}, by {:.3e})",
|
||||
c.name,
|
||||
c.adjusted.len(),
|
||||
deepest.node,
|
||||
deepest.depth
|
||||
));
|
||||
}
|
||||
}
|
||||
for (index, (label, kind, nodes, mi, area)) in chosen.into_iter().enumerate() {
|
||||
let idx: Vec<usize> = nodes.iter().map(|l| sys.node(*l).ok_or_else(|| Error::Internal(format!("node {l}")))).collect::<Result<_>>()?;
|
||||
let mat = sys.materials.get(mi).ok_or_else(|| Error::Internal("material index".into()))?;
|
||||
@@ -294,7 +331,19 @@ impl System {
|
||||
let rest = hex_scales(&k, &gamma, &mass, mat, rho, settings.hourglass);
|
||||
sys.lump(&nodes, &mass);
|
||||
let length = 2.0 * c / rest.omega2.sqrt();
|
||||
sys.hexes.push(Hex { label, index, nodes, material: mi, kinematics: k, gamma, mass, rest, length, thermal_rate: 0.0 });
|
||||
sys.hexes.push(Hex {
|
||||
label,
|
||||
index,
|
||||
nodes,
|
||||
material: mi,
|
||||
kinematics: k,
|
||||
gamma,
|
||||
mass,
|
||||
rest,
|
||||
length,
|
||||
thermal_rate: 0.0,
|
||||
mass_factor: 1.0,
|
||||
});
|
||||
}
|
||||
ElementKind::Tet4 => {
|
||||
let nodes: [usize; 4] = idx.try_into().map_err(|_| invalid(format!("element {label} does not have 4 nodes")))?;
|
||||
@@ -313,6 +362,7 @@ impl System {
|
||||
omega2,
|
||||
length: 2.0 * c / omega2.sqrt(),
|
||||
thermal_rate: 0.0,
|
||||
mass_factor: 1.0,
|
||||
});
|
||||
}
|
||||
ElementKind::Rod2 | ElementKind::Rod3 => {
|
||||
@@ -341,15 +391,126 @@ impl System {
|
||||
other => return Err(Error::Internal(format!("{} element kind", other.name()))),
|
||||
}
|
||||
}
|
||||
if let Some(scaling) = &settings.mass_scaling {
|
||||
sys.scale_mass(model, resolved, scaling, settings)?;
|
||||
}
|
||||
sys.discrete = Discrete::new(resolved, sys.dofs(), &sys.xyz, |l| sys.node(l))?;
|
||||
sys.discrete_increment = sys.discrete.critical_increment(&sys.dof_mass(), sys.nonlinear);
|
||||
sys.prescribe(model, resolved, step, settings.end_time)?;
|
||||
sys.load(model, resolved, step)?;
|
||||
sys.temperatures(model, resolved, step, settings.end_time)?;
|
||||
sys.conditions(resolved, settings)?;
|
||||
sys.check_hyperelastic()?;
|
||||
Ok(sys)
|
||||
}
|
||||
|
||||
/// Mass scaling to a target increment (see [`fieldcraft_model::MassScaling`]): each solid
|
||||
/// element (of the set) whose stable increment times the step's scale is below the target
|
||||
/// gets its density times (target / increment)², its lumped masses and frequency bound with
|
||||
/// it; the added mass is kept per node and per section.
|
||||
fn scale_mass(&mut self, model: &Model, resolved: &Resolved, scaling: &fieldcraft_model::MassScaling, settings: &ExplicitDynamic) -> Result<()> {
|
||||
let chosen: Option<&[u32]> = match &scaling.set {
|
||||
Some(s) => Some(resolved.sets.get(s).map(|r| r.elements.as_slice()).ok_or_else(|| invalid(format!("set `{s}` is not resolved")))?),
|
||||
None => None,
|
||||
};
|
||||
let wanted = |label: u32| chosen.is_none_or(|l| l.binary_search(&label).is_ok());
|
||||
let factor = |length: f64, material: usize, materials: &[Material]| -> f64 {
|
||||
let c = materials.get(material).map_or(f64::INFINITY, |m| m.wave_speed);
|
||||
let dt = settings.dt_scale * length / c;
|
||||
if dt > 0.0 && dt < scaling.target_increment { (scaling.target_increment / dt).powi(2) } else { 1.0 }
|
||||
};
|
||||
let hourglass = fieldcraft_model::Hourglass { kind: settings.hourglass.kind, coefficient: settings.hourglass.coefficient };
|
||||
let mut added = vec![0.0; self.labels.len()];
|
||||
let mut by_element: Vec<(u32, f64)> = Vec::new();
|
||||
for h in self.hexes.iter_mut().filter(|h| wanted(h.label)) {
|
||||
let f = factor(h.length, h.material, &self.materials);
|
||||
if f == 1.0 {
|
||||
continue;
|
||||
}
|
||||
let extra = h.mass.map(|m| m * (f - 1.0));
|
||||
for (n, x) in h.nodes.iter().zip(extra) {
|
||||
if let Some(a) = added.get_mut(*n) {
|
||||
*a += x;
|
||||
}
|
||||
}
|
||||
h.mass = h.mass.map(|m| m * f);
|
||||
h.mass_factor = f;
|
||||
if let Some(mat) = self.materials.get(h.material) {
|
||||
h.rest = hex_scales(&h.kinematics, &h.gamma, &h.mass, mat, mat.density * f, hourglass);
|
||||
}
|
||||
by_element.push((h.label, extra.iter().sum()));
|
||||
}
|
||||
for t in self.tets.iter_mut().filter(|t| wanted(t.label)) {
|
||||
let f = factor(t.length, t.material, &self.materials);
|
||||
if f == 1.0 {
|
||||
continue;
|
||||
}
|
||||
let extra = t.mass.map(|m| m * (f - 1.0));
|
||||
for (n, x) in t.nodes.iter().zip(extra) {
|
||||
if let Some(a) = added.get_mut(*n) {
|
||||
*a += x;
|
||||
}
|
||||
}
|
||||
t.mass = t.mass.map(|m| m * f);
|
||||
t.omega2 /= f;
|
||||
t.mass_factor = f;
|
||||
by_element.push((t.label, extra.iter().sum()));
|
||||
}
|
||||
for (m, a) in self.mass.iter_mut().zip(&added) {
|
||||
*m += a;
|
||||
}
|
||||
let total_added: f64 = added.iter().sum();
|
||||
let total: f64 = self.mass.iter().sum();
|
||||
let mut sections: Vec<(String, f64)> = Vec::new();
|
||||
for (name, section) in model.sections() {
|
||||
let elements = resolved.sets.get(§ion.set).map(|r| r.elements.as_slice()).unwrap_or_default();
|
||||
let mass: f64 = by_element.iter().filter(|(l, _)| elements.binary_search(l).is_ok()).map(|(_, m)| m).sum();
|
||||
if mass > 0.0 {
|
||||
sections.push((name.clone(), mass));
|
||||
}
|
||||
}
|
||||
if total_added > 0.0 {
|
||||
self.warnings.push(format!(
|
||||
"mass scaling to an increment of {:.3e} added {total_added:.4e} of mass ({:.2}% of the model's) to {} elements",
|
||||
scaling.target_increment,
|
||||
100.0 * total_added / total.max(f64::MIN_POSITIVE),
|
||||
by_element.len()
|
||||
));
|
||||
}
|
||||
self.mass_scaling = Some(crate::result::MassScalingSummary {
|
||||
target_increment: scaling.target_increment,
|
||||
elements: by_element.len(),
|
||||
added_mass: total_added,
|
||||
fraction: total_added / total.max(f64::MIN_POSITIVE),
|
||||
sections,
|
||||
});
|
||||
self.added_mass = added;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse what hyperelastic elements cannot take yet: small-displacement steps (the
|
||||
/// material is a finite-strain law) and thermal strain.
|
||||
fn check_hyperelastic(&self) -> Result<()> {
|
||||
let solids =
|
||||
self.hexes.iter().map(|h| (h.label, h.material, h.thermal_rate)).chain(self.tets.iter().map(|t| (t.label, t.material, t.thermal_rate)));
|
||||
for (label, material, thermal) in solids {
|
||||
let Some(m) = self.materials.get(material).filter(|m| m.hyperelastic.is_some()) else { continue };
|
||||
if !self.nonlinear {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"hyperelastic material `{}` (element {label}) without nonlinear geometry; hyperelastic laws are finite-strain, set the step's nonlinear_geometry to true",
|
||||
m.name
|
||||
)));
|
||||
}
|
||||
if thermal != 0.0 {
|
||||
return Err(Error::NotSupported(format!(
|
||||
"thermal strain in hyperelastic material `{}` (element {label}) in explicit dynamics",
|
||||
m.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an element's row-sum lumped masses.
|
||||
fn lump(&mut self, nodes: &[usize], mass: &[f64]) {
|
||||
for (node, v) in nodes.iter().zip(mass) {
|
||||
@@ -584,7 +745,7 @@ impl System {
|
||||
return Err(invalid(format!("node {label} of set `{}` starts behind its rigid wall (gap {gap:e})", w.set)));
|
||||
}
|
||||
}
|
||||
self.walls.push(Wall { point: w.point, normal, nodes });
|
||||
self.walls.push(Wall { point: w.point, normal, nodes, friction: w.friction });
|
||||
}
|
||||
if let Some(set) = &settings.history_nodes {
|
||||
let nodes = self.nodes_of(resolved, set);
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
//! Deformable contact and friction in explicit dynamics against closed-form solutions:
|
||||
//! - two identical elastic bars colliding exchange their velocities after a contact of 2L/c, with
|
||||
//! the interface force ρ c A v₀/2 (one-dimensional wave theory: K. F. Graff, *Wave Motion in
|
||||
//! Elastic Solids*, Dover 1991, §2.1–2.3; W. Johnson, *Impact Strength of Materials*, Arnold
|
||||
//! 1972, §1.6), and keep their energy (frictionless elastic impact);
|
||||
//! - a block sliding on a plane with Coulomb friction decelerates at μg and stops after
|
||||
//! v₀²/(2μg), on a rigid wall and on a deformable plate (any dynamics text, e.g. J. L. Meriam,
|
||||
//! L. G. Kraige, *Engineering Mechanics: Dynamics*, §3/5), and with the exponential decay law
|
||||
//! after the distance the decaying coefficient gives;
|
||||
//! - two elastic spheres colliding: Hertz's contact duration and peak force (H. Hertz, *Über die
|
||||
//! Berührung fester elastischer Körper*, J. reine angew. Math. 92:156–171, 1882; K. L. Johnson,
|
||||
//! *Contact Mechanics*, Cambridge 1985, §11.4);
|
||||
//! - a contact patch test: two blocks with non-matching meshes pressed together carry a uniform
|
||||
//! stress across the interface (R. L. Taylor, P. Papadopoulos, *On a patch test for contact
|
||||
//! problems in two dimensions*, in Nonlinear Computational Mechanics, Springer 1991, 690–702;
|
||||
//! T. Y. Crisfield-style uniform-pressure form for three dimensions);
|
||||
//! - and what a contact must never do: hold back a body in free flight, depend on the thread
|
||||
//! count, or crash on hostile input.
|
||||
|
||||
use fieldcraft_explicit::{ExplicitResult, Limits, solve_explicit};
|
||||
use fieldcraft_model::*;
|
||||
|
||||
/// A model built block by block, each block of hex8 with its own nodes.
|
||||
struct Mesh {
|
||||
model: Model,
|
||||
next_node: u32,
|
||||
next_element: u32,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
fn new() -> Mesh {
|
||||
Mesh { model: Model::default(), next_node: 1, next_element: 1 }
|
||||
}
|
||||
|
||||
/// A box from `origin` of `size` with `n` hex8 along each axis, positions mapped by `map`;
|
||||
/// its elements.
|
||||
fn block_mapped(&mut self, origin: [f64; 3], size: [f64; 3], n: [usize; 3], map: impl Fn([f64; 3]) -> [f64; 3]) -> Vec<u32> {
|
||||
let base = self.next_node;
|
||||
let at = |i: usize, j: usize, k: usize| base + (i + (n[0] + 1) * (j + (n[1] + 1) * k)) as u32;
|
||||
for k in 0..=n[2] {
|
||||
for j in 0..=n[1] {
|
||||
for i in 0..=n[0] {
|
||||
let x = [0, 1, 2].map(|d| origin[d] + size[d] * [i, j, k][d] as f64 / n[d] as f64);
|
||||
self.model.set_node(at(i, j, k), map(x)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next_node = at(n[0], n[1], n[2]) + 1;
|
||||
let mut out = Vec::new();
|
||||
for k in 0..n[2] {
|
||||
for j in 0..n[1] {
|
||||
for i in 0..n[0] {
|
||||
let nodes = vec![
|
||||
at(i, j, k),
|
||||
at(i + 1, j, k),
|
||||
at(i + 1, j + 1, k),
|
||||
at(i, j + 1, k),
|
||||
at(i, j, k + 1),
|
||||
at(i + 1, j, k + 1),
|
||||
at(i + 1, j + 1, k + 1),
|
||||
at(i, j + 1, k + 1),
|
||||
];
|
||||
self.model.set_element(self.next_element, ElementKind::Hex8, nodes).unwrap();
|
||||
out.push(self.next_element);
|
||||
self.next_element += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn block(&mut self, origin: [f64; 3], size: [f64; 3], n: [usize; 3]) -> Vec<u32> {
|
||||
self.block_mapped(origin, size, n, |x| x)
|
||||
}
|
||||
|
||||
fn xyz(&self, node: u32) -> [f64; 3] {
|
||||
self.model.node(node).unwrap().xyz
|
||||
}
|
||||
|
||||
/// The faces of `elements` whose centre satisfies `f`.
|
||||
fn faces_where(&self, elements: &[u32], f: impl Fn([f64; 3]) -> bool) -> Vec<ElementFace> {
|
||||
let mut out = Vec::new();
|
||||
for &e in elements {
|
||||
let el = self.model.element(e).unwrap();
|
||||
for (i, local) in el.kind.faces().iter().enumerate() {
|
||||
let c = [0, 1, 2].map(|d| local.iter().map(|n| self.xyz(el.nodes[*n])[d]).sum::<f64>() / local.len() as f64);
|
||||
if f(c) {
|
||||
out.push(ElementFace { element: e, face: i as u8 + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nodes_where(&self, elements: &[u32], f: impl Fn([f64; 3]) -> bool) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = elements.iter().flat_map(|e| self.model.element(*e).unwrap().nodes.clone()).filter(|n| f(self.xyz(*n))).collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn material(&mut self, name: &str, young: f64, poisson: f64, density: f64) {
|
||||
let m = Material { elastic: Some(Elastic::Isotropic { young, poisson }), density: Some(density), ..Material::default() };
|
||||
self.model.add_material(name, m).unwrap();
|
||||
}
|
||||
|
||||
/// A solid section of one-point hex8 of `material` on `elements`, under the set `name`.
|
||||
fn section(&mut self, name: &str, elements: &[u32], material: &str) {
|
||||
self.model.add_set(name, Set { elements: elements.to_vec(), ..Set::default() }).unwrap();
|
||||
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
|
||||
self.model.add_section(name, Section { set: name.into(), material: material.into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
}
|
||||
|
||||
fn face_set(&mut self, name: &str, faces: Vec<ElementFace>) {
|
||||
assert!(!faces.is_empty(), "{name}");
|
||||
self.model.add_set(name, Set { faces, ..Set::default() }).unwrap();
|
||||
}
|
||||
|
||||
fn node_set(&mut self, name: &str, nodes: Vec<u32>) {
|
||||
assert!(!nodes.is_empty(), "{name}");
|
||||
self.model.add_set(name, Set { nodes, ..Set::default() }).unwrap();
|
||||
}
|
||||
|
||||
fn pair(&mut self, name: &str, main: &str, secondary: &str, friction: Friction) {
|
||||
let c = Contact { friction, ..Contact::new(ContactSurfaces::Pair { main: main.into(), secondary: secondary.into() }) };
|
||||
self.model.add_contact(name, c).unwrap();
|
||||
}
|
||||
|
||||
fn step(&mut self, step: ExplicitDynamic, bcs: &[(&str, BoundaryCondition)], loads: &[(&str, Load)]) {
|
||||
let mut s = Step::new("Run", Procedure::ExplicitDynamic(step));
|
||||
for (name, bc) in bcs {
|
||||
self.model.add_boundary_condition(name, bc.clone()).unwrap();
|
||||
s.boundary_conditions.insert(name.to_string());
|
||||
}
|
||||
for (name, load) in loads {
|
||||
self.model.add_load(name, load.clone()).unwrap();
|
||||
s.loads.insert(name.to_string());
|
||||
}
|
||||
self.model.add_step(s, None).unwrap();
|
||||
}
|
||||
|
||||
fn solve(&self) -> ExplicitResult {
|
||||
let r = solve_explicit(&self.model, Some("Run"), &Limits::default()).unwrap();
|
||||
assert!(r.completed, "{:?}", r.warnings);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
fn node_index(r: &ExplicitResult, label: u32) -> usize {
|
||||
r.nodes.binary_search(&label).unwrap()
|
||||
}
|
||||
|
||||
/// The mean velocity of some nodes in the last frame.
|
||||
fn mean_velocity(r: &ExplicitResult, nodes: &[u32]) -> [f64; 3] {
|
||||
let f = r.frames.last().unwrap();
|
||||
let mut v = [0.0; 3];
|
||||
for n in nodes {
|
||||
let x = f.velocity[node_index(r, *n)];
|
||||
for k in 0..3 {
|
||||
v[k] += x[k] / nodes.len() as f64;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
const BAR_E: f64 = 1.0e4;
|
||||
const BAR_RHO: f64 = 1.0;
|
||||
const BAR_L: f64 = 10.0;
|
||||
const BAR_W: f64 = 0.1;
|
||||
|
||||
/// Two bars of `cells` hex8, the first moving at v₀ along x into the second at rest (touching
|
||||
/// end to end, separate nodes), with `contact` between their ends: (mesh, bar A, bar B).
|
||||
fn bars(cells: usize, v0: f64, end: f64, contact: impl FnOnce(&mut Mesh, Vec<ElementFace>, Vec<ElementFace>)) -> (Mesh, Vec<u32>, Vec<u32>) {
|
||||
let mut m = Mesh::new();
|
||||
let a = m.block([0.0; 3], [BAR_L, BAR_W, BAR_W], [cells, 1, 1]);
|
||||
let b = m.block([BAR_L, 0.0, 0.0], [BAR_L, BAR_W, BAR_W], [cells, 1, 1]);
|
||||
m.material("Mat", BAR_E, 0.0, BAR_RHO);
|
||||
m.section("A", &a, "Mat");
|
||||
m.section("B", &b, "Mat");
|
||||
let end_a = m.faces_where(&a, |c| (c[0] - BAR_L).abs() < 1e-9);
|
||||
let start_b = m.faces_where(&b, |c| (c[0] - BAR_L).abs() < 1e-9);
|
||||
contact(&mut m, end_a, start_b);
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "A".into(), velocity: [v0, 0.0, 0.0] }];
|
||||
// The elastic solution: no linear bulk viscosity (it dissipates at the wave fronts).
|
||||
step.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 1.5 };
|
||||
step.history_interval = Some(end / 4000.0);
|
||||
m.step(step, &[], &[]);
|
||||
(m, a, b)
|
||||
}
|
||||
|
||||
/// Velocity exchange, contact duration 2L/c, interface force ρ c A v₀ / 2 and the energy of a
|
||||
/// frictionless elastic impact, for a pair (both ways) and for general contact.
|
||||
#[test]
|
||||
fn colliding_bars_exchange_their_velocities() {
|
||||
let c = (BAR_E / BAR_RHO).sqrt();
|
||||
let v0 = 0.1;
|
||||
let duration = 2.0 * BAR_L / c;
|
||||
for kind in ["pair", "general"] {
|
||||
let (m, a, b) = bars(200, v0, 1.5 * duration, |m, end_a, start_b| match kind {
|
||||
"pair" => {
|
||||
m.face_set("end_a", end_a);
|
||||
m.face_set("start_b", start_b);
|
||||
m.pair("impact", "start_b", "end_a", Friction::NONE);
|
||||
}
|
||||
_ => m.model.add_contact("everything", Contact::new(ContactSurfaces::General { surface: None })).unwrap(),
|
||||
});
|
||||
let r = m.solve();
|
||||
let s = &r.contacts[0];
|
||||
let (first, last) = (s.first_contact.unwrap(), s.last_contact.unwrap());
|
||||
let plateau = BAR_RHO * c * BAR_W * BAR_W * v0 / 2.0;
|
||||
let va = mean_velocity(&r, &m.nodes_where(&a, |_| true))[0];
|
||||
let vb = mean_velocity(&r, &m.nodes_where(&b, |_| true))[0];
|
||||
let ke = r.energy.kinetic / r.initial_kinetic;
|
||||
// The force across the interface while the waves cross the bars.
|
||||
let mid: Vec<f64> = r
|
||||
.history
|
||||
.time
|
||||
.iter()
|
||||
.zip(&r.history.contact_force)
|
||||
.filter(|(t, _)| **t > 0.2 * duration && **t < 0.8 * duration)
|
||||
.map(|(_, f)| f[0])
|
||||
.collect();
|
||||
let mean_force = mid.iter().sum::<f64>() / mid.len() as f64;
|
||||
eprintln!(
|
||||
"{kind}: contact {first:.5}..{last:.5} ({:.5}) vs 2L/c = {duration:.5}; force {mean_force:.5e} (peak {:.5e}) vs ρcAv₀/2 = {plateau:.5e}; v_A {va:.5} v_B {vb:.5} vs 0, {v0}; KE/KE₀ {ke:.5}; contact energy {:.3e} of KE₀ {:.3e}; balance {:.2e}; max penetration {:.3e}; {} increments",
|
||||
last - first,
|
||||
s.peak_force,
|
||||
r.energy.contact,
|
||||
r.initial_kinetic,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
s.max_penetration,
|
||||
r.increments
|
||||
);
|
||||
assert!(first < 0.01 * duration, "{kind}: contact from the start, not {first}");
|
||||
assert!(((last - first) - duration).abs() < 0.015 * duration, "{kind}: contact for {} vs {duration}", last - first);
|
||||
assert!((mean_force - plateau).abs() < 0.02 * plateau, "{kind}: {mean_force} vs {plateau}");
|
||||
assert!(va.abs() < 0.02 * v0 && (vb - v0).abs() < 0.02 * v0, "{kind}: {va} {vb}");
|
||||
assert!(ke > 0.97 && ke <= 1.0 + 1e-6, "{kind}: KE ratio {ke}");
|
||||
// After the separation the contact springs are empty again: what they took they gave back.
|
||||
assert!(r.energy.contact.abs() < 0.005 * r.initial_kinetic, "{kind}: {:?}", r.energy);
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.005, "{kind}: {:?}", r.energy);
|
||||
assert!(s.max_penetration < 0.01 * BAR_L / 200.0 * 10.0, "{kind}: penetration {}", s.max_penetration);
|
||||
assert_eq!(r.energy.friction, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body in free flight with general contact on all its faces feels no contact force (no node
|
||||
/// grabs a face of its own body), and its motion is that of the same run without contact.
|
||||
#[test]
|
||||
fn general_contact_leaves_a_free_body_alone() {
|
||||
let run = |contact: bool| {
|
||||
let mut m = Mesh::new();
|
||||
let e = m.block([0.0; 3], [1.0, 0.6, 0.4], [5, 3, 2]);
|
||||
m.material("Mat", 1e4, 0.3, 1.0);
|
||||
m.section("all", &e, "Mat");
|
||||
if contact {
|
||||
m.model.add_contact("all", Contact::new(ContactSurfaces::General { surface: None })).unwrap();
|
||||
}
|
||||
let mut step = ExplicitDynamic::new(0.05);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "all".into(), velocity: [1.0, -0.5, 0.2] }];
|
||||
m.step(step, &[], &[]);
|
||||
m.solve()
|
||||
};
|
||||
let (with, without) = (run(true), run(false));
|
||||
assert!(with.history.contact_force.iter().all(|f| f[0] == 0.0));
|
||||
assert_eq!(with.contacts[0].first_contact, None);
|
||||
assert_eq!(with.frames.last().unwrap().displacement, without.frames.last().unwrap().displacement);
|
||||
assert_eq!(with.energy.contact, 0.0);
|
||||
}
|
||||
|
||||
/// What kind of plane a block slides on.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Plane {
|
||||
RigidWall,
|
||||
/// A plate fixed at its base, in contact with the block.
|
||||
Plate,
|
||||
}
|
||||
|
||||
/// A block (1 × 1 × 0.5, 4 × 4 × 2 hex8) launched along x at v₀ on a horizontal plane under
|
||||
/// gravity g with `friction`: the distance its centre of mass travels and the time it stops.
|
||||
fn slide(plane: Plane, friction: Friction, v0: f64, g: f64, end: f64) -> (f64, f64, ExplicitResult) {
|
||||
let mut m = Mesh::new();
|
||||
let block = m.block([0.0, 0.0, 0.0], [1.0, 1.0, 0.5], [4, 4, 2]);
|
||||
m.material("Mat", 1.0e5, 0.3, 1.0);
|
||||
m.section("block", &block, "Mat");
|
||||
let bottom = m.nodes_where(&block, |x| x[2].abs() < 1e-9);
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "block".into(), velocity: [v0, 0.0, 0.0] }];
|
||||
step.history_interval = Some(end / 2000.0);
|
||||
let mut bcs = Vec::new();
|
||||
match plane {
|
||||
Plane::RigidWall => {
|
||||
m.node_set("bottom", bottom);
|
||||
step.rigid_walls = vec![RigidWall { set: "bottom".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0], friction }];
|
||||
}
|
||||
Plane::Plate => {
|
||||
let plate = m.block([-0.5, -0.5, -0.5], [4.5, 2.0, 0.5], [9, 4, 1]);
|
||||
m.section("plate", &plate, "Mat");
|
||||
let top = m.faces_where(&plate, |c| c[2].abs() < 1e-9);
|
||||
let under = m.faces_where(&block, |c| c[2].abs() < 1e-9);
|
||||
m.face_set("top", top);
|
||||
m.face_set("under", under);
|
||||
m.pair("slide", "top", "under", friction);
|
||||
m.node_set("base", m.nodes_where(&plate, |x| (x[2] + 0.5).abs() < 1e-9));
|
||||
bcs.push(("hold", BoundaryCondition::Fixed { target: Target::Set("base".into()) }));
|
||||
}
|
||||
}
|
||||
let gravity = Load::Gravity { acceleration: [0.0, 0.0, -g], set: Some("block".into()) };
|
||||
m.step(step, &bcs, &[("weight", gravity)]);
|
||||
let r = m.solve();
|
||||
let nodes = m.nodes_where(&block, |_| true);
|
||||
let f = r.frames.last().unwrap();
|
||||
let distance = nodes.iter().map(|n| f.displacement[node_index(&r, *n)][0]).sum::<f64>() / nodes.len() as f64;
|
||||
// The block's momentum per mass (the plate's is nothing along x when it is held).
|
||||
let mass = match plane {
|
||||
Plane::RigidWall => r.total_mass,
|
||||
Plane::Plate => 0.5,
|
||||
};
|
||||
let stop = r.history.time.iter().zip(&r.history.momentum).find(|(_, p)| p[0] / mass < 0.005 * v0).map_or(f64::NAN, |(t, _)| *t);
|
||||
(distance, stop, r)
|
||||
}
|
||||
|
||||
/// Coulomb friction μ: deceleration μg, stopping after v₀/(μg) at v₀²/(2μg); on a rigid wall
|
||||
/// (kinematic friction impulses) and on a deformable plate (penalty contact, return map).
|
||||
#[test]
|
||||
fn a_block_slides_to_a_stop_under_coulomb_friction() {
|
||||
let (v0, g, mu) = (3.0, 10.0, 0.3);
|
||||
let (t_stop, d_stop) = (v0 / (mu * g), v0 * v0 / (2.0 * mu * g));
|
||||
for plane in [Plane::RigidWall, Plane::Plate] {
|
||||
let (d, t, r) = slide(plane, Friction::coulomb(mu), v0, g, 1.3 * t_stop);
|
||||
eprintln!(
|
||||
"{plane:?}: stops at {t:.4} vs v₀/(μg) = {t_stop:.4}, after {d:.4} vs v₀²/(2μg) = {d_stop:.4}; friction dissipated {:.4} vs ½mv₀² = {:.4}; balance {:.2e}; {} increments",
|
||||
r.energy.friction,
|
||||
0.5 * 0.5 * v0 * v0,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.increments
|
||||
);
|
||||
assert!((t - t_stop).abs() < 0.02 * t_stop, "{plane:?}: {t} vs {t_stop}");
|
||||
assert!((d - d_stop).abs() < 0.02 * d_stop, "{plane:?}: {d} vs {d_stop}");
|
||||
// The kinetic energy went into friction (and a little into the bouncing on the plane).
|
||||
let ke0 = 0.5 * 0.5 * v0 * v0;
|
||||
assert!((r.energy.friction - ke0).abs() < 0.03 * ke0, "{plane:?}: {:?}", r.energy);
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.01, "{plane:?}: {:?}", r.energy);
|
||||
}
|
||||
}
|
||||
|
||||
/// The exponential decay law μ(v) = μ_k + (μ_s − μ_k) e^{−d v}: the block decelerates at
|
||||
/// g μ(v), so it stops after t = ∫ dv / (g μ(v)) = [v₀ + ln((μ_k + Δμ e^{−d v₀}) / μ_s) / d] / (g μ_k)
|
||||
/// and travels ∫ v dv / (g μ(v)) (by quadrature).
|
||||
#[test]
|
||||
fn a_block_slides_with_a_decaying_friction_coefficient() {
|
||||
let (v0, g) = (3.0, 10.0);
|
||||
let f = Friction { static_coefficient: 0.5, kinetic: Some(0.2), decay: 1.5 };
|
||||
let (a, b, d): (f64, f64, f64) = (0.2, 0.3, 1.5);
|
||||
let t_stop = (v0 + ((a + b * (-d * v0).exp()) / (a + b)).ln() / d) / (g * a);
|
||||
let n = 20_000;
|
||||
let d_stop: f64 = (0..n)
|
||||
.map(|i| {
|
||||
let v = (i as f64 + 0.5) * v0 / n as f64;
|
||||
v / (g * f.coefficient(v)) * v0 / n as f64
|
||||
})
|
||||
.sum();
|
||||
for plane in [Plane::RigidWall, Plane::Plate] {
|
||||
let (dist, t, _) = slide(plane, f, v0, g, 1.3 * t_stop);
|
||||
eprintln!("{plane:?}, decaying μ: stops at {t:.4} vs {t_stop:.4}, after {dist:.4} vs {d_stop:.4}");
|
||||
assert!((t - t_stop).abs() < 0.03 * t_stop, "{plane:?}: {t} vs {t_stop}");
|
||||
assert!((dist - d_stop).abs() < 0.03 * d_stop, "{plane:?}: {dist} vs {d_stop}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two blocks with non-matching meshes (3 × 3 below, 2 × 2 above) pressed together slowly
|
||||
/// between symmetry planes: the stress is uniform σ_zz on both sides of the interface and the
|
||||
/// force across it is σ_zz A (a contact patch test in uniaxial stress).
|
||||
#[test]
|
||||
fn a_contact_patch_test_carries_a_uniform_stress_across_non_matching_meshes() {
|
||||
let mut m = Mesh::new();
|
||||
let lower = m.block([0.0; 3], [1.0, 1.0, 1.0], [3, 3, 2]);
|
||||
let upper = m.block([0.0, 0.0, 1.0], [1.0, 1.0, 1.0], [2, 2, 2]);
|
||||
let (young, rho) = (1000.0, 1.0);
|
||||
m.material("Mat", young, 0.3, rho);
|
||||
m.section("lower", &lower, "Mat");
|
||||
m.section("upper", &upper, "Mat");
|
||||
let both: Vec<u32> = lower.iter().chain(&upper).copied().collect();
|
||||
m.face_set("lower_top", m.faces_where(&lower, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.face_set("upper_bottom", m.faces_where(&upper, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.pair("interface", "lower_top", "upper_bottom", Friction::NONE);
|
||||
m.node_set("x0", m.nodes_where(&both, |x| x[0].abs() < 1e-9));
|
||||
m.node_set("y0", m.nodes_where(&both, |x| x[1].abs() < 1e-9));
|
||||
m.node_set("z0", m.nodes_where(&both, |x| x[2].abs() < 1e-9));
|
||||
m.node_set("top", m.nodes_where(&both, |x| (x[2] - 2.0).abs() < 1e-9));
|
||||
let squeeze = 0.01;
|
||||
let held = |set: &str, k: usize| {
|
||||
let mut values = [None; 6];
|
||||
values[k] = Some(0.0);
|
||||
BoundaryCondition::Displacement { target: Target::Set(set.into()), values }
|
||||
};
|
||||
let mut push_values = [None; 6];
|
||||
push_values[2] = Some(-squeeze);
|
||||
let push = BoundaryCondition::Displacement { target: Target::Set("top".into()), values: push_values };
|
||||
// A hundred wave transits: quasi-static.
|
||||
let c = (young * 0.7 / (1.3 * 0.4) / rho).sqrt();
|
||||
let end = 100.0 * 2.0 / c;
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.output_interval = Some(end / 4.0);
|
||||
m.step(step, &[("x0", held("x0", 0)), ("y0", held("y0", 1)), ("z0", held("z0", 2)), ("push", push)], &[]);
|
||||
let r = m.solve();
|
||||
let f = r.frames.last().unwrap();
|
||||
let szz: Vec<f64> = f.stress.iter().map(|s| s[2]).collect();
|
||||
let mean = szz.iter().sum::<f64>() / szz.len() as f64;
|
||||
let spread = szz.iter().map(|s| (s - mean).abs()).fold(0.0, f64::max);
|
||||
let lateral = f.stress.iter().flat_map(|s| [s[0], s[1], s[3], s[4], s[5]]).map(f64::abs).fold(0.0, f64::max);
|
||||
let force = *r.history.contact_force.last().unwrap().first().unwrap();
|
||||
eprintln!(
|
||||
"patch: σ_zz mean {mean:.5} (E ε = {:.5}), spread {spread:.3e}, largest other component {lateral:.3e}; force across {force:.5} vs |σ_zz| A = {:.5}",
|
||||
-young * squeeze / 2.0,
|
||||
mean.abs()
|
||||
);
|
||||
assert!(spread < 0.02 * mean.abs(), "σ_zz {szz:?}");
|
||||
assert!(lateral < 0.02 * mean.abs(), "{lateral}");
|
||||
assert!((force - mean.abs()).abs() < 0.02 * mean.abs(), "{force} vs {mean}");
|
||||
// The interface's penalty takes some of the squeeze (uniformly): each block is strained by
|
||||
// what is left.
|
||||
let closure = r.contacts[0].max_penetration;
|
||||
let expected = young * (squeeze - closure) / 2.0;
|
||||
eprintln!("patch: interface closure {closure:.4e}, σ_zz vs E (squeeze − closure) / 2 = {expected:.5}");
|
||||
assert!((mean.abs() - expected).abs() < 0.01 * expected, "{mean} vs {expected}");
|
||||
}
|
||||
|
||||
/// The same contact run gives the same numbers, bit for bit, on one thread and on four.
|
||||
#[test]
|
||||
fn contact_results_do_not_depend_on_the_thread_count() {
|
||||
let c = (BAR_E / BAR_RHO).sqrt();
|
||||
let (m, _, _) = bars(60, 0.1, 2.0 * BAR_L / c, |m, _, _| {
|
||||
m.model.add_contact("all", Contact { friction: Friction::coulomb(0.2), ..Contact::new(ContactSurfaces::General { surface: None }) }).unwrap()
|
||||
});
|
||||
let one = fieldcraft_parallel::with_threads(1, || solve_explicit(&m.model, None, &Limits::default()).unwrap());
|
||||
let four = fieldcraft_parallel::with_threads(4, || solve_explicit(&m.model, None, &Limits::default()).unwrap());
|
||||
assert_eq!(one.frames, four.frames);
|
||||
assert_eq!(one.history, four.history);
|
||||
assert_eq!(one.energy, four.energy);
|
||||
assert_eq!(one.contacts, four.contacts);
|
||||
}
|
||||
|
||||
/// Nodes that start inside the other surface are moved onto it and reported, or refused with
|
||||
/// the node, the face and the depth; a cap on contact faces is honoured; contact on the faces
|
||||
/// of tetrahedra works too.
|
||||
#[test]
|
||||
fn initial_penetration_caps_and_tetrahedra() {
|
||||
let build = |mode: InitialPenetration, overlap: f64| {
|
||||
let mut m = Mesh::new();
|
||||
let a = m.block([0.0; 3], [1.0, 1.0, 1.0], [2, 2, 2]);
|
||||
let b = m.block([0.0, 0.0, 1.0 - overlap], [1.0, 1.0, 1.0], [3, 3, 2]);
|
||||
m.material("Mat", 1000.0, 0.3, 1.0);
|
||||
m.section("a", &a, "Mat");
|
||||
m.section("b", &b, "Mat");
|
||||
m.face_set("a_top", m.faces_where(&a, |c| (c[2] - 1.0).abs() < 1e-9));
|
||||
m.face_set("b_bottom", m.faces_where(&b, |c| (c[2] - 1.0 + overlap).abs() < 1e-9));
|
||||
let c = Contact { initial_penetration: mode, ..Contact::new(ContactSurfaces::Pair { main: "a_top".into(), secondary: "b_bottom".into() }) };
|
||||
m.model.add_contact("stack", c).unwrap();
|
||||
m.step(ExplicitDynamic::new(0.01), &[], &[]);
|
||||
m
|
||||
};
|
||||
let r = build(InitialPenetration::Adjust, 0.01).solve();
|
||||
let s = &r.contacts[0];
|
||||
// Both passes: b's 16 bottom nodes into a, and a's 9 top nodes into b (they are inside b
|
||||
// after b's nodes moved down? no: b's nodes are moved onto a's top first, then a's top nodes
|
||||
// are on b's bottom).
|
||||
assert!(!s.adjusted.is_empty(), "{s:?}");
|
||||
assert!(s.adjusted.iter().all(|a| (a.depth - 0.01).abs() < 1e-9), "{:?}", s.adjusted);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("contact `stack`") && w.contains("moved onto it")), "{:?}", r.warnings);
|
||||
// After the adjustment nothing is pushed apart: no contact force worth the name.
|
||||
assert!(s.peak_force < 1e-6, "{s:?}");
|
||||
let e = solve_explicit(&build(InitialPenetration::Refuse, 0.01).model, None, &Limits::default()).unwrap_err().to_string();
|
||||
assert!(e.contains("contact `stack`") && e.contains("starts 1.0000e-2 inside face 2 of element"), "{e}");
|
||||
// Touching exactly is not a penetration.
|
||||
assert!(build(InitialPenetration::Refuse, 0.0).solve().contacts[0].adjusted.is_empty());
|
||||
let capped = Limits { max_contact_faces: 10, ..Limits::default() };
|
||||
let e = solve_explicit(&build(InitialPenetration::Adjust, 0.0).model, None, &capped).unwrap_err().to_string();
|
||||
assert!(e.contains("limit") && e.contains("contact `stack`"), "{e}");
|
||||
}
|
||||
|
||||
/// Interval ends on [0, 1] in `n` intervals growing from the first by `ratio`.
|
||||
fn graded(n: usize, ratio: f64) -> Vec<f64> {
|
||||
let widths: Vec<f64> = (0..n).map(|k| ratio.powi(k as i32)).collect();
|
||||
let total: f64 = widths.iter().sum();
|
||||
let mut out = vec![0.0];
|
||||
let mut at = 0.0;
|
||||
for w in widths {
|
||||
at += w / total;
|
||||
out.push(at);
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A quarter (x ≥ 0, y ≥ 0) of a ball of radius `radius` about `center`, meshed as a cubed sphere
|
||||
/// of hex8: an inner cube of half-size 0.4 R with `n` uniform elements per edge, and six blocks
|
||||
/// from its faces to the sphere with `n` × `n` elements across (equiangular, clustered towards
|
||||
/// each face's centre by `lateral`) and `layers` elements through (thinner towards the surface
|
||||
/// by `radial`). Its elements and the faces on the sphere within 30° of the pole below (−z) or
|
||||
/// above (+z) the centre (`towards`).
|
||||
fn quarter_ball(
|
||||
m: &mut Mesh,
|
||||
center: [f64; 3],
|
||||
radius: f64,
|
||||
n: usize,
|
||||
layers: usize,
|
||||
lateral: f64,
|
||||
radial: f64,
|
||||
towards: f64,
|
||||
) -> (Vec<u32>, Vec<ElementFace>) {
|
||||
use std::collections::BTreeMap;
|
||||
assert!(n.is_multiple_of(2));
|
||||
let inner = 0.4 * radius;
|
||||
// Clustered lateral parameter on [−1, 1]: symmetric, finest at 0.
|
||||
let half = graded(n / 2, lateral);
|
||||
let mut clustered: Vec<f64> = half.iter().rev().map(|s| -s).collect();
|
||||
clustered.extend(half.iter().skip(1));
|
||||
let uniform: Vec<f64> = (0..=n).map(|i| -1.0 + 2.0 * i as f64 / n as f64).collect();
|
||||
// Radial blend from the cube (0) to the sphere (1), finest at the sphere.
|
||||
let through: Vec<f64> = graded(layers, radial).iter().rev().map(|s| 1.0 - s).collect();
|
||||
let mut labels: BTreeMap<[i64; 3], u32> = BTreeMap::new();
|
||||
let quantum = 1e-9 * radius;
|
||||
let mut node = |m: &mut Mesh, x: [f64; 3]| -> u32 {
|
||||
let key = x.map(|c| (c / quantum).round() as i64);
|
||||
*labels.entry(key).or_insert_with(|| {
|
||||
let l = m.next_node;
|
||||
m.model.set_node(l, [center[0] + x[0], center[1] + x[1], center[2] + x[2]]).unwrap();
|
||||
m.next_node += 1;
|
||||
l
|
||||
})
|
||||
};
|
||||
let kept = |x: &[[f64; 3]]| {
|
||||
let c = [0, 1].map(|d| x.iter().map(|p| p[d]).sum::<f64>() / x.len() as f64);
|
||||
c[0] > 0.0 && c[1] > 0.0
|
||||
};
|
||||
let mut hexes: Vec<[[f64; 3]; 8]> = Vec::new();
|
||||
// The inner cube.
|
||||
let cube = |i: usize, j: usize, k: usize| [uniform[i], uniform[j], uniform[k]].map(|s| inner * s);
|
||||
for k in 0..n {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
hexes.push([
|
||||
cube(i, j, k),
|
||||
cube(i + 1, j, k),
|
||||
cube(i + 1, j + 1, k),
|
||||
cube(i, j + 1, k),
|
||||
cube(i, j, k + 1),
|
||||
cube(i + 1, j, k + 1),
|
||||
cube(i + 1, j + 1, k + 1),
|
||||
cube(i, j + 1, k + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The six blocks: face axis a, sign σ; (u, v) along the other two axes.
|
||||
for axis in 0..3 {
|
||||
for sign in [-1.0, 1.0] {
|
||||
let place = |u: f64, v: f64, w: f64| -> [f64; 3] {
|
||||
let mut p = [0.0; 3];
|
||||
p[axis] = w;
|
||||
p[(axis + 1) % 3] = u;
|
||||
p[(axis + 2) % 3] = v;
|
||||
p
|
||||
};
|
||||
let point = |i: usize, j: usize, k: usize| -> [f64; 3] {
|
||||
let t = through[k];
|
||||
let p_in = place(uniform[i], uniform[j], sign).map(|c| inner * c);
|
||||
let angle = |s: f64| (s * std::f64::consts::FRAC_PI_4).tan();
|
||||
let d = place(angle(clustered[i]), angle(clustered[j]), sign);
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let p_out = d.map(|c| radius * c / len);
|
||||
[0, 1, 2].map(|c| (1.0 - t) * p_in[c] + t * p_out[c])
|
||||
};
|
||||
for k in 0..layers {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
hexes.push([
|
||||
point(i, j, k),
|
||||
point(i + 1, j, k),
|
||||
point(i + 1, j + 1, k),
|
||||
point(i, j + 1, k),
|
||||
point(i, j, k + 1),
|
||||
point(i + 1, j, k + 1),
|
||||
point(i + 1, j + 1, k + 1),
|
||||
point(i, j + 1, k + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut elements = Vec::new();
|
||||
for mut h in hexes.into_iter().filter(|h| kept(h)) {
|
||||
// Positive volume: the bottom face counter-clockwise seen from the top.
|
||||
let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b, c) = (sub(h[1], h[0]), sub(h[3], h[0]), sub(h[4], h[0]));
|
||||
let det = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
|
||||
if det < 0.0 {
|
||||
h = [h[4], h[5], h[6], h[7], h[0], h[1], h[2], h[3]];
|
||||
}
|
||||
let nodes: Vec<u32> = h.iter().map(|x| node(m, *x)).collect();
|
||||
m.model.set_element(m.next_element, ElementKind::Hex8, nodes).unwrap();
|
||||
elements.push(m.next_element);
|
||||
m.next_element += 1;
|
||||
}
|
||||
let pole = [0.0, 0.0, towards];
|
||||
let exterior = m.model.exterior_faces();
|
||||
let faces: Vec<ElementFace> = m
|
||||
.faces_where(&elements, |c| {
|
||||
let r = [c[0] - center[0], c[1] - center[1], c[2] - center[2]];
|
||||
let len = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
|
||||
len > 0.9 * radius && (r[0] * pole[0] + r[1] * pole[1] + r[2] * pole[2]) / len > (std::f64::consts::PI / 6.0).cos()
|
||||
})
|
||||
.into_iter()
|
||||
.filter(|f| exterior.binary_search(f).is_ok())
|
||||
.collect();
|
||||
(elements, faces)
|
||||
}
|
||||
|
||||
/// Hertz's impact of two identical elastic spheres (radius R, mass m, E, ν) at relative speed v:
|
||||
/// with R* = R/2, m* = m/2, E* = E / (2(1 − ν²)), the approach reaches
|
||||
/// δ = (15 m* v² / (16 E* √R*))^{2/5}, the force F = (4/3) E* √R* δ^{3/2}, and the contact lasts
|
||||
/// 2.9432 δ / v (K. L. Johnson, *Contact Mechanics*, Cambridge 1985, §11.4).
|
||||
fn hertz(radius: f64, young: f64, poisson: f64, density: f64, v: f64) -> (f64, f64, f64) {
|
||||
let mass = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3) * density;
|
||||
let (r_eff, m_eff, e_eff) = (radius / 2.0, mass / 2.0, young / (2.0 * (1.0 - poisson * poisson)));
|
||||
let delta = (15.0 * m_eff * v * v / (16.0 * e_eff * r_eff.sqrt())).powf(0.4);
|
||||
(delta, 4.0 / 3.0 * e_eff * r_eff.sqrt() * delta.powf(1.5), 2.9432 * delta / v)
|
||||
}
|
||||
|
||||
/// Two spheres in a head-on collision (a quarter of each, between the symmetry planes x = 0 and
|
||||
/// y = 0), with a contact pair between their facing caps: the peak force and the contact
|
||||
/// duration against Hertz. `n` and `layers` set the mesh; `a_over_r` the contact radius the speed
|
||||
/// is chosen for.
|
||||
fn sphere_impact(n: usize, layers: usize, lateral: f64, radial: f64, a_over_r: f64) -> (f64, f64, f64, f64, ExplicitResult) {
|
||||
let (radius, young, poisson, density) = (1.0, 1.0, 0.3, 1.0);
|
||||
// The speed that gives a contact radius a = √(R* δ).
|
||||
let delta = a_over_r * a_over_r * radius * radius / (radius / 2.0);
|
||||
let mass = 4.0 / 3.0 * std::f64::consts::PI * density;
|
||||
let e_eff = young / (2.0 * (1.0 - poisson * poisson));
|
||||
let v = (delta.powf(2.5) * 16.0 * e_eff * (radius / 2.0f64).sqrt() / (15.0 * mass / 2.0)).sqrt();
|
||||
let (delta, force, duration) = hertz(radius, young, poisson, density, v);
|
||||
let mut m = Mesh::new();
|
||||
let (upper, upper_cap) = quarter_ball(&mut m, [0.0, 0.0, radius], radius, n, layers, lateral, radial, -1.0);
|
||||
let (lower, lower_cap) = quarter_ball(&mut m, [0.0, 0.0, -radius], radius, n, layers, lateral, radial, 1.0);
|
||||
m.material("Mat", young, poisson, density);
|
||||
m.section("upper", &upper, "Mat");
|
||||
m.section("lower", &lower, "Mat");
|
||||
m.face_set("upper_cap", upper_cap);
|
||||
m.face_set("lower_cap", lower_cap);
|
||||
m.pair("impact", "lower_cap", "upper_cap", Friction::NONE);
|
||||
let both: Vec<u32> = upper.iter().chain(&lower).copied().collect();
|
||||
m.node_set("x0", m.nodes_where(&both, |x| x[0].abs() < 1e-9));
|
||||
m.node_set("y0", m.nodes_where(&both, |x| x[1].abs() < 1e-9));
|
||||
let held = |set: &str, k: usize| {
|
||||
let mut values = [None; 6];
|
||||
values[k] = Some(0.0);
|
||||
BoundaryCondition::Displacement { target: Target::Set(set.into()), values }
|
||||
};
|
||||
let mut step = ExplicitDynamic::new(1.15 * duration);
|
||||
step.initial_velocities = vec![
|
||||
InitialVelocity { set: "upper".into(), velocity: [0.0, 0.0, -v / 2.0] },
|
||||
InitialVelocity { set: "lower".into(), velocity: [0.0, 0.0, v / 2.0] },
|
||||
];
|
||||
step.bulk_viscosity = BulkViscosity { linear: 0.0, quadratic: 1.5 };
|
||||
step.history_interval = Some(duration / 2000.0);
|
||||
step.output_interval = Some(1.15 * duration);
|
||||
m.step(step, &[("x0", held("x0", 0)), ("y0", held("y0", 1))], &[]);
|
||||
let r = m.solve();
|
||||
let s = &r.contacts[0];
|
||||
// A quarter of each sphere carries a quarter of the force.
|
||||
let peak = 4.0 * s.peak_force;
|
||||
let lasted = s.last_contact.unwrap_or(0.0) - s.first_contact.unwrap_or(0.0);
|
||||
eprintln!(
|
||||
"Hertz ({} elements, a/R = {a_over_r}): v = {v:.4e}, δ = {delta:.4e}; peak force {peak:.5e} vs {force:.5e} ({:+.2}%), duration {lasted:.4} vs {duration:.4} ({:+.2}%); max penetration {:.2e}; KE after/before {:.4}; balance {:.2e}; {} increments",
|
||||
both.len(),
|
||||
100.0 * (peak / force - 1.0),
|
||||
100.0 * (lasted / duration - 1.0),
|
||||
s.max_penetration,
|
||||
r.energy.kinetic / r.initial_kinetic,
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.increments
|
||||
);
|
||||
(peak, force, lasted, duration, r)
|
||||
}
|
||||
|
||||
/// A coarse sphere impact in the main gate: Hertz's force and duration to a few percent.
|
||||
#[test]
|
||||
fn spheres_collide_as_hertz_says_coarse() {
|
||||
let (peak, force, lasted, duration, _) = sphere_impact(12, 6, 1.25, 1.25, 0.15);
|
||||
assert!((peak - force).abs() < 0.1 * force, "{peak} vs {force}");
|
||||
assert!((lasted - duration).abs() < 0.1 * duration, "{lasted} vs {duration}");
|
||||
}
|
||||
|
||||
/// The fine sphere impact: contact radius a tenth of the radius, about six elements across it.
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn spheres_collide_as_hertz_says() {
|
||||
let (peak, force, lasted, duration, r) = sphere_impact(24, 12, 1.23, 1.185, 0.1);
|
||||
assert!((peak - force).abs() < 0.03 * force, "{peak} vs {force}");
|
||||
assert!((lasted - duration).abs() < 0.03 * duration, "{lasted} vs {duration}");
|
||||
assert!(r.energy.kinetic > 0.95 * r.initial_kinetic);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Hyperelastic materials in explicit dynamics: the stress of the shared finite-strain material
|
||||
//! point (neo-Hookean, Mooney–Rivlin, Yeoh) from the deformation gradient, on one-point hex8 and
|
||||
//! F-bar tet4, against the closed form of uniaxial strain reached quasi-statically, and the
|
||||
//! refusals.
|
||||
//!
|
||||
//! In uniaxial strain F = diag(λ, 1, 1): the hyperelastic Cauchy stress is the material point's
|
||||
//! Kirchhoff stress over J = λ (fieldcraft_fem::constitutive, whose laws are checked there
|
||||
//! against their strain energies), so the explicit elements must reproduce it once the motion has
|
||||
//! settled; the wave dynamics of a slow pull leave a fraction of a percent.
|
||||
|
||||
use fieldcraft_explicit::{ExplicitResult, Limits, solve_explicit};
|
||||
use fieldcraft_fem::constitutive::MaterialModel;
|
||||
use fieldcraft_model::*;
|
||||
|
||||
const N: usize = 2;
|
||||
|
||||
/// A unit cube of N³ hex8 (or six tet4 per cell), held on its faces x = 0, y = 0, y = 1, z = 0,
|
||||
/// z = 1 in their normal directions and pulled on x = 1 to a stretch λ over `end`.
|
||||
fn block(tet: bool, hyper: Hyperelastic, stretch: f64, end: f64, nonlinear: bool) -> Model {
|
||||
let mut m = Model::default();
|
||||
let h = 1.0 / N as f64;
|
||||
let label = |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 {
|
||||
m.set_node(label(i, j, k), [i as f64 * h, j as f64 * h, k as f64 * h]).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
|
||||
let mut next = 1;
|
||||
for k in 0..N {
|
||||
for j in 0..N {
|
||||
for i in 0..N {
|
||||
let at = |c: [usize; 3]| label(i + c[0], j + c[1], k + c[2]);
|
||||
if !tet {
|
||||
m.set_element(next, ElementKind::Hex8, corners.iter().map(|c| at(*c)).collect()).unwrap();
|
||||
next += 1;
|
||||
continue;
|
||||
}
|
||||
for perm in [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] {
|
||||
let mut v = [[0usize; 3]; 4];
|
||||
for s in 0..3 {
|
||||
v[s + 1] = v[s];
|
||||
v[s + 1][perm[s]] = 1;
|
||||
}
|
||||
let e = |a: usize| [0, 1, 2].map(|d| v[a][d] as f64 - v[0][d] as f64);
|
||||
let (a, b, c) = (e(1), e(2), e(3));
|
||||
let vol = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]);
|
||||
if vol < 0.0 {
|
||||
v.swap(1, 2);
|
||||
}
|
||||
m.set_element(next, ElementKind::Tet4, v.iter().map(|c| at(*c)).collect()).unwrap();
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let all: Vec<u32> = m.elements().keys().copied().collect();
|
||||
m.add_set("all", Set { elements: all, ..Set::default() }).unwrap();
|
||||
let nodes = |f: &dyn Fn([f64; 3]) -> bool| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| f(n.xyz)).map(|(l, _)| *l).collect() };
|
||||
let sets = [
|
||||
("x0", nodes(&|x| x[0] == 0.0)),
|
||||
("x1", nodes(&|x| x[0] == 1.0)),
|
||||
("y", nodes(&|x| x[1] == 0.0 || x[1] == 1.0)),
|
||||
("z", nodes(&|x| x[2] == 0.0 || x[2] == 1.0)),
|
||||
];
|
||||
for (name, list) in sets {
|
||||
m.add_set(name, Set { nodes: list, ..Set::default() }).unwrap();
|
||||
}
|
||||
let elastic = Elastic::Isotropic { young: 1.0, poisson: 0.45 };
|
||||
m.add_material("Rubber", Material { elastic: Some(elastic), density: Some(1.0), hyperelastic: Some(hyper), ..Material::default() }).unwrap();
|
||||
let integration = if tet { Integration::Full } else { Integration::Reduced };
|
||||
let formulation = Formulation { integration, ..Formulation::default() };
|
||||
m.add_section("all", Section { set: "all".into(), material: "Rubber".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
let held = |set: &str, k: usize, v: f64| {
|
||||
let mut values = [None; 6];
|
||||
values[k] = Some(v);
|
||||
BoundaryCondition::Displacement { target: Target::Set(set.into()), values }
|
||||
};
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.nonlinear_geometry = nonlinear;
|
||||
step.output_interval = Some(end / 2.0);
|
||||
let mut s = Step::new("Pull", Procedure::ExplicitDynamic(step));
|
||||
for (name, bc) in [("x0", held("x0", 0, 0.0)), ("x1", held("x1", 0, stretch - 1.0)), ("y", held("y", 1, 0.0)), ("z", held("z", 2, 0.0))] {
|
||||
m.add_boundary_condition(name, bc).unwrap();
|
||||
s.boundary_conditions.insert(name.into());
|
||||
}
|
||||
m.add_step(s, None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// σ of uniaxial strain λ from the material point.
|
||||
fn closed_form(hyper: Hyperelastic, stretch: f64) -> [f64; 6] {
|
||||
let model = MaterialModel::from_model(&Elastic::Isotropic { young: 1.0, poisson: 0.45 }, Some(&hyper)).unwrap();
|
||||
let f = [[stretch, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
model.response(&f, None, &[], &mut []).unwrap().stress.map(|t| t / stretch)
|
||||
}
|
||||
|
||||
fn mean_stress(r: &ExplicitResult) -> [f64; 6] {
|
||||
let f = r.frames.last().unwrap();
|
||||
let mut s = [0.0; 6];
|
||||
for e in &f.stress {
|
||||
for k in 0..6 {
|
||||
s[k] += e[k] / f.stress.len() as f64;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperelastic_uniaxial_strain_matches_the_material_point() {
|
||||
let stretch = 1.5;
|
||||
// Dilatational wave speed √((κ + 4μ/3)/ρ) ≈ 2.0: a pull over 400 transits of the cube.
|
||||
let end = 200.0;
|
||||
let materials = [Hyperelastic::NeoHookean {}, Hyperelastic::MooneyRivlin { c01: 0.08 }, Hyperelastic::Yeoh { c20: -0.01, c30: 0.002 }];
|
||||
for hyper in materials {
|
||||
let reference = closed_form(hyper, stretch);
|
||||
for tet in [false, true] {
|
||||
let r = solve_explicit(&block(tet, hyper, stretch, end, true), None, &Limits::default()).unwrap();
|
||||
assert!(r.completed);
|
||||
let s = mean_stress(&r);
|
||||
let spread = r.frames.last().unwrap().stress.iter().map(|e| (e[0] - s[0]).abs()).fold(0.0, f64::max);
|
||||
eprintln!(
|
||||
"{} {}: σ_xx {:.5} vs {:.5}, σ_yy {:.5} vs {:.5}; spread {spread:.2e}; balance {:.2e}; {} increments",
|
||||
hyper.name(),
|
||||
if tet { "tet4" } else { "hex8" },
|
||||
s[0],
|
||||
reference[0],
|
||||
s[1],
|
||||
reference[1],
|
||||
r.energy.relative_error(r.initial_kinetic),
|
||||
r.increments
|
||||
);
|
||||
for k in 0..3 {
|
||||
assert!((s[k] - reference[k]).abs() < 0.01 * reference[0].abs(), "{} tet {tet}: {s:?} vs {reference:?}", hyper.name());
|
||||
}
|
||||
assert!(spread < 0.01 * reference[0].abs(), "uniform state: {spread}");
|
||||
assert!(r.energy.relative_error(r.initial_kinetic) < 0.01, "{:?}", r.energy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperelastic_materials_need_nonlinear_geometry() {
|
||||
let e = solve_explicit(&block(false, Hyperelastic::NeoHookean {}, 1.1, 1.0, false), None, &Limits::default()).unwrap_err().to_string();
|
||||
assert!(e.contains("nonlinear geometry") && e.contains("Rubber"), "{e}");
|
||||
}
|
||||
@@ -168,7 +168,7 @@ impl Driver {
|
||||
let resolved = model.resolve().unwrap();
|
||||
let step = model.step("Dynamic").unwrap();
|
||||
let Procedure::ExplicitDynamic(settings) = &step.procedure else { panic!() };
|
||||
let sys = System::new(model, &resolved, step, settings, 1000).unwrap();
|
||||
let sys = System::new(model, &resolved, step, settings, &Limits { max_dofs: 1000, ..Limits::default() }).unwrap();
|
||||
let (state, ws) = (State::new(&sys), Workspace::new(&sys));
|
||||
let u = vec![0.0; sys.dofs()];
|
||||
Driver { sys, state, ws, u, time: 0.0 }
|
||||
@@ -318,7 +318,7 @@ fn taylor_bar_recovers_its_yield_stress() {
|
||||
step.output_interval = Some(end / 4.0);
|
||||
step.history_interval = Some(end / 400.0);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "nodes".into(), velocity: [0.0, 0.0, -speed] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "nodes".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "nodes".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0], friction: Friction::NONE }];
|
||||
step.hourglass = Hourglass { kind: HourglassKind::Stiffness, coefficient: 0.1 };
|
||||
let mut m = block([4, 4, 30], [width, width, length], ElementKind::Hex8);
|
||||
node_set(&mut m, "nodes", |_| true);
|
||||
@@ -359,7 +359,7 @@ fn taylor_with(material: Material) -> (Model, ExplicitResult) {
|
||||
let mut step = ExplicitDynamic::new(end);
|
||||
step.output_interval = Some(end / 2.0);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "nodes".into(), velocity: [0.0, 0.0, -300_000.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "nodes".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "nodes".into(), point: [0.0; 3], normal: [0.0, 0.0, 1.0], friction: Friction::NONE }];
|
||||
step.hourglass = Hourglass { kind: HourglassKind::Stiffness, coefficient: 0.1 };
|
||||
let mut m = block([2, 2, 12], [7.6, 7.6, 25.4], ElementKind::Hex8);
|
||||
node_set(&mut m, "nodes", |_| true);
|
||||
|
||||
@@ -305,3 +305,55 @@ fn whirling_mass_on_a_corotational_rod() {
|
||||
// A quarter turn: the mass ends near (0, L).
|
||||
assert!((l + end[0]).abs() < 1e-2 && (end[1] - l).abs() < 1e-2, "{end:?}");
|
||||
}
|
||||
|
||||
/// Mass scaling to twice a uniform bar's stable increment quadruples its density: the added mass
|
||||
/// is three times the bar's, the stable increment doubles with the halved wave speed
|
||||
/// c = √(E/ρ), and the kinetic energy of the added mass is three quarters of the total.
|
||||
#[test]
|
||||
fn mass_scaling_slows_the_waves_as_the_added_density_says() {
|
||||
use fieldcraft_explicit::{Limits, solve_explicit};
|
||||
use fieldcraft_model::*;
|
||||
let build = |scaled: Option<f64>| {
|
||||
let mut m = Model::default();
|
||||
let n = 40;
|
||||
for i in 0..=n {
|
||||
for (j, (y, z)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].iter().enumerate() {
|
||||
m.set_node((4 * i + j + 1) as u32, [i as f64, *y, *z]).unwrap();
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let a = |j: usize| (4 * i + j + 1) as u32;
|
||||
let b = |j: usize| (4 * (i + 1) + j + 1) as u32;
|
||||
m.set_element(i as u32 + 1, ElementKind::Hex8, vec![a(0), b(0), b(1), a(1), a(3), b(3), b(2), a(2)]).unwrap();
|
||||
}
|
||||
m.add_set("all", Set { elements: (1..=n as u32).collect(), ..Set::default() }).unwrap();
|
||||
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: 1.0, poisson: 0.0 }), density: Some(1.0), ..Material::default() })
|
||||
.unwrap();
|
||||
let formulation = Formulation { integration: Integration::Reduced, ..Formulation::default() };
|
||||
m.add_section("bar", Section { set: "all".into(), material: "Mat".into(), kind: SectionKind::Solid {}, formulation }).unwrap();
|
||||
let mut step = ExplicitDynamic::new(10.0);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "all".into(), velocity: [0.01, 0.0, 0.0] }];
|
||||
step.mass_scaling = scaled.map(|t| MassScaling { target_increment: t, set: None });
|
||||
m.add_step(Step::new("Run", Procedure::ExplicitDynamic(step)), None).unwrap();
|
||||
solve_explicit(&m, None, &Limits::default()).unwrap()
|
||||
};
|
||||
let plain = build(None);
|
||||
let scaled = build(Some(2.0 * plain.initial_increment));
|
||||
let s = scaled.mass_scaling.as_ref().unwrap();
|
||||
eprintln!(
|
||||
"mass scaling: {s:?}; increments {} vs {}; Δt {:.4e} vs {:.4e}",
|
||||
scaled.increments, plain.increments, scaled.initial_increment, plain.initial_increment
|
||||
);
|
||||
assert_eq!(s.elements, 40);
|
||||
assert!((s.added_mass / plain.total_mass - 3.0).abs() < 1e-9 && (s.fraction - 0.75).abs() < 1e-9, "{s:?}");
|
||||
assert_eq!(s.sections.len(), 1);
|
||||
assert!((scaled.initial_increment / plain.initial_increment - 2.0).abs() < 1e-9);
|
||||
assert!((scaled.total_mass / plain.total_mass - 4.0).abs() < 1e-9);
|
||||
assert!((scaled.energy.added_kinetic / scaled.energy.kinetic - 0.75).abs() < 1e-9, "{:?}", scaled.energy);
|
||||
assert!(scaled.warnings.iter().any(|w| w.contains("mass scaling")));
|
||||
assert!(
|
||||
scaled.controlling.first().is_some_and(|c| (0.9 * c.increment / scaled.initial_increment - 1.0).abs() < 1e-6),
|
||||
"{:?}",
|
||||
scaled.controlling
|
||||
);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ fn bar_rebounds_from_a_rigid_wall() {
|
||||
step.history_interval = Some(end / 3000.0);
|
||||
step.output_interval = Some(end);
|
||||
step.initial_velocities = vec![InitialVelocity { set: "all_nodes".into(), velocity: [-v0, 0.0, 0.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "start".into(), point: [0.0; 3], normal: [1.0, 0.0, 0.0] }];
|
||||
step.rigid_walls = vec![RigidWall { set: "start".into(), point: [0.0; 3], normal: [1.0, 0.0, 0.0], friction: Friction::NONE }];
|
||||
step.bulk_viscosity.linear = linear;
|
||||
let mut bar = Bar::new(kind, 200, ExplicitDynamic::new(end));
|
||||
let nodes: Vec<u32> = bar.model.nodes().keys().copied().collect();
|
||||
@@ -347,7 +347,7 @@ fn caps_and_unsupported_models_are_errors() {
|
||||
assert!(solve_explicit(&light.model, None, &Limits::default()).is_err());
|
||||
// A node behind its wall at the start.
|
||||
let mut behind = ExplicitDynamic::new(end);
|
||||
behind.rigid_walls = vec![RigidWall { set: "end".into(), point: [LENGTH + 1.0, 0.0, 0.0], normal: [-1.0, 0.0, 0.0] }];
|
||||
behind.rigid_walls = vec![RigidWall { set: "end".into(), point: [LENGTH + 1.0, 0.0, 0.0], normal: [-1.0, 0.0, 0.0], friction: Friction::NONE }];
|
||||
let mut wall = Bar::new(ElementKind::Hex8, 4, ExplicitDynamic::new(end));
|
||||
wall.model.replace_step(Step::new("Dynamic", Procedure::ExplicitDynamic(behind.clone()))).unwrap();
|
||||
assert!(solve_explicit(&wall.model, None, &Limits::default()).is_ok(), "the wall is beyond the end node, which is on its allowed side");
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Contact geometry shared by the solvers: the closest point of an element face to a point (the
|
||||
//! local search) and a bucket grid of face boxes (the global search).
|
||||
//!
|
||||
//! **Closest point.** A face is a flat triangle or a bilinear quadrilateral given by its corner
|
||||
//! nodes, numbered so that their right-hand normal points out of the element. On a triangle the
|
||||
//! closest point is found exactly by the region tests of C. Ericson, *Real-Time Collision
|
||||
//! Detection*, Morgan Kaufmann 2005, §5.1.5. On a bilinear quadrilateral x(ξ, η) it is the
|
||||
//! minimum of ½|x(ξ, η) − p|² over [−1, 1]², found by Newton's method on the stationarity
|
||||
//! conditions x,ξ · (x − p) = x,η · (x − p) = 0 with the exact Hessian (the bilinear patch's
|
||||
//! only second derivative is x,ξη), projected onto the square after each step: the
|
||||
//! closest-point projection of node-to-segment contact (J. O. Hallquist, G. L. Goudreau, D. J.
|
||||
//! Benson, *Sliding interfaces with contact-impact in large-scale Lagrangian computations*,
|
||||
//! CMAME 51:107–137, 1985; P. Wriggers, *Computational Contact Mechanics*, 2nd ed., Springer
|
||||
//! 2006, §4.1). The signed gap is measured along the face's unit normal at the closest point; the
|
||||
//! lateral offset (how far the point lies beside the face rather than above or below it) tells a
|
||||
//! point over the face from one past its edge.
|
||||
//!
|
||||
//! **Global search.** Each face's bounding box, grown by a margin, goes into every cell of a
|
||||
//! uniform grid it overlaps (a bucket sort: D. J. Benson, J. O. Hallquist, *A single surface
|
||||
//! contact algorithm for the post-buckling analysis of shell structures*, CMAME 78:141–163,
|
||||
//! 1990); a point's candidates are the faces in its cell. The cell is twice the mean box size,
|
||||
//! grown until the grid and its entries fit their caps, so the search is linear in the number of
|
||||
//! faces and its memory bounded whatever the input. Entries are listed in face order, so the
|
||||
//! candidates (and everything decided from them) do not depend on how the work is split.
|
||||
|
||||
pub use crate::frame::Vec3;
|
||||
use crate::frame::{cross, dot, norm, sub};
|
||||
|
||||
/// Newton iterations of the bilinear closest-point search.
|
||||
const MAX_NEWTON: usize = 30;
|
||||
|
||||
/// The closest point of a face to a point.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Projection {
|
||||
/// Face coordinates of the closest point: (ξ, η) ∈ [−1, 1]² on a quadrilateral, the
|
||||
/// barycentric coordinates of the second and third nodes on a triangle.
|
||||
pub xi: [f64; 2],
|
||||
/// The face's shape functions at the closest point, per node (0 past the face's nodes).
|
||||
pub weights: [f64; 4],
|
||||
pub point: Vec3,
|
||||
/// The unit outward normal at the closest point.
|
||||
pub normal: Vec3,
|
||||
/// (p − point) · normal: negative when the point is behind the face.
|
||||
pub gap: f64,
|
||||
/// |p − point|.
|
||||
pub distance: f64,
|
||||
/// The part of p − point across the normal: 0 when the point is over or under the face, its
|
||||
/// distance from the face's edge when it is beside it.
|
||||
pub lateral: f64,
|
||||
}
|
||||
|
||||
/// The closest point of a face (three or four corner nodes, in its node order) to `p`; `None`
|
||||
/// for another number of nodes, non-finite coordinates or a face collapsed at that point.
|
||||
pub fn closest_point(face: &[Vec3], p: Vec3) -> Option<Projection> {
|
||||
if face.iter().chain(std::iter::once(&p)).flatten().any(|c| !c.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (xi, weights, normal) = match face {
|
||||
[a, b, c] => triangle(*a, *b, *c, p)?,
|
||||
[a, b, c, d] => quadrilateral([*a, *b, *c, *d], p)?,
|
||||
_ => return None,
|
||||
};
|
||||
let mut point = [0.0; 3];
|
||||
for (x, w) in face.iter().zip(weights) {
|
||||
for k in 0..3 {
|
||||
point[k] += w * x[k];
|
||||
}
|
||||
}
|
||||
let d = sub(p, point);
|
||||
let gap = dot(d, normal);
|
||||
let distance = norm(d);
|
||||
let lateral = (distance * distance - gap * gap).max(0.0).sqrt();
|
||||
Some(Projection { xi, weights, point, normal, gap, distance, lateral })
|
||||
}
|
||||
|
||||
/// The unit vector along `v`, unless it is too short against `scale` (a squared length).
|
||||
fn unit(v: Vec3, scale: f64) -> Option<Vec3> {
|
||||
let n = norm(v);
|
||||
(n > 1e-12 * scale && n.is_finite()).then(|| v.map(|c| c / n))
|
||||
}
|
||||
|
||||
/// Ericson's closest point on triangle abc: (barycentric v, w), weights, unit normal.
|
||||
fn triangle(a: Vec3, b: Vec3, c: Vec3, p: Vec3) -> Option<([f64; 2], [f64; 4], Vec3)> {
|
||||
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
|
||||
let normal = unit(cross(ab, ac), dot(ab, ab).max(dot(ac, ac)))?;
|
||||
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
|
||||
let bp = sub(p, b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
let cp = sub(p, c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
let (va, vb, vc) = (d3 * d6 - d5 * d4, d5 * d2 - d1 * d6, d1 * d4 - d3 * d2);
|
||||
let (v, w) = if d1 <= 0.0 && d2 <= 0.0 {
|
||||
(0.0, 0.0)
|
||||
} else if d3 >= 0.0 && d4 <= d3 {
|
||||
(1.0, 0.0)
|
||||
} else if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
(d1 / (d1 - d3), 0.0)
|
||||
} else if d6 >= 0.0 && d5 <= d6 {
|
||||
(0.0, 1.0)
|
||||
} else if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
(0.0, d2 / (d2 - d6))
|
||||
} else if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
let t = (d4 - d3) / ((d4 - d3) + (d5 - d6));
|
||||
(1.0 - t, t)
|
||||
} else {
|
||||
let s = va + vb + vc;
|
||||
(vb / s, vc / s)
|
||||
};
|
||||
if !(v.is_finite() && w.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(([v, w], [1.0 - v - w, v, w, 0.0], normal))
|
||||
}
|
||||
|
||||
/// The bilinear shape functions and their derivatives at (ξ, η), nodes at (−1, −1), (1, −1),
|
||||
/// (1, 1), (−1, 1).
|
||||
fn bilinear(s: f64, t: f64) -> ([f64; 4], [f64; 4], [f64; 4]) {
|
||||
let n = [(1.0 - s) * (1.0 - t), (1.0 + s) * (1.0 - t), (1.0 + s) * (1.0 + t), (1.0 - s) * (1.0 + t)].map(|v| 0.25 * v);
|
||||
let ds = [-(1.0 - t), 1.0 - t, 1.0 + t, -(1.0 + t)].map(|v| 0.25 * v);
|
||||
let dt = [-(1.0 - s), -(1.0 + s), 1.0 + s, 1.0 - s].map(|v| 0.25 * v);
|
||||
(n, ds, dt)
|
||||
}
|
||||
|
||||
fn combine(x: &[Vec3; 4], w: &[f64; 4]) -> Vec3 {
|
||||
let mut out = [0.0; 3];
|
||||
for (xi, wi) in x.iter().zip(w) {
|
||||
for k in 0..3 {
|
||||
out[k] += wi * xi[k];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The closest point of a bilinear quadrilateral by projected Newton iterations.
|
||||
fn quadrilateral(x: [Vec3; 4], p: Vec3) -> Option<([f64; 2], [f64; 4], Vec3)> {
|
||||
let size = dot(sub(x[2], x[0]), sub(x[2], x[0])).max(dot(sub(x[3], x[1]), sub(x[3], x[1])));
|
||||
if !(size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
// x,ξη: constant over the patch.
|
||||
let twist = [0, 1, 2].map(|k| 0.25 * (x[0][k] - x[1][k] + x[2][k] - x[3][k]));
|
||||
let (mut s, mut t) = (0.0f64, 0.0f64);
|
||||
for _ in 0..MAX_NEWTON {
|
||||
let (n, ds, dt) = bilinear(s, t);
|
||||
let r = sub(combine(&x, &n), p);
|
||||
let (a, b) = (combine(&x, &ds), combine(&x, &dt));
|
||||
let g = [dot(a, r), dot(b, r)];
|
||||
let (haa, hbb) = (dot(a, a), dot(b, b));
|
||||
let mut hab = dot(a, b) + dot(r, twist);
|
||||
// Far from the patch the exact Hessian may be indefinite: Gauss–Newton then.
|
||||
if haa * hbb - hab * hab <= 1e-14 * haa * hbb {
|
||||
hab = dot(a, b);
|
||||
}
|
||||
let det = haa * hbb - hab * hab;
|
||||
if !(det > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let step = [-(hbb * g[0] - hab * g[1]) / det, -(haa * g[1] - hab * g[0]) / det];
|
||||
let (s1, t1) = ((s + step[0]).clamp(-1.0, 1.0), (t + step[1]).clamp(-1.0, 1.0));
|
||||
let moved = (s1 - s).abs().max((t1 - t).abs());
|
||||
(s, t) = (s1, t1);
|
||||
if !moved.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if moved < 1e-13 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (n, ds, dt) = bilinear(s, t);
|
||||
let normal = unit(cross(combine(&x, &ds), combine(&x, &dt)), size)?;
|
||||
Some(([s, t], n, normal))
|
||||
}
|
||||
|
||||
/// A box: its lowest and highest corners.
|
||||
pub type Aabb = [Vec3; 2];
|
||||
|
||||
/// The bounding box of a face's nodes, grown by `margin` on every side.
|
||||
pub fn bounds(face: &[Vec3], margin: f64) -> Aabb {
|
||||
let mut b = [[f64::INFINITY; 3], [f64::NEG_INFINITY; 3]];
|
||||
for x in face {
|
||||
for k in 0..3 {
|
||||
b[0][k] = b[0][k].min(x[k] - margin);
|
||||
b[1][k] = b[1][k].max(x[k] + margin);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// A uniform grid of boxes (a bucket sort), for the global contact search.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Grid {
|
||||
lowest: Vec3,
|
||||
cell: f64,
|
||||
dims: [usize; 3],
|
||||
/// Per cell, the range of its entries in `items` (compressed rows).
|
||||
start: Vec<usize>,
|
||||
items: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Most cells of a grid (whatever `max_cells` says).
|
||||
pub const MAX_GRID_CELLS: usize = 1 << 24;
|
||||
|
||||
/// Most entries of a grid (box × cell overlaps).
|
||||
pub const MAX_GRID_ITEMS: usize = 1 << 27;
|
||||
|
||||
impl Grid {
|
||||
/// The grid of `boxes` (their indices are the entries; boxes that are not finite are left
|
||||
/// out), with at most `max_cells` cells (and [`MAX_GRID_CELLS`]).
|
||||
pub fn new(boxes: &[Aabb], max_cells: usize) -> Grid {
|
||||
let finite = |b: &Aabb| b.iter().flatten().all(|c| c.is_finite()) && (0..3).all(|k| b[0][k] <= b[1][k]);
|
||||
let mut lowest = [f64::INFINITY; 3];
|
||||
let mut highest = [f64::NEG_INFINITY; 3];
|
||||
let (mut count, mut extent) = (0usize, 0.0);
|
||||
for b in boxes.iter().filter(|b| finite(b)) {
|
||||
for k in 0..3 {
|
||||
lowest[k] = lowest[k].min(b[0][k]);
|
||||
highest[k] = highest[k].max(b[1][k]);
|
||||
}
|
||||
extent += (0..3).map(|k| b[1][k] - b[0][k]).fold(0.0, f64::max);
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return Grid::default();
|
||||
}
|
||||
let span = (0..3).map(|k| highest[k] - lowest[k]).fold(0.0, f64::max);
|
||||
let max_cells = max_cells.clamp(1, MAX_GRID_CELLS);
|
||||
// Twice the mean box size, but not below a millionth of the span (point-like boxes).
|
||||
let mut cell = (2.0 * extent / count as f64).max(1e-6 * span).max(f64::MIN_POSITIVE);
|
||||
for _ in 0..200 {
|
||||
let dims = [0, 1, 2].map(|k| ((highest[k] - lowest[k]) / cell).floor() + 1.0);
|
||||
let cells = dims[0] * dims[1] * dims[2];
|
||||
if !(cells <= max_cells as f64) {
|
||||
cell *= (cells / max_cells as f64).cbrt().max(1.01) * 1.01;
|
||||
continue;
|
||||
}
|
||||
let dims = dims.map(|d| d as usize);
|
||||
let mut grid = Grid { lowest, cell, dims, start: vec![0; dims[0] * dims[1] * dims[2] + 1], items: Vec::new() };
|
||||
// Count the entries per cell, then fill (compressed rows, boxes in order).
|
||||
let mut total = 0usize;
|
||||
for b in boxes.iter().filter(|b| finite(b)) {
|
||||
let (lo, hi) = (grid.coordinates(b[0]), grid.coordinates(b[1]));
|
||||
total = total.saturating_add((0..3).map(|k| hi[k] - lo[k] + 1).product::<usize>());
|
||||
}
|
||||
if total > MAX_GRID_ITEMS {
|
||||
cell *= 2.0;
|
||||
continue;
|
||||
}
|
||||
for b in boxes.iter().filter(|b| finite(b)) {
|
||||
grid.each_cell(b, |c, start| start[c + 1] += 1);
|
||||
}
|
||||
for c in 0..grid.start.len() - 1 {
|
||||
grid.start[c + 1] += grid.start[c];
|
||||
}
|
||||
let mut next = grid.start.clone();
|
||||
let mut items = vec![0u32; total];
|
||||
for (i, b) in boxes.iter().enumerate().filter(|(_, b)| finite(b)) {
|
||||
grid.each_cell(b, |c, _| {
|
||||
if let (Some(slot), Some(item)) = (next.get_mut(c), u32::try_from(i).ok()) {
|
||||
if let Some(e) = items.get_mut(*slot) {
|
||||
*e = item;
|
||||
}
|
||||
*slot += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
grid.items = items;
|
||||
return grid;
|
||||
}
|
||||
Grid::default()
|
||||
}
|
||||
|
||||
/// The cell coordinates of a point inside the grid's span (clamped to it).
|
||||
fn coordinates(&self, x: Vec3) -> [usize; 3] {
|
||||
[0, 1, 2].map(|k| {
|
||||
let c = ((x[k] - self.lowest[k]) / self.cell).floor();
|
||||
if c > 0.0 { (c as usize).min(self.dims[k].saturating_sub(1)) } else { 0 }
|
||||
})
|
||||
}
|
||||
|
||||
fn index(&self, c: [usize; 3]) -> usize {
|
||||
c[0] + self.dims[0] * (c[1] + self.dims[1] * c[2])
|
||||
}
|
||||
|
||||
/// Call `f(cell, start)` for every cell a box overlaps.
|
||||
fn each_cell(&mut self, b: &Aabb, mut f: impl FnMut(usize, &mut Vec<usize>)) {
|
||||
let (lo, hi) = (self.coordinates(b[0]), self.coordinates(b[1]));
|
||||
for k in lo[2]..=hi[2] {
|
||||
for j in lo[1]..=hi[1] {
|
||||
for i in lo[0]..=hi[0] {
|
||||
let c = self.index([i, j, k]);
|
||||
f(c, &mut self.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The boxes that may hold `p`: those listed in its cell, in box order (empty outside the
|
||||
/// grid).
|
||||
pub fn candidates(&self, p: Vec3) -> &[u32] {
|
||||
if self.items.is_empty() || p.iter().any(|c| !c.is_finite()) {
|
||||
return &[];
|
||||
}
|
||||
let outside = (0..3).any(|k| p[k] < self.lowest[k] || p[k] > self.lowest[k] + self.cell * self.dims[k] as f64);
|
||||
if outside {
|
||||
return &[];
|
||||
}
|
||||
let c = self.index(self.coordinates(p));
|
||||
match (self.start.get(c), self.start.get(c + 1)) {
|
||||
(Some(a), Some(b)) => self.items.get(*a..*b).unwrap_or_default(),
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The boxes that may overlap box `b`: those listed in the cells it overlaps, in box order
|
||||
/// without repeats (into `out`, cleared first).
|
||||
pub fn candidates_in(&self, b: &Aabb, out: &mut Vec<u32>) {
|
||||
out.clear();
|
||||
if self.items.is_empty() || b.iter().flatten().any(|c| !c.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let top = [0, 1, 2].map(|k| self.lowest[k] + self.cell * self.dims[k] as f64);
|
||||
if (0..3).any(|k| b[1][k] < self.lowest[k] || b[0][k] > top[k]) {
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = (self.coordinates(b[0]), self.coordinates(b[1]));
|
||||
for k in lo[2]..=hi[2] {
|
||||
for j in lo[1]..=hi[1] {
|
||||
for i in lo[0]..=hi[0] {
|
||||
let c = self.index([i, j, k]);
|
||||
if let (Some(a), Some(e)) = (self.start.get(c), self.start.get(c + 1)) {
|
||||
out.extend_from_slice(self.items.get(*a..*e).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
}
|
||||
|
||||
/// The number of entries (box × cell overlaps).
|
||||
pub fn entries(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-12
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_projection_covers_the_face_its_edges_and_corners() {
|
||||
let t = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
|
||||
// Above the inside: the foot of the perpendicular, positive gap along +z.
|
||||
let p = closest_point(&t, [0.5, 0.25, 0.3]).unwrap();
|
||||
assert!(close(p.gap, 0.3) && close(p.lateral, 0.0) && close(p.point[0], 0.5) && close(p.point[1], 0.25));
|
||||
assert!(close(p.weights.iter().sum::<f64>(), 1.0) && p.normal == [0.0, 0.0, 1.0]);
|
||||
// Below: a negative gap.
|
||||
assert!(close(closest_point(&t, [0.5, 0.5, -0.1]).unwrap().gap, -0.1));
|
||||
// Past the hypotenuse: the closest point is on it, the offset lateral.
|
||||
let p = closest_point(&t, [2.0, 2.0, 0.0]).unwrap();
|
||||
assert!(close(p.point[0], 1.0) && close(p.point[1], 1.0) && close(p.lateral, 2f64.sqrt()) && close(p.gap, 0.0));
|
||||
// Past a corner.
|
||||
let p = closest_point(&t, [-1.0, -1.0, 1.0]).unwrap();
|
||||
assert_eq!(p.weights, [1.0, 0.0, 0.0, 0.0]);
|
||||
assert!(close(p.distance, 3f64.sqrt()));
|
||||
// Degenerate and hostile faces give nothing.
|
||||
assert!(closest_point(&[[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], [0.0, 1.0, 0.0]).is_none());
|
||||
assert!(closest_point(&t, [f64::NAN, 0.0, 0.0]).is_none());
|
||||
assert!(closest_point(&t[..2], [0.0; 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadrilateral_projection_finds_the_foot_on_a_warped_patch() {
|
||||
let flat = [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [0.0, 1.0, 0.0]];
|
||||
let p = closest_point(&flat, [1.5, 0.25, -0.2]).unwrap();
|
||||
assert!(close(p.xi[0], 0.5) && close(p.xi[1], -0.5) && close(p.gap, -0.2) && close(p.lateral, 0.0));
|
||||
assert!(close(p.weights.iter().sum::<f64>(), 1.0));
|
||||
// A warped (hyperbolic paraboloid) patch: the residual is normal to the surface.
|
||||
let warped = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.2], [1.0, 1.0, 0.0], [0.0, 1.0, 0.2]];
|
||||
let q = [0.3, 0.6, 0.5];
|
||||
let p = closest_point(&warped, q).unwrap();
|
||||
let (_, ds, dt) = bilinear(p.xi[0], p.xi[1]);
|
||||
let r = sub(q, p.point);
|
||||
assert!(dot(r, combine(&warped, &ds)).abs() < 1e-12 && dot(r, combine(&warped, &dt)).abs() < 1e-12, "{p:?}");
|
||||
assert!(p.lateral < 1e-9 && p.gap > 0.0);
|
||||
// Beyond an edge: clamped, with the offset lateral.
|
||||
let p = closest_point(&flat, [3.0, 0.5, 0.0]).unwrap();
|
||||
assert!(close(p.xi[0], 1.0) && close(p.lateral, 1.0));
|
||||
// A quadrilateral collapsed to a segment has no normal.
|
||||
assert!(closest_point(&[[0.0; 3], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]], [0.5, 0.5, 0.0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_finds_every_box_holding_a_point_and_stays_bounded() {
|
||||
let boxes: Vec<Aabb> = (0..50).map(|i| [[i as f64, 0.0, 0.0], [i as f64 + 1.5, 1.0, 1.0]]).collect();
|
||||
let grid = Grid::new(&boxes, 1000);
|
||||
for k in 0..500 {
|
||||
let p = [k as f64 * 0.1 + 0.01, 0.5, 0.5];
|
||||
let found = grid.candidates(p);
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let inside = (0..3).all(|d| p[d] >= b[0][d] && p[d] <= b[1][d]);
|
||||
assert!(!inside || found.contains(&(i as u32)), "point {p:?} misses box {i}");
|
||||
}
|
||||
assert!(found.windows(2).all(|w| w[0] < w[1]), "candidates in box order");
|
||||
}
|
||||
assert!(grid.candidates([-5.0, 0.0, 0.0]).is_empty() && grid.candidates([f64::NAN; 3]).is_empty());
|
||||
// One huge box among small ones, a cap of one cell, boxes that are not finite: still
|
||||
// bounded and correct.
|
||||
let mut mixed = boxes.clone();
|
||||
mixed.push([[-1e6, -1e6, -1e6], [1e6, 1e6, 1e6]]);
|
||||
mixed.push([[f64::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]]);
|
||||
for cells in [1, 10, 1 << 30] {
|
||||
let g = Grid::new(&mixed, cells);
|
||||
assert!(g.candidates([10.2, 0.5, 0.5]).contains(&50) && g.candidates([10.2, 0.5, 0.5]).contains(&9));
|
||||
assert!(!g.candidates([10.2, 0.5, 0.5]).contains(&51));
|
||||
assert!(g.entries() <= MAX_GRID_ITEMS);
|
||||
}
|
||||
assert_eq!(Grid::new(&[], 10).candidates([0.0; 3]), &[] as &[u32]);
|
||||
// Box queries: every box that overlaps, once, in order.
|
||||
let mut found = Vec::new();
|
||||
grid.candidates_in(&[[10.2, 0.2, 0.2], [12.1, 0.4, 0.4]], &mut found);
|
||||
let expected: Vec<u32> = (0..50).filter(|i| boxes[*i as usize][0][0] <= 12.1 && boxes[*i as usize][1][0] >= 10.2).collect();
|
||||
assert!(expected.iter().all(|e| found.contains(e)) && found.windows(2).all(|w| w[0] < w[1]), "{found:?} {expected:?}");
|
||||
grid.candidates_in(&[[-9.0, 0.0, 0.0], [-8.0, 1.0, 1.0]], &mut found);
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,12 @@ fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]],
|
||||
if !geometric {
|
||||
return;
|
||||
}
|
||||
let tau = crate::rotation::tensor(&r.stress);
|
||||
add_geometric(k, gradients, &r.stress, w);
|
||||
}
|
||||
|
||||
/// K_ab += w (∇N_a · τ ∇N_b) I, the geometric (initial-stress) term.
|
||||
fn add_geometric(k: &mut Matrix, gradients: &[[f64; 3]], stress: &[f64; 6], w: f64) {
|
||||
let tau = crate::rotation::tensor(stress);
|
||||
let n = k.n;
|
||||
for (a, ga) in gradients.iter().enumerate() {
|
||||
let tg: [f64; 3] = std::array::from_fn(|i| (0..3).map(|jj| tau[i][jj] * ga[jj]).sum());
|
||||
@@ -458,6 +463,58 @@ impl Solid<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Solid<'_> {
|
||||
/// The initial-stress (geometric) stiffness of a stress state given at the stiffness
|
||||
/// integration points: K_σ,ab = ∫ (∇N_a · σ ∇N_b) I dV over the reference element, the
|
||||
/// geometric term of the tangent with the reference gradients (linear buckling: Bathe 2014,
|
||||
/// §6.8.2; the same term as [`Solid::nonlinear`]'s at u = 0 with τ = σ).
|
||||
pub fn geometric_stiffness(&self, stress: &[[f64; 6]]) -> Result<Matrix> {
|
||||
let points = self.rule().points();
|
||||
if stress.len() != points.len() {
|
||||
return Err(Error::Invalid(format!("{} stresses for {} integration points", stress.len(), points.len())));
|
||||
}
|
||||
let n = self.ty.nodes;
|
||||
let mut k = Matrix::zeros(3 * n);
|
||||
for (p, s) in points.iter().zip(stress) {
|
||||
if s.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Invalid("stresses must be finite".into()));
|
||||
}
|
||||
let g = self.gradients(p.xi)?;
|
||||
add_geometric(&mut k, g.dx.get(..n).unwrap_or_default(), s, g.det * p.weight);
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
}
|
||||
|
||||
impl Plane<'_> {
|
||||
/// The initial-stress stiffness of a 2D element (u, v per node) for the stresses of
|
||||
/// [`Plane::stresses_with`] at its stiffness points: the in-plane term and, axisymmetric,
|
||||
/// the hoop stress's σ_θθ N_a N_b / r² on the radial motion.
|
||||
pub fn geometric_stiffness(&self, stress: &[[f64; 6]]) -> Result<Matrix> {
|
||||
let rule = self.rule().points();
|
||||
if stress.len() != rule.len() {
|
||||
return Err(Error::Invalid(format!("{} stresses for {} integration points", stress.len(), rule.len())));
|
||||
}
|
||||
let n = self.ty.nodes;
|
||||
let mut k = Matrix::zeros(2 * n);
|
||||
for (p, s) in rule.iter().zip(stress) {
|
||||
if s.iter().any(|v| !v.is_finite()) {
|
||||
return Err(Error::Invalid("stresses must be finite".into()));
|
||||
}
|
||||
let at = self.at(p)?;
|
||||
let cols = at.g.get(..n).unwrap_or_default();
|
||||
for (a, ga) in cols.iter().enumerate() {
|
||||
for (b, gb) in cols.iter().enumerate() {
|
||||
let v = ga[0] * gb[0] * s[0] + ga[1] * gb[1] * s[1] + (ga[0] * gb[1] + ga[1] * gb[0]) * s[3];
|
||||
k.add(2 * a, 2 * b, at.dv * (v + s[2] * ga[2] * gb[2]));
|
||||
k.add(2 * a + 1, 2 * b + 1, at.dv * v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
}
|
||||
|
||||
/// One integration point of a 2D element in a nonlinear step: the total deformation gradient,
|
||||
/// det F_m, the reference position and the response.
|
||||
struct PlanePoint {
|
||||
@@ -958,4 +1015,32 @@ mod tests {
|
||||
let stress = Plane::new(ElementKind::Quad4, &quad8[..4], Formulation::Full, Theory::PlaneStress { thickness: 1.0 }).unwrap();
|
||||
assert!(matches!(stress.finite_strain(&svk, &[[0.0; 2]; 4], &[], &mut [], true), Err(Error::NotSupported(_))));
|
||||
}
|
||||
|
||||
/// uᵀ K_σ u = ∫ σ_xx (∂u_y/∂x)² dV = σ_xx V for u_y = x under a uniform σ_xx, in a distorted
|
||||
/// hex20 and a plane strain quad8, and K_σ annihilates rigid translations.
|
||||
#[test]
|
||||
fn geometric_stiffness_of_a_uniform_stress() {
|
||||
let sigma = 2.5;
|
||||
let hex: Vec<[f64; 3]> = crate::basis::Basis::Hex20
|
||||
.nodes()
|
||||
.iter()
|
||||
.map(|p| [1.0 + 0.6 * p[0] + 0.05 * p[1] * p[2], 0.8 * p[1], 0.7 * p[2] + 0.03 * p[0]])
|
||||
.collect();
|
||||
let solid = Solid::new(ElementKind::Hex20, &hex, Formulation::Full).unwrap();
|
||||
let points = solid.rule().points().len();
|
||||
let k = solid.geometric_stiffness(&vec![[sigma, 0.0, 0.0, 0.0, 0.0, 0.0]; points]).unwrap();
|
||||
let u: Vec<f64> = hex.iter().flat_map(|x| [0.0, x[0], 0.0]).collect();
|
||||
let energy: f64 = (0..u.len()).map(|i| u[i] * (0..u.len()).map(|j| k.get(i, j) * u[j]).sum::<f64>()).sum();
|
||||
assert!((energy - sigma * solid.volume().unwrap()).abs() < 1e-12, "{energy}");
|
||||
let t: Vec<f64> = hex.iter().flat_map(|_| [0.3, -0.2, 1.0]).collect();
|
||||
assert!(k.mul_vec(&t).iter().all(|v| v.abs() < 1e-12));
|
||||
assert!(solid.geometric_stiffness(&[[sigma, 0.0, 0.0, 0.0, 0.0, 0.0]]).is_err());
|
||||
let quad: Vec<[f64; 3]> = crate::basis::Basis::Quad8.nodes().iter().map(|p| [1.0 + 0.5 * p[0] + 0.04 * p[1], 0.6 * p[1], 0.0]).collect();
|
||||
let plane =
|
||||
crate::plane::Plane::new(ElementKind::Quad8, &quad, Formulation::Full, crate::plane::Theory::PlaneStrain { thickness: 0.3 }).unwrap();
|
||||
let k = plane.geometric_stiffness(&vec![[sigma, 0.0, 0.0, 0.0, 0.0, 0.0]; plane.rule().points().len()]).unwrap();
|
||||
let u: Vec<f64> = quad.iter().flat_map(|x| [0.0, x[0]]).collect();
|
||||
let energy: f64 = (0..u.len()).map(|i| u[i] * (0..u.len()).map(|j| k.get(i, j) * u[j]).sum::<f64>()).sum();
|
||||
assert!((energy - sigma * plane.volume().unwrap()).abs() < 1e-12, "{energy}");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
//! - [`beam`]: two-node Timoshenko beams with profile section properties;
|
||||
//! - [`frame`]: local axes of shells and beams and the rotation of their matrices;
|
||||
//! - [`face`]: consistent nodal forces of pressure on element faces;
|
||||
//! - [`contact`]: contact geometry: the closest point of an element face to a point and the
|
||||
//! bucket grid of the global search;
|
||||
//! - [`heat`]: heat conduction (conductivity, capacity, sources, surface flux and convection,
|
||||
//! heat flux); thermal strain loads and stresses are in [`element`];
|
||||
//! - [`uniform`]: uniform-strain (one-point) hexahedron and tetrahedron kinematics, hourglass
|
||||
@@ -54,6 +56,7 @@
|
||||
pub mod basis;
|
||||
pub mod beam;
|
||||
pub mod constitutive;
|
||||
pub mod contact;
|
||||
pub mod dense;
|
||||
pub mod dof;
|
||||
pub mod elastoplastic;
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
//! with SolidCraft, and hand back its bodies with persistent face names.
|
||||
//!
|
||||
//! The design is rebuilt from its authoritative inputs (sketches, features, parameters); cached
|
||||
//! display meshes in the file are never used. [`open_design`] takes bytes; [`open_design_file`]
|
||||
//! reads a path through SolidCraft's file access, which in the browser is the in-memory files the
|
||||
//! web host loaded.
|
||||
//! display meshes in the file are never used. [`open_design`] takes the file's bytes (reading
|
||||
//! files is `fieldcraft_io::fs`'s job, which in the browser holds the page's files).
|
||||
|
||||
use solidcraft_doc::{Document, Model, naming};
|
||||
use solidcraft_kernel::{KernelError, guard};
|
||||
@@ -45,11 +44,6 @@ pub fn open_design(bytes: &[u8]) -> Result<Design> {
|
||||
rebuild(&doc)
|
||||
}
|
||||
|
||||
/// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it.
|
||||
pub fn open_design_file(path: &str) -> Result<Design> {
|
||||
open_design(&crate::read_file(path, MAX_DESIGN_BYTES)?)
|
||||
}
|
||||
|
||||
/// Rebuild an in-memory document (the design half of [`open_design`]).
|
||||
pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
let model = guard("rebuild design", || {
|
||||
@@ -165,8 +159,5 @@ mod tests {
|
||||
assert!(open_design(bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
assert!(open_design(&vec![b' '; MAX_DESIGN_BYTES + 1]).is_err());
|
||||
for path in ["", "/", "/dev/zero", "/no/such/design.solidcraft"] {
|
||||
assert!(open_design_file(path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub mod sample;
|
||||
#[cfg(feature = "scripts")]
|
||||
mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
pub use script::{MAX_SCRIPT_BYTES, script_design};
|
||||
@@ -47,14 +47,6 @@ impl From<KernelError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max_bytes` through SolidCraft's file access (in the browser,
|
||||
/// the in-memory files the web host loaded). Opening a design or a 3MF package from these bytes
|
||||
/// ([`open_design`], [`tagged_mesh::read_3mf`]) lets the caller record a hash of exactly what it
|
||||
/// opened.
|
||||
pub fn read_file(path: &str, max_bytes: usize) -> Result<Vec<u8>> {
|
||||
solidcraft_io::vfs::read(path, max_bytes).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))
|
||||
}
|
||||
|
||||
/// SolidCraft's linear modelling tolerance (mm): points closer than this are the same point.
|
||||
const LINEAR_TOLERANCE: f64 = solidcraft_geom::LINEAR_TOL;
|
||||
|
||||
|
||||
@@ -344,12 +344,6 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<Tagge
|
||||
Ok(TaggedSurface { name, mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags, weld_distance: 0.0 }, faces })
|
||||
}
|
||||
|
||||
/// [`read_3mf`] of a file: a regular file of at most [`MAX_FILE_BYTES`] (SolidCraft's virtual
|
||||
/// file system, so the browser's in-memory files work too).
|
||||
pub fn read_3mf_file(path: &str) -> Result<Vec<TaggedSurface>> {
|
||||
read_3mf(&crate::read_file(path, MAX_FILE_BYTES)?)
|
||||
}
|
||||
|
||||
/// The surfaces of a 3MF package, one per build item, in millimetres.
|
||||
pub fn read_3mf(bytes: &[u8]) -> Result<Vec<TaggedSurface>> {
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
|
||||
@@ -7,17 +7,18 @@
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
//!
|
||||
//! In the browser (`wasm32`) the same calls read and write files kept in memory
|
||||
//! ([`crate::memory`]): the web host puts files in ([`insert`]) and takes out what commands wrote
|
||||
//! ([`take_writes`]). Everything else in FieldCraft reaches files only through this module.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use disk::*;
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn io_error(path: &Path, e: std::io::Error) -> Error {
|
||||
Error::Io(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The backup of a file written with `backup: true`.
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
@@ -25,185 +26,276 @@ pub fn backup_path(path: &Path) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
use crate::Result;
|
||||
use crate::memory::MemoryFs;
|
||||
|
||||
/// Flush the directory entry (the rename) to disk where the platform allows it.
|
||||
fn sync_dir(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
|
||||
&& let Ok(d) = fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
thread_local! {
|
||||
// The browser runs the app on one thread.
|
||||
static FILES: RefCell<MemoryFs> = RefCell::new(MemoryFs::default());
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` (a backup is not kept: the host hands each save out as a
|
||||
/// download).
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], _backup: bool) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().write(path, bytes))
|
||||
}
|
||||
|
||||
/// Read a file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
FILES.with(|f| f.borrow().read(path, max))
|
||||
}
|
||||
|
||||
pub fn is_file(path: &Path) -> bool {
|
||||
FILES.with(|f| f.borrow().is_file(path))
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().remove(path))
|
||||
}
|
||||
|
||||
/// The files directly in `dir`.
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
FILES.with(|f| f.borrow().list(dir))
|
||||
}
|
||||
|
||||
/// Folders are implied by the files' paths.
|
||||
pub fn create_dir_all(_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The host puts a file in (one picked, dropped or kept in the browser's storage).
|
||||
pub fn insert(path: &Path, bytes: Vec<u8>) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().insert(path, bytes))
|
||||
}
|
||||
|
||||
/// What commands wrote since the last call, for the host to hand out or store.
|
||||
pub fn take_writes() -> Vec<(PathBuf, std::sync::Arc<[u8]>)> {
|
||||
FILES.with(|f| f.borrow_mut().take_writes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Steps of a save, so tests can stop one part way (as a crash would).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Step {
|
||||
TempWritten,
|
||||
BackupWritten,
|
||||
Replaced,
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod disk {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
|
||||
let tmp = temp_beside(path, "new");
|
||||
if let Err(e) = write_synced(&tmp, bytes) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
use super::backup_path;
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn io_error(path: &Path, e: std::io::Error) -> Error {
|
||||
Error::Io(format!("{}: {e}", path.display()))
|
||||
}
|
||||
if stop == Step::TempWritten {
|
||||
return Ok(());
|
||||
|
||||
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
|
||||
}
|
||||
if backup && path.is_file() {
|
||||
// Copy (not move) the old file, so the target exists at every moment.
|
||||
let bak_tmp = temp_beside(path, "bak");
|
||||
let r = fs::copy(path, &bak_tmp)
|
||||
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
|
||||
.and_then(|f| f.sync_all())
|
||||
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
|
||||
if let Err(e) = r {
|
||||
let _ = fs::remove_file(&bak_tmp);
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Flush the directory entry (the rename) to disk where the platform allows it.
|
||||
fn sync_dir(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
|
||||
&& let Ok(d) = fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Steps of a save, so tests can stop one part way (as a crash would).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Step {
|
||||
TempWritten,
|
||||
BackupWritten,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
|
||||
let tmp = temp_beside(path, "new");
|
||||
if let Err(e) = write_synced(&tmp, bytes) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if stop == Step::BackupWritten {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(path);
|
||||
// Temporary files of saves that crashed part way (a minute old or more) go.
|
||||
for t in stale_temps(path) {
|
||||
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
|
||||
let _ = fs::remove_file(t);
|
||||
if stop == Step::TempWritten {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
|
||||
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
|
||||
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let prefix = format!(".{name}.");
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
|
||||
.take(10_000)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
|
||||
}
|
||||
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
// The file may grow between the size check and the read.
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_keeps_a_backup() {
|
||||
let d = dir("bak");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"one", true).unwrap();
|
||||
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
|
||||
write_atomic(&p, b"two", true).unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two");
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
write_atomic(&p, b"three", false).unwrap();
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
assert!(stale_temps(&p).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save stopped after any step leaves the old file whole (or the new one).
|
||||
#[test]
|
||||
fn a_save_stopped_part_way_keeps_the_old_file() {
|
||||
let d = dir("stop");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"old", true).unwrap();
|
||||
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
|
||||
write_steps(&p, b"new content", true, stop).unwrap();
|
||||
let now = fs::read(&p).unwrap();
|
||||
if stop < Step::Replaced {
|
||||
assert_eq!(now, b"old", "{stop:?}");
|
||||
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
|
||||
for t in stale_temps(&p) {
|
||||
fs::remove_file(t).unwrap();
|
||||
}
|
||||
} else {
|
||||
assert_eq!(now, b"new content");
|
||||
if backup && path.is_file() {
|
||||
// Copy (not move) the old file, so the target exists at every moment.
|
||||
let bak_tmp = temp_beside(path, "bak");
|
||||
let r = fs::copy(path, &bak_tmp)
|
||||
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
|
||||
.and_then(|f| f.sync_all())
|
||||
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
|
||||
if let Err(e) = r {
|
||||
let _ = fs::remove_file(&bak_tmp);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
write_atomic(&p, b"old", false).unwrap();
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
if stop == Step::BackupWritten {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(path);
|
||||
// Temporary files of saves that crashed part way (a minute old or more) go.
|
||||
for t in stale_temps(path) {
|
||||
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
|
||||
let _ = fs::remove_file(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_target() {
|
||||
let d = dir("fail");
|
||||
let p = d.join("missing-dir").join("a.fieldcraft");
|
||||
assert!(write_atomic(&p, b"x", true).is_err());
|
||||
assert!(!p.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
|
||||
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_capped() {
|
||||
let d = dir("read");
|
||||
let p = d.join("a.bin");
|
||||
fs::write(&p, [7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
|
||||
assert!(read_capped(&d, 1 << 20).is_err());
|
||||
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
|
||||
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let prefix = format!(".{name}.");
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
|
||||
.take(10_000)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
|
||||
}
|
||||
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
// The file may grow between the size check and the read.
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn is_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> Result<()> {
|
||||
fs::remove_file(path).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
/// The files directly in `dir` (at most a million).
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(1_000_000)
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_dir_all(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir).map_err(|e| io_error(dir, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_keeps_a_backup() {
|
||||
let d = dir("bak");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"one", true).unwrap();
|
||||
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
|
||||
write_atomic(&p, b"two", true).unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two");
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
write_atomic(&p, b"three", false).unwrap();
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
assert!(stale_temps(&p).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save stopped after any step leaves the old file whole (or the new one).
|
||||
#[test]
|
||||
fn a_save_stopped_part_way_keeps_the_old_file() {
|
||||
let d = dir("stop");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"old", true).unwrap();
|
||||
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
|
||||
write_steps(&p, b"new content", true, stop).unwrap();
|
||||
let now = fs::read(&p).unwrap();
|
||||
if stop < Step::Replaced {
|
||||
assert_eq!(now, b"old", "{stop:?}");
|
||||
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
|
||||
for t in stale_temps(&p) {
|
||||
fs::remove_file(t).unwrap();
|
||||
}
|
||||
} else {
|
||||
assert_eq!(now, b"new content");
|
||||
}
|
||||
write_atomic(&p, b"old", false).unwrap();
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_target() {
|
||||
let d = dir("fail");
|
||||
let p = d.join("missing-dir").join("a.fieldcraft");
|
||||
assert!(write_atomic(&p, b"x", true).is_err());
|
||||
assert!(!p.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_capped() {
|
||||
let d = dir("read");
|
||||
let p = d.join("a.bin");
|
||||
fs::write(&p, [7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
|
||||
assert!(read_capped(&d, 1 << 20).is_err());
|
||||
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,12 @@
|
||||
//! capped before anything is allocated, every part's checksum is verified, and the model is
|
||||
//! rebuilt through the same checked methods commands use, so a file cannot hold what commands
|
||||
//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`],
|
||||
//! [`recovery`] and [`roots`] touch the file system.
|
||||
//! [`recovery`] and [`roots`] touch the file system, which in the browser is [`memory`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod fs;
|
||||
pub mod memory;
|
||||
pub mod mesh;
|
||||
pub mod recovery;
|
||||
pub mod roots;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Files kept in memory: the file system of the browser build.
|
||||
//!
|
||||
//! A browser page has no file system it can call synchronously, so on `wasm32` [`crate::fs`]
|
||||
//! keeps files here. The web host puts files in before a command reads them (a file picked or
|
||||
//! dropped, an autosave kept in the browser's storage) and takes out what commands wrote
|
||||
//! ([`MemoryFs::take_writes`]) to hand it out as a download or keep it in the browser's storage.
|
||||
//! Natively the type is only used by its tests.
|
||||
//!
|
||||
//! Follows SolidCraft's `crates/io/src/vfs.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Most files kept, and most bytes in all: a page that is handed more refuses the write.
|
||||
pub const MAX_FILES: usize = 10_000;
|
||||
pub const MAX_TOTAL_BYTES: u64 = 2 << 30;
|
||||
|
||||
/// Files by path (shared, so handing one out copies nothing), their total size, and the paths
|
||||
/// written since the host last took them.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryFs {
|
||||
files: BTreeMap<PathBuf, Arc<[u8]>>,
|
||||
total: u64,
|
||||
written: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn missing(path: &Path) -> Error {
|
||||
Error::Io(format!("{}: no such file in this browser page", path.display()))
|
||||
}
|
||||
|
||||
impl MemoryFs {
|
||||
fn room_for(&self, path: &Path, len: usize) -> Result<()> {
|
||||
let replaced = self.files.get(path).map_or(0, |b| b.len() as u64);
|
||||
let total = self.total.saturating_sub(replaced).saturating_add(len as u64);
|
||||
if !self.files.contains_key(path) && self.files.len() >= MAX_FILES {
|
||||
return Err(Error::Limit(format!("{}: more than {MAX_FILES} files in this browser page", path.display())));
|
||||
}
|
||||
if total > MAX_TOTAL_BYTES {
|
||||
return Err(Error::Limit(format!("{}: files in this browser page would take more than {} MB", path.display(), MAX_TOTAL_BYTES >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&mut self, path: &Path, bytes: Arc<[u8]>) -> Result<()> {
|
||||
self.room_for(path, bytes.len())?;
|
||||
self.total = self.total.saturating_add(bytes.len() as u64);
|
||||
if let Some(old) = self.files.insert(path.to_path_buf(), bytes) {
|
||||
self.total = self.total.saturating_sub(old.len() as u64);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a file in from the host (not recorded as written).
|
||||
pub fn insert(&mut self, path: &Path, bytes: Vec<u8>) -> Result<()> {
|
||||
self.put(path, bytes.into())
|
||||
}
|
||||
|
||||
/// Write a file for a command: kept, and recorded for [`MemoryFs::take_writes`].
|
||||
pub fn write(&mut self, path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
self.put(path, bytes.into())?;
|
||||
self.written.retain(|p| p != path);
|
||||
self.written.push(path.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file of at most `max` bytes.
|
||||
pub fn read(&self, path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let bytes = self.files.get(path).ok_or_else(|| missing(path))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), bytes.len() >> 20, max >> 20)));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub fn is_file(&self, path: &Path) -> bool {
|
||||
self.files.contains_key(path)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &Path) -> Result<()> {
|
||||
let old = self.files.remove(path).ok_or_else(|| missing(path))?;
|
||||
self.total = self.total.saturating_sub(old.len() as u64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files directly in `dir`, in path order.
|
||||
pub fn list(&self, dir: &Path) -> Vec<PathBuf> {
|
||||
// Paths order by component, so everything under `dir` follows it.
|
||||
self.files
|
||||
.range(dir.to_path_buf()..)
|
||||
.map(|(p, _)| p)
|
||||
.take_while(|p| p.starts_with(dir))
|
||||
.filter(|p| p.parent() == Some(dir))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The files written since the last call, in the order last written, with their content
|
||||
/// (a file removed since is left out).
|
||||
pub fn take_writes(&mut self) -> Vec<(PathBuf, Arc<[u8]>)> {
|
||||
std::mem::take(&mut self.written).into_iter().filter_map(|p| self.files.get(&p).cloned().map(|b| (p, b))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_are_kept_listed_and_handed_out_once_written() {
|
||||
let mut m = MemoryFs::default();
|
||||
let (a, b) = (Path::new("/browser/a.fieldcraft"), Path::new("/browser/b.inp"));
|
||||
m.insert(a, b"picked".to_vec()).unwrap();
|
||||
assert_eq!(m.read(a, 100).unwrap(), b"picked");
|
||||
assert!(m.take_writes().is_empty(), "a file put in by the host is not a write");
|
||||
m.write(b, b"one").unwrap();
|
||||
m.write(a, b"saved").unwrap();
|
||||
m.write(b, b"two").unwrap();
|
||||
let taken: Vec<(PathBuf, Vec<u8>)> = m.take_writes().into_iter().map(|(p, b)| (p, b.to_vec())).collect();
|
||||
assert_eq!(taken, vec![(a.to_path_buf(), b"saved".to_vec()), (b.to_path_buf(), b"two".to_vec())]);
|
||||
assert!(m.take_writes().is_empty());
|
||||
assert_eq!(m.list(Path::new("/browser")), vec![a.to_path_buf(), b.to_path_buf()]);
|
||||
assert!(m.list(Path::new("/")).is_empty(), "only the folder's own files");
|
||||
m.insert(Path::new("/browser2/c"), vec![]).unwrap();
|
||||
m.insert(Path::new("/browser/sub/d"), vec![]).unwrap();
|
||||
assert_eq!(m.list(Path::new("/browser")).len(), 2, "not a sibling folder's files, nor a subfolder's");
|
||||
m.write(a, b"again").unwrap();
|
||||
m.remove(a).unwrap();
|
||||
assert!(m.take_writes().is_empty(), "a removed file is not handed out");
|
||||
assert!(!m.is_file(a) && m.remove(a).is_err() && m.read(a, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_sizes_are_capped() {
|
||||
let mut m = MemoryFs::default();
|
||||
let p = Path::new("/x");
|
||||
m.insert(p, vec![0; 100]).unwrap();
|
||||
assert!(matches!(m.read(p, 99), Err(Error::Limit(_))));
|
||||
assert_eq!(m.read(p, 100).map(|b| b.len()), Ok(100));
|
||||
for i in 1..MAX_FILES {
|
||||
m.insert(&PathBuf::from(format!("/f{i}")), vec![]).unwrap();
|
||||
}
|
||||
assert!(matches!(m.write(Path::new("/one-too-many"), b""), Err(Error::Limit(_))));
|
||||
assert!(m.write(p, b"replacing is fine").is_ok());
|
||||
assert_eq!(m.total, 17, "the total follows replacements");
|
||||
m.remove(p).unwrap();
|
||||
assert_eq!(m.total, 0);
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,29 @@
|
||||
//! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists
|
||||
//! it for the next launch to offer.
|
||||
//!
|
||||
//! In the browser the folder is [`BROWSER_DIR`] in the page's files ([`crate::memory`]), which
|
||||
//! the web host keeps in the browser's storage, and an entry is held while the page that wrote it
|
||||
//! is open (the page holds no file locks; another tab's entry counts as left behind).
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md. The engine decides when to autosave; this module only stores.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fs::{read_capped, write_atomic};
|
||||
use crate::fs::{create_dir_all, is_file, list, read_capped, remove as remove_file, write_atomic};
|
||||
use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result};
|
||||
|
||||
/// The recovery folder of the browser build.
|
||||
pub const BROWSER_DIR: &str = "/recovery";
|
||||
|
||||
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `FieldCraft/recovery` (`fieldcraft/` on
|
||||
/// Linux) in the platform's per-user data folder.
|
||||
/// Linux) in the platform's per-user data folder; [`BROWSER_DIR`] in the browser.
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Some(PathBuf::from(BROWSER_DIR));
|
||||
}
|
||||
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
|
||||
if let Some(d) = env("FIELDCRAFT_RECOVERY_DIR") {
|
||||
return Some(d);
|
||||
@@ -47,12 +56,28 @@ pub struct Entry {
|
||||
pub elements: usize,
|
||||
}
|
||||
|
||||
/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm).
|
||||
fn unix_now() -> u64 {
|
||||
/// Milliseconds since 1970 from the host, where the platform has no clock of its own (the
|
||||
/// browser's plain wasm); see [`set_clock`].
|
||||
static CLOCK: std::sync::OnceLock<fn() -> u64> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the module a clock (milliseconds since 1970) on a platform without one: the web host
|
||||
/// passes the browser's. Entries then record when they were saved and get ids no other page
|
||||
/// takes. The first clock given stays.
|
||||
pub fn set_clock(now_ms: fn() -> u64) {
|
||||
let _ = CLOCK.set(now_ms);
|
||||
}
|
||||
|
||||
/// Milliseconds since 1970 (0 without a clock).
|
||||
fn unix_now_ms() -> u64 {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return 0;
|
||||
return CLOCK.get().map_or(0, |now| now());
|
||||
}
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis().try_into().unwrap_or(u64::MAX)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Seconds since 1970 (0 without a clock).
|
||||
fn unix_now() -> u64 {
|
||||
unix_now_ms() / 1000
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
@@ -63,11 +88,106 @@ fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
|
||||
dir.join(format!("{id}.{ext}"))
|
||||
}
|
||||
|
||||
/// Whether a running app holds entry `id`'s lock.
|
||||
fn held(dir: &Path, id: &str) -> bool {
|
||||
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
|
||||
/// Entry locks on the desktop: `<id>.lock`, locked while the app runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod lock {
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{MAX_LISTED, file};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A held entry lock.
|
||||
#[derive(Debug)]
|
||||
pub struct Lock(#[allow(dead_code)] File);
|
||||
|
||||
/// Whether a running app holds entry `id`'s lock.
|
||||
pub fn held(dir: &Path, id: &str) -> bool {
|
||||
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
|
||||
}
|
||||
|
||||
pub fn take(dir: &Path, id: &str) -> Result<Lock> {
|
||||
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
Ok(Lock(lock))
|
||||
}
|
||||
|
||||
/// Remove the locks of apps that ended with nothing to recover.
|
||||
pub fn sweep(dir: &Path) {
|
||||
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("lock")
|
||||
&& !p.with_extension("json").exists()
|
||||
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
|
||||
{
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A process-unique part of new entry ids.
|
||||
pub fn instance() -> String {
|
||||
std::process::id().to_string()
|
||||
}
|
||||
|
||||
/// Let go of the lock; its file goes unless an autosave stays for the next launch.
|
||||
pub fn release(lock: Lock, dir: &Path, id: &str, keep: bool) {
|
||||
drop(lock);
|
||||
if !keep {
|
||||
let _ = fs::remove_file(file(dir, id, "lock"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry "locks" in the browser: the entries this page holds, kept in memory (a page that is
|
||||
/// gone holds none).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod lock {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
thread_local! {
|
||||
static HELD: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
|
||||
}
|
||||
|
||||
/// Releases the entry when dropped (the slot is gone).
|
||||
#[derive(Debug)]
|
||||
pub struct Lock(String);
|
||||
|
||||
impl Drop for Lock {
|
||||
fn drop(&mut self) {
|
||||
HELD.with(|h| h.borrow_mut().remove(&self.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn held(_dir: &Path, id: &str) -> bool {
|
||||
HELD.with(|h| h.borrow().contains(id))
|
||||
}
|
||||
|
||||
pub fn take(_dir: &Path, id: &str) -> Result<Lock> {
|
||||
HELD.with(|h| h.borrow_mut().insert(id.to_string()));
|
||||
Ok(Lock(id.to_string()))
|
||||
}
|
||||
|
||||
pub fn sweep(_dir: &Path) {}
|
||||
|
||||
/// The page's part of new entry ids: the milliseconds of the clock (the id has its seconds),
|
||||
/// so pages started in the same second take different ids (unless in the same millisecond).
|
||||
pub fn instance() -> String {
|
||||
format!("w{}", super::unix_now_ms() % 1000)
|
||||
}
|
||||
|
||||
pub fn release(lock: Lock, _dir: &Path, _id: &str, _keep: bool) {
|
||||
drop(lock);
|
||||
}
|
||||
}
|
||||
|
||||
use lock::held;
|
||||
|
||||
fn missing(id: &str) -> Error {
|
||||
Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::<String>()))
|
||||
}
|
||||
@@ -84,17 +204,14 @@ fn read_entry(path: &Path) -> Option<Entry> {
|
||||
|
||||
/// Entries left by apps that are no longer running, newest first.
|
||||
pub fn orphans(dir: &Path) -> Vec<Entry> {
|
||||
let mut out: Vec<Entry> = fs::read_dir(dir)
|
||||
let mut out: Vec<Entry> = list(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
.filter_map(|p| {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("json") {
|
||||
return None;
|
||||
}
|
||||
let entry = read_entry(&p)?;
|
||||
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file()
|
||||
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !is_file(&file(dir, &entry.id, EXTENSION))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -126,7 +243,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
return Err(Error::Io(format!("recovered model `{id}` belongs to a running app")));
|
||||
}
|
||||
for ext in ["json", EXTENSION, "lock"] {
|
||||
let _ = fs::remove_file(file(dir, id, ext));
|
||||
let _ = remove_file(&file(dir, id, ext));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -136,32 +253,21 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
pub struct Slot {
|
||||
dir: PathBuf,
|
||||
id: String,
|
||||
lock: Option<File>,
|
||||
lock: Option<lock::Lock>,
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Take a new entry in `dir` (created if needed), holding its lock.
|
||||
pub fn new(dir: &Path) -> Result<Slot> {
|
||||
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
|
||||
fs::create_dir_all(dir).map_err(io)?;
|
||||
// Locks of apps that ended with nothing to recover.
|
||||
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("lock")
|
||||
&& !p.with_extension("json").exists()
|
||||
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
|
||||
{
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
create_dir_all(dir)?;
|
||||
lock::sweep(dir);
|
||||
// Unique within the process too: an app started again in the same second (tests that
|
||||
// "crash" and restart) must not take over the crashed app's entry.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
let id = format!("{}-{}-{seq}", unix_now(), lock::instance());
|
||||
let lock = lock::take(dir, &id)?;
|
||||
Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false })
|
||||
}
|
||||
|
||||
@@ -189,7 +295,7 @@ impl Slot {
|
||||
pub fn clear(&mut self) {
|
||||
if self.has_entry {
|
||||
for ext in ["json", EXTENSION] {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
||||
let _ = remove_file(&file(&self.dir, &self.id, ext));
|
||||
}
|
||||
self.has_entry = false;
|
||||
}
|
||||
@@ -198,15 +304,16 @@ impl Slot {
|
||||
/// The app is closing: the lock is released; with no autosave the lock file goes too, while
|
||||
/// an autosave stays for the next launch to offer.
|
||||
pub fn close(mut self) {
|
||||
self.lock = None;
|
||||
if !self.has_entry {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
|
||||
if let Some(lock) = self.lock.take() {
|
||||
lock::release(lock, &self.dir, &self.id, self.has_entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
|
||||
@@ -98,7 +98,7 @@ impl Backend for Headless {
|
||||
}
|
||||
|
||||
fn commands(&mut self) -> Result<Value, String> {
|
||||
Ok(fieldcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect())
|
||||
Ok(fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect())
|
||||
}
|
||||
|
||||
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
|
||||
|
||||
@@ -243,6 +243,9 @@ fn a_batch_stops_at_the_first_error() {
|
||||
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0);
|
||||
let listed = ok(&mut s, "list_commands", json!({"filter": "SOLVE."}));
|
||||
assert!(listed.as_array().unwrap().iter().all(|c| c["id"].as_str().unwrap().starts_with("solve.")));
|
||||
// Each command's parameters as JSON Schema, for agents to build valid calls.
|
||||
let statics = listed.as_array().unwrap().iter().find(|c| c["id"] == "solve.static").unwrap();
|
||||
assert_eq!(statics["schema"]["properties"]["threads"]["maximum"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,7 @@ pub const MAX_STEPS: usize = 10_000;
|
||||
/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is
|
||||
/// left out.
|
||||
const MESHERS: &[(&str, &str, &str)] = &[
|
||||
("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"),
|
||||
("tet", "mesh.tet.generate", "constrained Delaunay tets of every body of the open design, one {body}, or an imported surface {surface}"),
|
||||
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
|
||||
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
|
||||
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
|
||||
|
||||