Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1fd58c6ce | ||
|
|
90948833e0 | ||
|
|
73987bdbae | ||
|
|
7df37d4b15 | ||
|
|
52c12e2ad7 | ||
|
|
1fcc7acf84 | ||
|
|
3aab588b8b | ||
|
|
f074e10442 | ||
|
|
05abcec6c2 | ||
|
|
012119e2c2 | ||
|
|
f5a84f5f74 | ||
|
|
910d437819 | ||
|
|
0e4ea5e099 | ||
|
|
ed2541170a | ||
|
|
0bbdd463ea | ||
|
|
65fb1112ab | ||
|
|
de8b7b6321 | ||
|
|
70c5c9b26f | ||
|
|
f4b6d1dbff | ||
|
|
2d5dfb2137 | ||
|
|
ad84a52ed2 | ||
|
|
5758dc7911 | ||
|
|
29af2aef91 | ||
|
|
696b64c30f | ||
|
|
a0f48c7207 | ||
|
|
9e38fe866d | ||
|
|
c26b142630 | ||
|
|
6bb3b04e60 | ||
|
|
b1180bf987 | ||
|
|
786121c21a | ||
|
|
e223a6256a | ||
|
|
fe557054ef | ||
|
|
fc85550534 | ||
|
|
3d8864c313 | ||
|
|
2443746b35 | ||
|
|
8b12cdd5c7 | ||
|
|
6b9e669c7e | ||
|
|
eb66b86e26 | ||
|
|
f78680e091 | ||
|
|
13b506150c | ||
|
|
bdd84a491d | ||
|
|
904dc3381b | ||
|
|
9dcbb9c9a6 | ||
|
|
8df4ea040c | ||
|
|
e8cada94d0 | ||
|
|
f8e0be970e | ||
|
|
94c98e0c0b | ||
|
|
ffb71de6e1 | ||
|
|
a11b971dae | ||
|
|
6bfa0b410a | ||
|
|
29e7a4ca31 | ||
|
|
794122a302 | ||
|
|
860707efa6 | ||
|
|
a84a8339ae | ||
|
|
f89545a66a | ||
|
|
0f99a1c687 | ||
|
|
9fc8e27933 | ||
|
|
b73a3bb976 | ||
|
|
7d689fb6a2 | ||
|
|
0b6b6c966c | ||
|
|
1fbb5581ea | ||
|
|
f6d227d8cb | ||
|
|
22f1006f71 | ||
|
|
1c16972cf2 | ||
|
|
b0d0fc9900 | ||
|
|
321add294b | ||
|
|
4c91f9b731 | ||
|
|
8409101483 | ||
|
|
e0d5adba23 | ||
|
|
06b98f01c7 | ||
|
|
2188d891ff | ||
|
|
1fc9d1e8d5 | ||
|
|
94a10a1bf3 | ||
|
|
14e2d25e68 | ||
|
|
4a60674530 | ||
|
|
ddde08fb4c | ||
|
|
a8f6be3bee | ||
|
|
872dfdb421 | ||
|
|
854ced2888 | ||
|
|
c8bd641e27 | ||
|
|
48cae3eb1d | ||
|
|
227c4544ed | ||
|
|
6baa189231 | ||
|
|
2ca5f84113 | ||
|
|
6da3055a1c | ||
|
|
6bd52c0b29 | ||
|
|
98d263ae54 | ||
|
|
0b7f3021d9 | ||
|
|
f5a0a1289a | ||
|
|
ecdff11e72 | ||
|
|
cf8ed71e4f | ||
|
|
581646bdac | ||
|
|
c9a765aa02 | ||
|
|
0220e2d8bb | ||
|
|
85f0905db1 | ||
|
|
2b1c52d021 | ||
|
|
794aa9f158 | ||
|
|
7e9366f726 | ||
|
|
f9981d305e | ||
|
|
8d2fa980a5 | ||
|
|
a91ac5baf9 | ||
|
|
32c5c1b307 | ||
|
|
6ca3233aaa | ||
|
|
945a2076c3 | ||
|
|
5770288f47 | ||
|
|
8871f6721d | ||
|
|
37f338c9e8 | ||
|
|
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 | ||
|
|
9469ac0853 | ||
|
|
fed27d816d | ||
|
|
e6688ef014 | ||
|
|
f11be61ff3 | ||
|
|
9e269a9f45 | ||
|
|
32c67d1988 | ||
|
|
bf42d182ff | ||
|
|
0d7c881eea | ||
|
|
858b6e9dee | ||
|
|
8172af67e7 | ||
|
|
8caa0d15cb | ||
|
|
ff6daa7cd6 | ||
|
|
f1253b4010 | ||
|
|
152092cfb4 | ||
|
|
71cc39213c | ||
|
|
a719ae7e5b | ||
|
|
7d2696057f | ||
|
|
3587b0de74 |
No files matched your search
@@ -1,2 +1,8 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
|
||||
# Windows release binaries link the C runtime statically, so they run without the VC++
|
||||
# redistributable (packaging/windows/package.ps1 builds with --target, which keeps the flag
|
||||
# off build scripts and proc-macros).
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -0,0 +1,2 @@
|
||||
# Every change to FieldCraft needs the maintainer's review before it merges.
|
||||
* @jyviko
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Bug report
|
||||
description: Something FieldCraft does wrong, a wrong result, or a crash
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: observed
|
||||
attributes:
|
||||
label: Observed failure
|
||||
description: Exactly what happened. Quote error messages and wrong numbers as they appear.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Numbered steps, or a script (`fieldcraft-cli run script.json`, control-channel JSON lines), with the sample, corpus part or attached `.fieldcraft` / `.solidcraft` file that shows it.
|
||||
placeholder: |
|
||||
1. fieldcraft --sample cantilever
|
||||
2. Mesh ▸ Tet ▸ OK
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What should happen instead, and why — a closed form, a published benchmark, the documentation, or how the rest of the app behaves. Never output from a commercial CAE product.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: investigation
|
||||
attributes:
|
||||
label: Investigation
|
||||
description: Anything you found about the cause — code you suspect, what you ruled out. Leave empty if you have not looked.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version and platform
|
||||
placeholder: "v0.5.8 (or commit), macOS 15 arm64"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Feature request
|
||||
description: A capability FieldCraft should have
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: current
|
||||
attributes:
|
||||
label: Current behaviour
|
||||
description: What you can and cannot do today, and what happens when you try.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: need
|
||||
attributes:
|
||||
label: What you need and why
|
||||
description: The analysis or workflow you are trying to do.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What FieldCraft should do, as precisely as you can.
|
||||
- type: textarea
|
||||
id: reference
|
||||
attributes:
|
||||
label: References
|
||||
description: Papers, textbooks, public specifications or published benchmarks that define the behaviour. Do not paste text, files or results from commercial CAE products.
|
||||
@@ -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,11 @@
|
||||
Closes #
|
||||
|
||||
## What and why
|
||||
|
||||
## How it was verified
|
||||
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
|
||||
|
||||
## Checklist
|
||||
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
|
||||
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
|
||||
- [ ] No code, text or data from GPL-family projects or commercial CAE products
|
||||
@@ -0,0 +1,27 @@
|
||||
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
|
||||
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
|
||||
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
|
||||
[README](https://github.com/jyviko/fieldcraft#readme) ·
|
||||
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
|
||||
[issues](https://github.com/jyviko/fieldcraft/issues)
|
||||
|
||||
**Which file?**
|
||||
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
|
||||
drag `FieldCraft.app` onto Applications. The command-line tool is in
|
||||
`fieldcraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
|
||||
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
|
||||
`fieldcraft-{version}-linux-x86_64.tar.gz` and run `bin/fieldcraft` (`bin/fieldcraft-cli` is the
|
||||
command-line tool).
|
||||
- **Windows 10+** (x64): unzip `fieldcraft-{version}-windows-x64.zip` and run `fieldcraft.exe`
|
||||
(`fieldcraft-cli.exe` is the command-line tool).
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing` (macOS:
|
||||
`shasum -a 256 -c SHA256SUMS.txt --ignore-missing`).
|
||||
|
||||
To start with a sample model, run the app with `--sample cantilever` (macOS:
|
||||
`/Applications/FieldCraft.app/Contents/MacOS/FieldCraft --sample cantilever`).
|
||||
|
||||
**Signing.** {macos_signing} The Linux and Windows builds are not signed; on Windows, SmartScreen
|
||||
may say "Windows protected your PC": choose **More info**, then **Run anyway**.
|
||||
|
||||
Every package carries `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `THIRD-PARTY-LICENSES.txt`.
|
||||
@@ -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,10 +1,21 @@
|
||||
name: CI
|
||||
|
||||
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): 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; so do Dependabot's (.github/dependabot.yml), which nobody tests
|
||||
# locally. The heavy job stays off pull requests.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**'
|
||||
- '!**/*.md'
|
||||
- '!docs/**'
|
||||
- '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.)
|
||||
@@ -15,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:
|
||||
@@ -26,7 +40,7 @@ permissions:
|
||||
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -34,6 +48,7 @@ env:
|
||||
jobs:
|
||||
ci:
|
||||
name: cargo xtask ci
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -44,12 +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.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -57,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:
|
||||
@@ -65,6 +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.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:
|
||||
@@ -73,13 +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.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
|
||||
@@ -0,0 +1,294 @@
|
||||
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
|
||||
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# 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 develop or tags never start it.
|
||||
#
|
||||
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
|
||||
# Linux x86_64 (glibc 2.35+) .tar.gz, .AppImage, .deb and .rpm packaging/linux
|
||||
# Windows x64 .zip with fieldcraft.exe and fieldcraft-cli.exe packaging/windows
|
||||
#
|
||||
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
|
||||
# otherwise (the release notes say which). Linux and Windows builds are not signed.
|
||||
#
|
||||
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
|
||||
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
|
||||
# the draft.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: "Create/update the draft release (main only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
draft: ${{ steps.v.outputs.draft }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve the version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ github.event_name == 'push' || inputs.draft }}
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
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/main ]; then
|
||||
echo "::error::Draft releases are built from main only"
|
||||
exit 1
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
signing: ${{ steps.package.outputs.signing }}
|
||||
env:
|
||||
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
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/main' }}
|
||||
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize and package (the dmg is mounted and checked)
|
||||
id: package
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove the temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
linux:
|
||||
name: Linux x86_64
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
# The .deb and .rpm builders (both MIT), pinned.
|
||||
CARGO_DEB_VERSION: 3.8.0
|
||||
CARGO_GENERATE_RPM_VERSION: 0.21.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils lintian rpm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-x86_64
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install cargo-deb and cargo-generate-rpm
|
||||
run: |
|
||||
cargo install --locked --version "$CARGO_DEB_VERSION" cargo-deb
|
||||
cargo install --locked --version "$CARGO_GENERATE_RPM_VERSION" cargo-generate-rpm
|
||||
- name: Build and package (tar.gz, AppImage, deb, rpm)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
base="fieldcraft-$VERSION-linux-x86_64"
|
||||
deb="dist/release/fieldcraft_${VERSION}_amd64.deb"
|
||||
rpm="dist/release/fieldcraft-$VERSION.x86_64.rpm"
|
||||
list="$(tar -tzf "dist/release/$base.tar.gz")"
|
||||
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
|
||||
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
|
||||
grep -Fx -- "$base/$f" <<<"$list"
|
||||
done
|
||||
# The .deb and .rpm carry the tar.gz's files under /usr (the .deb adds its copyright
|
||||
# file); their lists live in apps/fieldcraft/Cargo.toml, so a drift fails here.
|
||||
files() { grep -v '/$' | sed -E "s#^(\./|/|$base/)##; s#^usr/##" | grep -vx 'share/doc/fieldcraft/copyright' | sort; }
|
||||
diff <(files <<<"$list") <(dpkg-deb --fsys-tarfile "$deb" | tar -t | files)
|
||||
diff <(files <<<"$list") <(rpm -qpl "$rpm" | files)
|
||||
dpkg-deb --info "$deb"
|
||||
rpm -qpi "$rpm"
|
||||
echo "rpm requires:"; rpm -qpR "$rpm"
|
||||
# The release notes live on GitHub, so the package has no Debian changelog (a native
|
||||
# package built upstream, not by a distribution).
|
||||
lintian --info --fail-on error --suppress-tags no-changelog "$deb"
|
||||
ldd target/release/fieldcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- name: Install the .deb and .rpm in containers
|
||||
run: |
|
||||
# The oldest supported Ubuntu (the build's glibc) and a current Fedora. Recommended and
|
||||
# weak dependencies (GPU drivers) are skipped to keep it quick; the hard ones must resolve.
|
||||
# Both images leave out documentation when installing (dpkg path-exclude, dnf nodocs), so
|
||||
# the licences are checked in the package's file list rather than on disk.
|
||||
docker run --rm -v "$PWD/dist/release:/dist:ro" ubuntu:22.04 bash -euxc '
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /dist/*.deb
|
||||
fieldcraft-cli --version
|
||||
fieldcraft --version
|
||||
dpkg -L fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt'
|
||||
docker run --rm -v "$PWD/dist/release:/dist:ro" fedora:44 bash -euxc '
|
||||
dnf install -y -q --setopt=install_weak_deps=False /dist/*.rpm
|
||||
fieldcraft-cli --version
|
||||
fieldcraft --version
|
||||
rpm -ql fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt
|
||||
dnf install -y -q rpmlint
|
||||
rpmlint /dist/*.rpm || true'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
name: Windows x64
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
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/main' }}
|
||||
- name: Build and package (zip)
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/package.ps1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-x64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft release (or dry-run artifacts)
|
||||
needs: [version, macos, linux, windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
MACOS_SIGNING: ${{ needs.macos.outputs.signing }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Check the set, checksums and notes
|
||||
run: |
|
||||
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
|
||||
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
|
||||
"fieldcraft_${VERSION}_amd64.deb" "fieldcraft-$VERSION.x86_64.rpm" \
|
||||
"fieldcraft-$VERSION-windows-x64.zip"; do
|
||||
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
|
||||
done
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
|
||||
case "$MACOS_SIGNING" in
|
||||
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
|
||||
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
|
||||
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
|
||||
esac
|
||||
awk -v v="$VERSION" -v m="$macos" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); print }' \
|
||||
.github/release-notes.md >notes.md
|
||||
cat notes.md >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload the dry-run artifacts
|
||||
if: needs.version.outputs.draft != 'true'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: |
|
||||
artifacts/*
|
||||
notes.md
|
||||
if-no-files-found: error
|
||||
- name: Create or update the draft release
|
||||
if: needs.version.outputs.draft == 'true'
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="FieldCraft v$VERSION"
|
||||
if gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" --jq '.[].ref' | grep -Fxq "refs/tags/$tag"; then
|
||||
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
|
||||
exit 1
|
||||
fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != true ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
# A rebuild of the same version replaces the draft's assets (removing names it no longer
|
||||
# makes); its notes are kept.
|
||||
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes --prerelease="$PRERELEASE" -- *
|
||||
fi
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`: $(gh release view "$tag" --json url --jq .url)" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -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,29 +108,34 @@ 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 yourself with a rebase merge, which keeps
|
||||
`main` linear and every commit bisectable: `gh pr merge <N> --rebase --delete-branch`, run as
|
||||
its own command. Never squash. Then remove your worktree and delete your local branch.
|
||||
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.
|
||||
Then remove your worktree and delete your local branch.
|
||||
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
|
||||
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
|
||||
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
|
||||
oracle, the heavy tests and the advisories once per merge on `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,16 @@ 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 |
|
||||
| docs/media/examples/drop-test.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved drop-test sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/rubber-seal.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved rubber-seal sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/heated-bracket.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved heated-bracket sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/bracket-hex.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved bracket-hex sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -48,11 +57,10 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.element.add.svg, assets/icons/mesh.element.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.element.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.decompose.svg, assets/icons/mesh.hex.decompose.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.decompose.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.dominant.svg, assets/icons/mesh.hex.dominant.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.dominant.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.blocks.svg, assets/icons/mesh.hex.blocks.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.blocks.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.mapped.svg, assets/icons/mesh.hex.mapped.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.mapped.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.cells.svg, assets/icons/mesh.cells.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.cells.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.generate.svg, assets/icons/mesh.hex.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.octree.svg, assets/icons/mesh.hex.octree.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.octree.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.polycube.svg, assets/icons/mesh.hex.polycube.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.polycube.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.hex.sweep.svg, assets/icons/mesh.hex.sweep.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.sweep.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.import.orphan.svg, assets/icons/mesh.import.orphan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.import.orphan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.layers.svg, assets/icons/mesh.layers.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.layers.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -63,7 +71,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.seed.svg, assets/icons/mesh.size.seed.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.seed.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -133,12 +141,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 |
|
||||
@@ -153,6 +160,7 @@ is committed and listed here with its exports.
|
||||
| assets/app-icon/icon.iconset/icon_256x256@2x.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.iconset/icon_512x512.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/app-icon/icon.iconset/icon_512x512@2x.png | FieldCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
|
||||
| packaging/macos/README.txt, packaging/linux/io.github.jyviko.fieldcraft.desktop, packaging/macos/entitlements.plist | FieldCraft contributors | Original packaging text: the disk image's install note, the Linux desktop entry and the (empty) hardened-runtime entitlements | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/fonts/Inter-SemiBold.ttf | The Inter Project Authors | https://github.com/rsms/inter release v4.1 (Inter-4.1.zip, extras/ttf/Inter-SemiBold.ttf), unmodified; licence text in crates/ui-egui/fonts/OFL.txt; the interface's caps labels (theme::LABEL_FAMILY) | SIL OFL 1.1 |
|
||||
|
||||
## Adapted code
|
||||
@@ -165,11 +173,23 @@ is committed and listed here with its exports.
|
||||
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
|
||||
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
|
||||
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft/Cargo.toml (the run-time dependency lists of `[package.metadata.deb]` and `[package.metadata.generate-rpm]`) | SolidCraft `packaging/linux/nfpm.yaml` | MIT OR Apache-2.0 |
|
||||
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
|
||||
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
|
||||
| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Code of conduct
|
||||
|
||||
FieldCraft is built in the open by people with different backgrounds and levels of experience.
|
||||
Everyone taking part — in issues, pull requests, reviews and discussions — is expected to:
|
||||
|
||||
- be respectful and assume good intent; criticise work, never people;
|
||||
- keep discussion technical, specific and on topic;
|
||||
- not harass, insult, demean or discriminate against anyone, publicly or privately;
|
||||
- respect the project's decisions on scope, licences and clean-room rules.
|
||||
|
||||
The maintainer may edit or remove comments, close threads, and block anyone who does not follow
|
||||
this code. Report problems privately to the maintainer through the contact on the GitHub
|
||||
profile of [@jyviko](https://github.com/jyviko).
|
||||
|
||||
This code is our own, written in the spirit of the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/).
|
||||
@@ -1,10 +1,80 @@
|
||||
# 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.
|
||||
|
||||
1. Branch from `main`, keep a pull request to one topic.
|
||||
2. Run `cargo xtask ci` before pushing; CI runs the same gates.
|
||||
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
|
||||
|
||||
Every change starts from an issue, and only the maintainer merges.
|
||||
|
||||
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
|
||||
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
|
||||
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
|
||||
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
|
||||
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
|
||||
for 14 days goes back to the pool.
|
||||
|
||||
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
|
||||
|
||||
## Making the change
|
||||
|
||||
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.
|
||||
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
|
||||
parameters.
|
||||
4. Meshing and solver algorithms cite the paper they come from in the module docs.
|
||||
@@ -12,3 +82,8 @@ whether a person or an agent writes it.
|
||||
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
|
||||
|
||||
By contributing you agree your work is licensed under MIT OR Apache-2.0.
|
||||
|
||||
`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]]
|
||||
@@ -969,7 +1019,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1035,6 +1085,20 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1106,7 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1173,10 +1237,11 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-ui-egui",
|
||||
@@ -1187,7 +1252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1197,7 +1262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1206,7 +1271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1220,6 +1285,7 @@ dependencies = [
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-solver",
|
||||
"log",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1228,7 +1294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1239,7 +1305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1247,7 +1313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
@@ -1263,20 +1329,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
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.8"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1286,7 +1352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1297,7 +1363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"num-bigint",
|
||||
@@ -1309,7 +1375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1318,14 +1384,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1335,9 +1401,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-parallel",
|
||||
"serde",
|
||||
@@ -1346,7 +1413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1357,7 +1424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1374,6 +1441,25 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.8"
|
||||
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"
|
||||
@@ -1734,6 +1820,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"
|
||||
@@ -1770,6 +1865,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2007,7 +2104,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -3110,7 +3207,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3206,6 +3303,15 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3213,8 +3319,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]]
|
||||
@@ -3375,15 +3492,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -3391,8 +3509,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3408,8 +3526,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -3417,13 +3535,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -3433,13 +3551,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3450,8 +3570,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3460,8 +3580,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.8.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"solidcraft-geom",
|
||||
@@ -3557,7 +3677,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3658,6 +3778,21 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -3688,6 +3823,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -3772,7 +3913,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-meshalgo"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"array-macro",
|
||||
"derive_more",
|
||||
@@ -3822,7 +3963,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-shapeops"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"itertools 0.13.0",
|
||||
@@ -3837,7 +3978,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "truck-stepio"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=de1479029dd6821cffd28b70688a58e7f61f0cfd#de1479029dd6821cffd28b70688a58e7f61f0cfd"
|
||||
source = "git+https://github.com/bherbruck/solidcraft?rev=a629236650c44ee73edf36fab0dcd2385103a610#a629236650c44ee73edf36fab0dcd2385103a610"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"truck-geometry",
|
||||
@@ -3891,7 +4032,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3981,6 +4122,26 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4412,7 +4573,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4750,6 +4911,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@@ -4821,7 +4992,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.5.8"
|
||||
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"
|
||||
@@ -58,13 +58,13 @@ rfd = "0.17"
|
||||
|
||||
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
|
||||
# (plan/adr/0001-libraries.md).
|
||||
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
# Runs SolidCraft command scripts (the test corpus recipes); only behind fieldcraft-geometry's
|
||||
# `scripts` feature.
|
||||
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -88,6 +88,14 @@ opt-level = 1
|
||||
[profile.dev.package.fieldcraft-lbm]
|
||||
opt-level = 3
|
||||
|
||||
# Meshing and the sparse solver are compute loops too: the tet quality stage and the
|
||||
# mesh-convergence oracle cases (tests/oracle) run in tests.
|
||||
[profile.dev.package.fieldcraft-mesh]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.fieldcraft-solver]
|
||||
opt-level = 3
|
||||
|
||||
# Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's
|
||||
# debug-only checks reject valid geometry (SolidCraft builds its tests the same way).
|
||||
[profile.ci]
|
||||
@@ -104,6 +112,12 @@ opt-level = 2
|
||||
[profile.ci.package.fieldcraft-lbm]
|
||||
opt-level = 3
|
||||
|
||||
[profile.ci.package.fieldcraft-mesh]
|
||||
opt-level = 3
|
||||
|
||||
[profile.ci.package.fieldcraft-solver]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
@@ -114,10 +128,10 @@ debug = "line-tables-only"
|
||||
# reach a dependent workspace, so FieldCraft applies the same ones from the same pinned commit;
|
||||
# keep this list and the rev in step with SolidCraft's [patch.crates-io].
|
||||
[patch.crates-io]
|
||||
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
# egui-wgpu with SolidCraft's fixes (vendor/egui-wgpu there): MSAA clamped to what the device
|
||||
# supports and 1x on OpenGL adapters, so the requested 4x never fails pipeline validation at start.
|
||||
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "de1479029dd6821cffd28b70688a58e7f61f0cfd" }
|
||||
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
|
||||
@@ -6,8 +6,17 @@ explicit, thermal and lattice Boltzmann solvers, and decks for Abaqus, Nastran a
|
||||
desktop app, the command line and AI agents (over MCP) drive the same commands.
|
||||
|
||||
**Status:** early but working end to end — open a part, mesh it, define the analysis, solve,
|
||||
see the results. Hex meshing is the centre of gravity; the next large step is the nonlinear
|
||||
implicit solver that contact, elastomers and composites need. See [ROADMAP.md](ROADMAP.md).
|
||||
see the results — with a nonlinear implicit solver (large deformation, plasticity, rubber) now
|
||||
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
|
||||
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
|
||||
**Want to help?** Pick an issue labelled
|
||||
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
|
||||
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
|
||||
[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).*
|
||||
|
||||

|
||||
|
||||
@@ -17,34 +26,91 @@ 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
|
||||
cells in SolidCraft, FieldCraft meshes it all-hex as one conforming mesh — O-grids round the holes,
|
||||
graded through the fillet — and its hex20 tip deflection matches a fine tet10 solution to 0.1 %.
|
||||
The same part also meshes all-hex automatically with the octree mesher (`mesh.hex.octree`).
|
||||
|
||||
| Cells | Conforming hex mesh | Von Mises (clamp and load drawn) |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
## What it does today
|
||||
|
||||
- **Geometry in:** SolidCraft designs (`.solidcraft`) with persistent face, edge and body names,
|
||||
and face-tagged 3MF meshes; surface checks (weld, manifold, orientation, self-intersection).
|
||||
- **Meshing:** constrained Delaunay tetrahedra (tet4, tet10) with exact predicates and a
|
||||
certified validity gate; structured hex by sweeping, revolving and mapping (hex8, hex20; 16 of
|
||||
the 21 corpus parts mesh all-hex); 2D quad and tri meshes; quality metrics per element and set.
|
||||
certified validity gate; Delaunay refinement, smoothing and sliver removal; structured hex by
|
||||
sweeping, revolving and mapping (hex8, hex20), and conforming multi-block hex across a part
|
||||
split into cells (`mesh.hex.blocks`); unstructured octree all-hex for any part
|
||||
(`mesh.hex.octree`); 2D quad and tri meshes; quality metrics per element and set.
|
||||
- **Elements:** solids (tet4, tet10, wedge6, hex8 with incompatible modes, hex20 full and
|
||||
reduced, hybrid u–p for near-incompressible materials), plane stress, plane strain and
|
||||
axisymmetric, trusses, Timoshenko beams, MITC3/MITC4 shells, springs, dashpots and point masses.
|
||||
- **Model:** sets bound to geometry names, so they survive remeshing; isotropic and orthotropic
|
||||
elasticity, J2 plasticity, Johnson–Cook damage, thermal properties; fixed, displacement,
|
||||
elasticity, J2 plasticity (isotropic and kinematic hardening, Johnson–Cook), Johnson–Cook
|
||||
damage, neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, thermal properties; fixed, displacement,
|
||||
symmetry and temperature conditions; forces, moments, pressure, gravity, centrifugal and heat
|
||||
loads; reference points with kinematic and distributing couplings; undo, redo and a
|
||||
text-diffable `.fieldcraft` file.
|
||||
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); steady and
|
||||
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); nonlinear static
|
||||
by Newton–Raphson with automatic increments — large deformation (total Lagrangian) on every
|
||||
solid element, follower pressure, finite- and small-strain plasticity sharing one return map
|
||||
with the explicit solver, rubber and thermal strain, with measured quadratic convergence; steady and
|
||||
transient heat transfer and coupled thermal stress; explicit dynamics with plasticity, damage,
|
||||
erosion and rigid walls; D3Q19 lattice Boltzmann flow with forces on the part's faces. Results
|
||||
never depend on the thread count.
|
||||
- **Decks:** Abaqus, Nastran and LS-DYNA writers, each refusing by name what it cannot express.
|
||||
- **Results:** contours of stress, strain and displacement with a legend, deformed shapes, mode
|
||||
shapes, explicit frames, probes and reaction sums; the analysis setup drawn in the viewport.
|
||||
shapes, explicit and nonlinear frames, probes and reaction sums; supports, loads, reference
|
||||
points and couplings drawn in the viewport.
|
||||
- **Verification:** every capability is checked against closed forms and published benchmarks
|
||||
(NAFEMS, MacNeal–Harder, Scordelis–Lo) in [`tests/oracle`](tests/oracle), with results frozen
|
||||
so they cannot drift: [docs/oracle.md](docs/oracle.md).
|
||||
(NAFEMS, MacNeal–Harder, Scordelis–Lo, the elastica, Hill's plastic cylinder, Rivlin's tube) in [`tests/oracle`](tests/oracle), with results frozen
|
||||
so they cannot drift: 170 of 176 quantities pass in 50 cases, the 6 others documented known
|
||||
failures ([docs/oracle.md](docs/oracle.md)).
|
||||
- **Driving it:** a desktop app in the Craft family's layout, a CLI, a JSON control channel and an
|
||||
MCP server; every capability is a command ([docs/features.md](docs/features.md)).
|
||||
|
||||

|
||||
|
||||
## Examples
|
||||
|
||||
Ready-to-solve samples: open one from **File ▸ Samples** (or the start screen), or start the app
|
||||
with `fieldcraft --sample NAME`, and press **Solve**. Each opens and solves within half a minute and is checked
|
||||
against a closed form or a reference solution ([docs/examples.md](docs/examples.md)).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/drop-test.png" alt="Drop test" width="100%"><br><b>Drop test</b> (<code>drop-test</code>): an aluminium bracket dropped 2 m onto a tilted floor; explicit dynamics, the strike's stress waves</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/rubber-seal.png" alt="Rubber seal" width="100%"><br><b>Rubber seal</b> (<code>rubber-seal</code>): a Mooney–Rivlin gasket ring squeezed 25 %; nonlinear static, large strain</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/heated-bracket.png" alt="Heated bracket" width="100%"><br><b>Heated bracket</b> (<code>heated-bracket</code>): a clamped steel bracket heated at one end; steady heat and thermal stress</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/bracket-hex.png" alt="Hex-meshed bracket" width="100%"><br><b>Hex-meshed bracket</b> (<code>bracket-hex</code>): the bracket split into 30 cells, meshed all-hex (hex20), von Mises</td>
|
||||
<td width="33%" valign="top"><img src="docs/media/examples/crank-arm.png" alt="Bicycle crank arm" width="100%"><br><b>Bicycle crank arm</b> (<code>crank-arm</code>): an aluminium crank meshed structured all-hex (hex20) from a partitioned half and its mirror image, a rider's weight on the pedal; root bending within 0.3 % of M/Z</td>
|
||||
<td width="33%" valign="top"><i>No picture yet: the viewport does not draw flow results (M12).</i><br><b>Flow past a bracket</b> (<code>flow-past</code>): air past an L-bracket on the floor; lattice Boltzmann flow, the drag and the force on each face</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
@@ -59,12 +125,14 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Examples](docs/examples.md): the ready-to-solve samples and their checks
|
||||
- [Architecture](docs/architecture.md): crates, layers and the meshing pipeline
|
||||
- [Element library](docs/elements.md) and [verification and validation](docs/verification.md)
|
||||
- [Capability checklist](docs/parity.md) and [roadmap](ROADMAP.md)
|
||||
- [Libraries and licences](docs/adr/0001-libraries.md)
|
||||
- [AI agents (MCP)](docs/mcp.md): `claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp`
|
||||
- [Control protocol](docs/control-protocol.md): driving the desktop app
|
||||
- [Releasing](docs/releasing.md): how a release is built, verified and published
|
||||
- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md)
|
||||
|
||||
## License
|
||||
|
||||
@@ -28,20 +28,20 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
|---|---|---|
|
||||
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
|
||||
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, few-ulp rounding repair checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
|
||||
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
|
||||
| M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | 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 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 |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: the half cut into seven cells, each swept structured, seeded to merge, and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); pyramid transitions; 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: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
|
||||
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (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; 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, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
|
||||
|
||||
## Next
|
||||
|
||||
@@ -60,7 +60,7 @@ In progress, in this order of landing:
|
||||
|
||||
1. Tet quality: sliver repair and Delaunay refinement to a size field, so curved and filleted parts
|
||||
(the filleted bracket) solve on tets (M2).
|
||||
2. Octree all-hex for any part (M6), the project's main meshing goal.
|
||||
2. Octree all-hex (M6): concave transitions (Schneiders' modified templates or Livesu's dual schemes) so curved and thin-walled parts mesh without filling their hull with fine cells.
|
||||
3. The analysis video regenerated with the setup's symbols (done: support, load, reference-point
|
||||
and coupling symbols in the viewport and headless renders), captions and an orbit (screenshot
|
||||
camera and the ribbon following the command: done).
|
||||
@@ -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; implicit mortar contact pairs (augmented Lagrangian, Coulomb friction) in nonlinear static steps; ties between non-matching meshes (dual mortar); couplings, springs, dashpots | Implicit self contact, 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)
|
||||
@@ -304,13 +312,13 @@ smithay-client-toolkit 0.19.2 (MIT)
|
||||
smithay-client-toolkit 0.20.0 (MIT)
|
||||
smithay-clipboard 0.7.3 (MIT)
|
||||
smol_str 0.2.2 (MIT OR Apache-2.0)
|
||||
solidcraft-doc 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-engine 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-geom 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-io 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-kernel 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-render 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-sketch 0.8.0 (MIT OR Apache-2.0)
|
||||
solidcraft-doc 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-engine 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-geom 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-io 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-kernel 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-render 0.11.0 (MIT OR Apache-2.0)
|
||||
solidcraft-sketch 0.11.0 (MIT OR Apache-2.0)
|
||||
spade 2.15.1 (MIT OR Apache-2.0)
|
||||
spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0)
|
||||
static_assertions 1.1.0 (MIT OR Apache-2.0)
|
||||
@@ -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
|
||||
================================================================================
|
||||
@@ -13088,13 +13308,13 @@ objc2-user-notifications 0.2.2: MIT (https://github.com/madsmtm/objc2)
|
||||
profiling 1.0.18: MIT OR Apache-2.0 (https://github.com/aclysma/profiling)
|
||||
r-efi 5.3.0: MIT OR Apache-2.0 OR LGPL-2.1-or-later (https://github.com/r-efi/r-efi)
|
||||
r-efi 6.0.0: MIT OR Apache-2.0 OR LGPL-2.1-or-later (https://github.com/r-efi/r-efi)
|
||||
solidcraft-doc 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-engine 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-geom 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-io 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-kernel 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-render 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-sketch 0.8.0: MIT OR Apache-2.0
|
||||
solidcraft-doc 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-engine 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-geom 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-io 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-kernel 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-render 0.11.0: MIT OR Apache-2.0
|
||||
solidcraft-sketch 0.11.0: MIT OR Apache-2.0
|
||||
spirv 0.4.0+sdk-1.4.341.0: Apache-2.0 (https://github.com/gfx-rs/rspirv)
|
||||
truck-base 0.5.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-derivers 0.1.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! fieldcraft-cli run <script.json>
|
||||
//! fieldcraft-cli features
|
||||
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! fieldcraft-cli --version
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -14,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
|
||||
@@ -26,6 +28,7 @@ usage:
|
||||
--automation-read-root DIR files may be read only inside DIR
|
||||
--automation-write-root DIR files may be written only inside DIR (with either root,
|
||||
the access a root left out is not granted)
|
||||
fieldcraft-cli --version print the version
|
||||
";
|
||||
|
||||
/// Largest script accepted (bytes).
|
||||
@@ -34,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);
|
||||
@@ -46,6 +53,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
|
||||
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
|
||||
Some("-V" | "--version") => {
|
||||
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! `fieldcraft-cli --version`, which the release packaging runs to check each build.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_workspace_version() {
|
||||
for flag in ["--version", "-V"] {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).arg(flag).output().unwrap();
|
||||
assert!(out.status.success(), "{flag}: {out:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), format!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
}
|
||||
@@ -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,328 @@
|
||||
//! 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,
|
||||
// Preferences last for the page's run.
|
||||
prefs_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(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fieldcraft"
|
||||
description = "FieldCraft desktop app"
|
||||
description = "Finite element modeling: meshing, model setup, solver decks and a linear solver"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -18,3 +18,87 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# The Windows executable's icon resource (build.rs).
|
||||
embed-resource = "3.0.12"
|
||||
|
||||
# Linux .deb and .rpm packages (cargo-deb, cargo-generate-rpm), built by packaging/linux/package.sh
|
||||
# from the release binaries it has already built and stripped; it sets the version and the file
|
||||
# names. The file list matches the tar.gz's (package.sh's staged tree; the release workflow
|
||||
# compares them).
|
||||
# The binaries link only glibc and libgcc_s. Windowing (Wayland, X11, xkbcommon), the GPU (Vulkan
|
||||
# or EGL) and the file dialogs (the XDG desktop portal) are opened at run time, so they are no ELF
|
||||
# NEEDED entries and the tools cannot find them: they are listed here.
|
||||
[package.metadata.deb]
|
||||
name = "fieldcraft"
|
||||
maintainer = "FieldCraft contributors <jyviko@users.noreply.github.com>"
|
||||
copyright = "FieldCraft contributors"
|
||||
section = "science"
|
||||
priority = "optional"
|
||||
extended-description = """\
|
||||
FieldCraft turns geometry into analysis-ready finite element models: volume \
|
||||
meshes, materials, sections, boundary conditions, loads and steps. It writes \
|
||||
Abaqus input, Nastran bulk data and LS-DYNA keyword decks, and solves linear \
|
||||
static and modal problems with its own solver. Includes fieldcraft-cli, a \
|
||||
headless command runner and MCP server."""
|
||||
depends = "$auto, libxkbcommon0, libxkbcommon-x11-0, libwayland-client0, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libvulkan1 | libegl1"
|
||||
recommends = "mesa-vulkan-drivers, libegl-mesa0, xdg-desktop-portal"
|
||||
assets = [
|
||||
["target/release/fieldcraft", "usr/bin/", "755"],
|
||||
["target/release/fieldcraft-cli", "usr/bin/", "755"],
|
||||
["../../packaging/linux/io.github.jyviko.fieldcraft.desktop", "usr/share/applications/", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_16x16.png", "usr/share/icons/hicolor/16x16/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_32x32.png", "usr/share/icons/hicolor/32x32/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_32x32@2x.png", "usr/share/icons/hicolor/64x64/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_128x128.png", "usr/share/icons/hicolor/128x128/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_256x256.png", "usr/share/icons/hicolor/256x256/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_512x512.png", "usr/share/icons/hicolor/512x512/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../assets/app-icon/icon.iconset/icon_512x512@2x.png", "usr/share/icons/hicolor/1024x1024/apps/io.github.jyviko.fieldcraft.png", "644"],
|
||||
["../../README.md", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../LICENSE-MIT", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../LICENSE-APACHE", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../NOTICE", "usr/share/doc/fieldcraft/", "644"],
|
||||
["../../THIRD-PARTY-LICENSES.txt", "usr/share/doc/fieldcraft/", "644"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
name = "fieldcraft"
|
||||
license = "MIT OR Apache-2.0"
|
||||
url = "https://github.com/jyviko/fieldcraft"
|
||||
assets = [
|
||||
{ source = "target/release/fieldcraft", dest = "/usr/bin/fieldcraft", mode = "755" },
|
||||
{ source = "target/release/fieldcraft-cli", dest = "/usr/bin/fieldcraft-cli", mode = "755" },
|
||||
{ source = "../../packaging/linux/io.github.jyviko.fieldcraft.desktop", dest = "/usr/share/applications/io.github.jyviko.fieldcraft.desktop", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_16x16.png", dest = "/usr/share/icons/hicolor/16x16/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_32x32@2x.png", dest = "/usr/share/icons/hicolor/64x64/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_256x256.png", dest = "/usr/share/icons/hicolor/256x256/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../assets/app-icon/icon.iconset/icon_512x512@2x.png", dest = "/usr/share/icons/hicolor/1024x1024/apps/io.github.jyviko.fieldcraft.png", mode = "644" },
|
||||
{ source = "../../README.md", dest = "/usr/share/doc/fieldcraft/README.md", mode = "644", doc = true },
|
||||
{ source = "../../LICENSE-MIT", dest = "/usr/share/doc/fieldcraft/LICENSE-MIT", mode = "644", doc = true },
|
||||
{ source = "../../LICENSE-APACHE", dest = "/usr/share/doc/fieldcraft/LICENSE-APACHE", mode = "644", doc = true },
|
||||
{ source = "../../NOTICE", dest = "/usr/share/doc/fieldcraft/NOTICE", mode = "644", doc = true },
|
||||
{ source = "../../THIRD-PARTY-LICENSES.txt", dest = "/usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt", mode = "644", doc = true },
|
||||
]
|
||||
# The binaries' ELF dependencies (glibc, libgcc_s) are found automatically; these are opened at run
|
||||
# time. Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL
|
||||
# and openSUSE.
|
||||
require-sh = false
|
||||
|
||||
[package.metadata.generate-rpm.requires]
|
||||
"libxkbcommon.so.0()(64bit)" = "*"
|
||||
"libxkbcommon-x11.so.0()(64bit)" = "*"
|
||||
"libwayland-client.so.0()(64bit)" = "*"
|
||||
"libX11.so.6()(64bit)" = "*"
|
||||
"libX11-xcb.so.1()(64bit)" = "*"
|
||||
"libxcb.so.1()(64bit)" = "*"
|
||||
"libXcursor.so.1()(64bit)" = "*"
|
||||
"libXi.so.6()(64bit)" = "*"
|
||||
"(libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))" = ""
|
||||
|
||||
[package.metadata.generate-rpm.recommends]
|
||||
mesa-vulkan-drivers = "*"
|
||||
xdg-desktop-portal = "*"
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Windows builds embed the application icon (assets/app-icon/icon.ico) in `fieldcraft.exe`, so
|
||||
//! Explorer, the taskbar and shortcuts show it. Other targets need nothing here.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let icon = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?).join("../../assets/app-icon/icon.ico");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed={}", icon.display());
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
let out = PathBuf::from(std::env::var("OUT_DIR")?);
|
||||
let rc = out.join("fieldcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
|
||||
// Missing resource tools are a build error, rather than silently shipping a generic icon.
|
||||
embed_resource::compile_for(&rc, ["fieldcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,11 +17,15 @@ use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::{self, Autosaver};
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
|
||||
const USAGE: &str = "usage: fieldcraft [--sample [parts|cantilever|impact]] [--control PORT] [FILE…] (FILE: .fieldcraft, .solidcraft or .3mf)";
|
||||
const USAGE: &str =
|
||||
"usage: fieldcraft [--sample [NAME]] [--control PORT] [FILE…] (NAME: a sample, docs/examples.md; FILE: .fieldcraft, .solidcraft or .3mf)";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// The window draws its own caption buttons where the OS ones would sit apart from the app bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
const MSAA: u16 = 4;
|
||||
/// The reverse-DNS application id: the Wayland app_id / X11 class, matched to the packaged
|
||||
/// `.desktop` entry (packaging/linux), and the macOS bundle id (packaging/macos/Info.plist.in).
|
||||
const APP_ID: &str = "io.github.jyviko.fieldcraft";
|
||||
|
||||
/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon).
|
||||
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png");
|
||||
@@ -91,6 +95,7 @@ fn services() -> Services {
|
||||
})),
|
||||
// Beside the recovery folder in the per-user data folder.
|
||||
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
|
||||
prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,9 +150,15 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if args.control.is_some() {
|
||||
// A run driven by automation keeps its autosaves (and recent files and preferences, which
|
||||
// live beside them) in a temporary folder, never the user's.
|
||||
fieldcraft_io::recovery::use_automation_dir();
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FieldCraft")
|
||||
.with_app_id(APP_ID)
|
||||
.with_icon(app_icon())
|
||||
.with_inner_size([1400.0, 900.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
|
||||
@@ -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,14 +15,16 @@ 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) |
|
||||
|
||||
The desktop app embeds `icon.iconset/icon_128x128@2x.png` as its window and Dock icon
|
||||
(`apps/fieldcraft/src/main.rs`). There is no macOS bundle yet; packaging will copy
|
||||
`Assets.car` and `icon.icns` into `Contents/Resources` and name `FieldCraft` in
|
||||
`CFBundleIconName` / `CFBundleIconFile`.
|
||||
(`apps/fieldcraft/src/main.rs`). The release packages (docs/releasing.md) use the rest:
|
||||
`packaging/macos/package.sh` copies `Assets.car` and `icon.icns` (as `FieldCraft.icns`) into
|
||||
`FieldCraft.app/Contents/Resources`, named by `CFBundleIconName` / `CFBundleIconFile`;
|
||||
`apps/fieldcraft/build.rs` embeds `icon.ico` in `fieldcraft.exe`; and
|
||||
`packaging/linux/package.sh` installs the iconset PNGs as the hicolor icons.
|
||||
|
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 |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.blocks</title>
|
||||
<title>mesh.cells</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
Before Width: | Height: | Size: 1008 B After Width: | Height: | Size: 1003 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.mapped</title>
|
||||
<title>mesh.hex.generate</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 884 B |
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.hex.sweep</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 8 L12 17 L3 17 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 8 L7.5 17 M3 12.5 L12 12.5" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L8 4 L21 4 L21 13 L12 17 M12 8 L21 4" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 13 L20 10 M18.17 12.63 L20 10 L16.8 9.88" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 825 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.size.seed</title>
|
||||
<title>mesh.seed.edge</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 19 C7 5 17 5 21 19" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.7 19 C1.7 18.282 2.282 17.7 3 17.7 C3.718 17.7 4.3 18.282 4.3 19 C4.3 19.718 3.718 20.3 3 20.3 C2.282 20.3 1.7 19.718 1.7 19 Z" fill="#4f9ce8"/>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
{"shapes": [{"rect": [3, 8, 9, 9], "fill": "soft", "stroke": "line"}, {"d": "M7.5 8 L7.5 17 M3 12.5 L12 12.5", "stroke": "line", "width": 1}, {"d": "M3 8 L8 4 L21 4 L21 13 L12 17 M12 8 L21 4", "stroke": "line", "width": 1.25}, {"d": "M14 13 L20 10 M18.17 12.63 L20 10 L16.8 9.88", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -13,8 +13,25 @@
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! # Ties
|
||||
//!
|
||||
//! Each tie is `*TIE, NAME=…, ADJUST=NO` with the secondary surface first and its position
|
||||
//! tolerance when it has one: FieldCraft ties the nodes where they are (no adjustment).
|
||||
//!
|
||||
//! # Steps and the activity table
|
||||
//!
|
||||
@@ -130,8 +147,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 +191,8 @@ 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.ties(model)?;
|
||||
d.steps(model, r)?;
|
||||
Ok(d.out)
|
||||
}
|
||||
@@ -957,6 +976,92 @@ 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(())
|
||||
}
|
||||
|
||||
/// Ties: `*TIE` per tie, secondary surface first.
|
||||
fn ties(&mut self, model: &Model) -> Result<()> {
|
||||
if model.ties().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.comment("Ties: each secondary surface bonded to its main surface");
|
||||
for (name, t) in model.ties() {
|
||||
let tolerance = t.position_tolerance.map_or(String::new(), |v| format!(", POSITION TOLERANCE={v:?}"));
|
||||
self.line(&format!("*TIE, NAME={name}, ADJUST=NO{tolerance}"));
|
||||
self.line(&format!("{}, {}", t.secondary, t.main));
|
||||
}
|
||||
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 +1211,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 +1264,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,13 @@
|
||||
//! - 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; a tie as `*CONTACT_TIED_SURFACE_TO_SURFACE` likewise), 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 +76,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 +184,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 +277,13 @@ 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());
|
||||
}
|
||||
for t in model.ties().values() {
|
||||
segment_sets.extend([t.main.as_str(), t.secondary.as_str()]);
|
||||
}
|
||||
let mut gravity_sets: BTreeSet<Option<&str>> = BTreeSet::new();
|
||||
for step in model.steps() {
|
||||
if matches!(step.procedure, Procedure::ExplicitDynamic(_)) {
|
||||
@@ -689,6 +704,57 @@ 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)]);
|
||||
}
|
||||
// Ties: a tied surface-to-surface contact between the segment sets, secondary first.
|
||||
for (name, t) in model.ties() {
|
||||
let sid = |set: &str| plan.sets.get(set).copied().ok_or_else(|| broken(format!("tie `{name}`: set `{set}`")));
|
||||
d.comment(&format!("tie `{name}`"));
|
||||
if t.position_tolerance.is_some() {
|
||||
d.comment("FieldCraft's position tolerance: LS-DYNA ties the nodes within its own search distance");
|
||||
}
|
||||
d.line("*CONTACT_TIED_SURFACE_TO_SURFACE");
|
||||
d.header(&["surfa", "surfb", "surfatyp", "surfbtyp"]);
|
||||
d.card([Field::from(sid(&t.secondary)?), Field::from(sid(&t.main)?), Field::from(0), Field::from(0)]);
|
||||
d.header(&["fs", "fd", "dc"]);
|
||||
d.card([Field::from(0.0), Field::from(0.0), Field::from(0.0)]);
|
||||
d.header(&["sfsa", "sfsb"]);
|
||||
d.card([Field::from(1.0), Field::from(1.0)]);
|
||||
}
|
||||
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 +893,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 +927,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,12 @@ 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 name in self.model.ties().keys() {
|
||||
self.problems.insert(format!("tie `{name}` (this writer does not write glued contact yet)"));
|
||||
}
|
||||
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,102 @@
|
||||
//! 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, Tie, 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.add_tie("glue", Tie { position_tolerance: Some(0.05), ..Tie::new("lower", "upper") }).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",
|
||||
"*TIE, NAME=glue, ADJUST=NO, POSITION TOLERANCE=0.05\nupper, lower\n",
|
||||
] {
|
||||
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}");
|
||||
}
|
||||
assert_eq!(after("*CONTACT_TIED_SURFACE_TO_SURFACE"), ["4 3 0 0", "0.0 0.0 0.0", "1.0 1.0"], "{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}");
|
||||
}
|
||||
assert!(what.contains("tie `glue`"), "{what}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-model = { workspace = true }
|
||||
fieldcraft-geometry = { workspace = true }
|
||||
# Samples build their geometry from SolidCraft scripts (sample.rs).
|
||||
fieldcraft-geometry = { workspace = true, features = ["scripts"] }
|
||||
fieldcraft-mesh = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-decks = { workspace = true }
|
||||
@@ -26,6 +27,5 @@ thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
png = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
# Multi-body designs from SolidCraft scripts in tests.
|
||||
fieldcraft-geometry = { workspace = true, features = ["scripts"] }
|
||||
@@ -0,0 +1,27 @@
|
||||
{"commands": [
|
||||
{"command": "parameters.change", "params": {"name": "arm_thickness", "expression": "16 mm"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "ArmProfile"}},
|
||||
{"command": "sketch.line", "params": {"points": [[15.471735240951828, -18.362928548901984], [160.67227699330076, -12.384082712040556]], "ids": ["lo"]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [160.91912663845798, -18.3790026658586], "start": [161.58293237729853, -12.415835559271503], "end": [160.67227699330076, -12.384082712040556]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [170.0, 0], "start": [161.58293237729853, -12.415835559271503], "end": [161.58293237729853, 12.415835559271503]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [160.91912663845798, 18.3790026658586], "start": [160.67227699330076, 12.384082712040556], "end": [161.58293237729853, 12.415835559271503]}},
|
||||
{"command": "sketch.line", "params": {"points": [[160.67227699330076, 12.384082712040556], [15.471735240951828, 18.362928548901984]], "ids": ["hi"]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [15.883151316213851, 28.354461805265395], "start": [13.455063072993756, 18.65372021077189], "end": [15.471735240951828, 18.362928548901984]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [0, 0], "start": [13.455063072993756, 18.65372021077189], "end": [13.455063072993756, -18.65372021077189]}},
|
||||
{"command": "sketch.arc.center_point", "params": {"center": [15.883151316213851, -28.354461805265395], "start": [15.471735240951828, -18.362928548901984], "end": [13.455063072993756, -18.65372021077189]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "arm_thickness", "body_name": "CrankArm", "name": "Arm"}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 46.0, "height": 22, "base": [0, 0, 0], "operation": "join", "targets": ["CrankArm"], "name": "SpindleBoss"}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 30.0, "height": 20, "base": [170.0, 0, 0], "operation": "join", "targets": ["CrankArm"], "name": "PedalBoss"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "BackPocket"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [48, -7], "p1": [130, 7]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 8, "operation": "cut", "targets": ["CrankArm"], "name": "Pocket"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "SpindleSquare"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [6.35, 6.35]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"through_all": true, "operation": "cut", "targets": ["CrankArm"], "name": "SpindleTaper"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[6.35, 6.35, 10], [-6.35, 6.35, 10], [-6.35, -6.35, 10], [6.35, -6.35, 10]], "radius": 1.5, "name": "SquareCornerFillet"}},
|
||||
{"command": "solid.hole", "params": {"position": [170.0, 0, 20], "diameter": 14, "name": "PedalHole"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[48, -7, 4], [48, 7, 4], [130, -7, 4], [130, 7, 4]], "radius": 5, "name": "PocketCornerFillet"}}
|
||||
]}
|
||||
@@ -84,6 +84,7 @@ mod tests {
|
||||
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
|
||||
assert_eq!(listed[0]["id"], id);
|
||||
assert_eq!(listed[0]["nodes"], 1);
|
||||
assert!(listed[0]["age"].as_u64().is_some_and(|a| a < 60), "{listed}");
|
||||
let mut next = Session::default();
|
||||
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
|
||||
assert_eq!(r["nodes"], 1);
|
||||
|
||||
@@ -155,6 +155,9 @@ pub struct Capability {
|
||||
pub status: Status,
|
||||
/// Related commands in the entry's drop-down (edit, delete…).
|
||||
pub more: &'static [&'static str],
|
||||
/// Commands the entry's own form covers (an option of it does the same): claimed by the entry
|
||||
/// for the command search and the ribbon's stage, but not listed in the ribbon.
|
||||
pub covers: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
@@ -194,7 +197,7 @@ impl Capability {
|
||||
}
|
||||
|
||||
const fn ready(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[] }
|
||||
Capability { id, label, tab, panel, tip, status: Status::Ready, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
const fn ready_more(
|
||||
@@ -208,12 +211,23 @@ const fn ready_more(
|
||||
Capability { more, ..ready(id, label, tab, panel, tip) }
|
||||
}
|
||||
|
||||
const fn covering(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
tab: Tab,
|
||||
panel: &'static str,
|
||||
tip: &'static str,
|
||||
covers: &'static [&'static str],
|
||||
) -> Capability {
|
||||
Capability { covers, ..ready(id, label, tab, panel, tip) }
|
||||
}
|
||||
|
||||
const fn soon(id: &'static str, label: &'static str, tab: Tab, panel: &'static str, milestone: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[] }
|
||||
Capability { id, label, tab, panel, tip, status: Status::Planned { milestone }, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
const fn deck(id: &'static str, label: &'static str, format: &'static str, tip: &'static str) -> Capability {
|
||||
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[] }
|
||||
Capability { id, label, tab: Tab::Solve, panel: "Decks", tip, status: Status::Deck { format, milestone: "M3" }, more: &[], covers: &[] }
|
||||
}
|
||||
|
||||
use Tab::*;
|
||||
@@ -232,19 +246,40 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Inspect",
|
||||
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
|
||||
),
|
||||
soon("geometry.partition", "Partition", Geometry, "Prepare", "M5", "Split bodies and faces as SolidCraft features"),
|
||||
soon("geometry.datum", "Datum Plane", Geometry, "Prepare", "M5", "Construction planes for partitions"),
|
||||
ready_more(
|
||||
"geometry.datum",
|
||||
"Datum Plane",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Datum planes (principal plane and offset, three points, point and normal) and axes",
|
||||
&["geometry.datum.axis", "geometry.datum.delete"],
|
||||
),
|
||||
ready_more(
|
||||
"geometry.partition",
|
||||
"Partition",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Cut bodies into cells by a datum plane; the two faces of each cut are paired by name",
|
||||
&["geometry.partition.delete"],
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
|
||||
soon("mesh.size.seed", "Seed Edges", Mesh, "Size", "M2", "Element counts, sizes and bias per edge and face"),
|
||||
ready_more(
|
||||
"mesh.seed.edge",
|
||||
"Seed Edges",
|
||||
Mesh,
|
||||
"Size",
|
||||
"Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them",
|
||||
&["mesh.seed.clear", "mesh.seed.list"],
|
||||
),
|
||||
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
|
||||
ready(
|
||||
"mesh.tet.generate",
|
||||
"Tet Mesh",
|
||||
Mesh,
|
||||
"Tet",
|
||||
"Constrained Delaunay tet mesh of a body: every boundary triangle kept, faces tagged (refinement comes next)",
|
||||
"Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged",
|
||||
),
|
||||
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
|
||||
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
|
||||
@@ -256,32 +291,31 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Quad or triangle mesh of a planar face or polygon in the XY plane for plane stress, plane strain and axisymmetric models; edges tagged",
|
||||
),
|
||||
soon("mesh.quad.faces", "Quad Faces", Mesh, "Structured Hex", "M5", "All-quad meshes of faces on shared edge nodes"),
|
||||
ready_more(
|
||||
"mesh.hex.mapped",
|
||||
"Mapped Hex",
|
||||
covering(
|
||||
"mesh.hex.generate",
|
||||
"Hex Mesh",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"Mapped hex mesh of a six-sided block (curved faces allowed)",
|
||||
&["mesh.hex.generate"],
|
||||
"Structured hex mesh of a body: automatic, or extruded, swept, revolved or mapped (the Method drop-down)",
|
||||
&["mesh.hex.sweep", "mesh.hex.mapped"],
|
||||
),
|
||||
ready_more(
|
||||
"mesh.hex.sweep",
|
||||
"Sweep Hex",
|
||||
covering(
|
||||
"mesh.cells",
|
||||
"Partitioned Mesh",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"Hex mesh swept along extrusions (several levels), around revolutions, or from a source face to a target",
|
||||
&["mesh.hex.generate"],
|
||||
),
|
||||
ready(
|
||||
"mesh.hex.blocks",
|
||||
"Block Hex",
|
||||
Mesh,
|
||||
"Structured Hex",
|
||||
"One conforming hex mesh of a part split into six-sided blocks: shared faces meshed once",
|
||||
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
|
||||
&["mesh.hex.blocks"],
|
||||
),
|
||||
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
|
||||
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
|
||||
soon("mesh.hex.octree", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
|
||||
ready(
|
||||
"mesh.hex.octree",
|
||||
"Octree Hex",
|
||||
Mesh,
|
||||
"Automatic Hex",
|
||||
"Unstructured all-hex mesh of any part: adaptive grid, boundary layers on every sharp edge, untangled and proven valid",
|
||||
),
|
||||
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
|
||||
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
|
||||
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
|
||||
@@ -294,7 +328,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
Mesh,
|
||||
"Edit",
|
||||
"Add an element on existing nodes, or a mapped block of them",
|
||||
&["mesh.element.remove", "mesh.block"],
|
||||
&["mesh.element.remove", "mesh.block", "mesh.mirror"],
|
||||
),
|
||||
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
|
||||
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
|
||||
@@ -366,7 +400,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
Loads,
|
||||
"Boundary Conditions",
|
||||
"Fixed, displacement or symmetry condition on a set or reference point",
|
||||
&["model.boundary_condition.edit", "model.boundary_condition.delete"],
|
||||
&["model.boundary_condition.edit", "model.boundary_condition.delete", "model.boundary_condition.symmetry"],
|
||||
),
|
||||
ready_more(
|
||||
"model.load.create",
|
||||
@@ -408,10 +442,37 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Kinematic or distributing coupling of a set to a reference point",
|
||||
&["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.tie",
|
||||
"Tie",
|
||||
Interactions,
|
||||
"Constraints",
|
||||
"Tie non-matching meshes together (dual mortar constraints)",
|
||||
&["interaction.tie.edit", "interaction.tie.delete"],
|
||||
),
|
||||
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 and nonlinear static steps)",
|
||||
&["interaction.contact.edit", "interaction.contact.delete"],
|
||||
),
|
||||
// Its edit and delete rows are General Contact's: they act on contacts of either kind.
|
||||
ready(
|
||||
"interaction.contact.pair",
|
||||
"Contact Pair",
|
||||
Interactions,
|
||||
"Contact",
|
||||
"Contact between two surfaces: with friction in explicit dynamics, mortar contact with friction in nonlinear static steps",
|
||||
),
|
||||
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(
|
||||
@@ -444,8 +505,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(
|
||||
@@ -511,9 +572,12 @@ pub fn find(id: &str) -> Option<&'static Capability> {
|
||||
}
|
||||
|
||||
/// The entry that claims a command: the one running it, else the one listing it among its
|
||||
/// related commands. Every registered command has one (a test checks it).
|
||||
/// related commands or covering it. Every registered command has one (a test checks it).
|
||||
pub fn entry_of(command: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command)))
|
||||
CATALOGUE
|
||||
.iter()
|
||||
.find(|c| c.command() == Some(command))
|
||||
.or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command) || c.covers.contains(&command)))
|
||||
}
|
||||
|
||||
/// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script,
|
||||
@@ -556,7 +620,8 @@ pub fn features_page() -> String {
|
||||
let preset = c.preset();
|
||||
let main =
|
||||
c.command().map(|id| if preset.as_object().is_some_and(|o| !o.is_empty()) { format!("`{id} {preset}`") } else { format!("`{id}`") });
|
||||
let commands: Vec<String> = main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).collect();
|
||||
let commands: Vec<String> =
|
||||
main.into_iter().chain(c.more.iter().map(|id| format!("`{id}`"))).chain(c.covers.iter().map(|id| format!("(`{id}`)"))).collect();
|
||||
let _ = writeln!(md, "| {} | {} — {} | {status} | {} |", c.panel, c.label, c.tip, commands.join(", "));
|
||||
}
|
||||
}
|
||||
@@ -586,6 +651,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each command has one place in the ribbon: no two menu rows (an entry or a drop-down row)
|
||||
/// run the same command with the same preset, and a command an entry's form covers is no row.
|
||||
#[test]
|
||||
fn no_command_appears_twice_across_ribbon_menus() {
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
for c in CATALOGUE {
|
||||
let entry = c.command().map(|id| format!("{id} {}", c.preset()));
|
||||
for row in entry.into_iter().chain(c.more.iter().map(|id| format!("{id} {{}}"))) {
|
||||
assert!(!rows.contains(&row), "{}: `{row}` is in the ribbon twice", c.id);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
for c in CATALOGUE {
|
||||
for m in c.covers {
|
||||
assert!(!rows.iter().any(|r| r.split(' ').next() == Some(m)), "{}: `{m}` is covered by its form and also a ribbon row", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ribbon_follows_each_command_to_its_stage() {
|
||||
assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh));
|
||||
@@ -600,7 +684,7 @@ mod tests {
|
||||
assert_eq!(stage_tab("no.such"), None);
|
||||
// A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown.
|
||||
for c in CATALOGUE {
|
||||
for command in c.command().into_iter().chain(c.more.iter().copied()) {
|
||||
for command in c.command().into_iter().chain(c.more.iter().copied()).chain(c.covers.iter().copied()) {
|
||||
assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab);
|
||||
}
|
||||
}
|
||||
@@ -614,9 +698,9 @@ mod tests {
|
||||
assert!(find_command(command).is_some(), "{}: `{command}` is not a registered command", c.id);
|
||||
claimed.push(command);
|
||||
} else {
|
||||
assert!(c.more.is_empty(), "{}: a planned entry has no commands", c.id);
|
||||
assert!(c.more.is_empty() && c.covers.is_empty(), "{}: a planned entry has no commands", c.id);
|
||||
}
|
||||
for m in c.more {
|
||||
for m in c.more.iter().chain(c.covers) {
|
||||
assert!(find_command(m).is_some(), "{}: `{m}` is not a registered command", c.id);
|
||||
claimed.push(m);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ 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,
|
||||
requires: &[],
|
||||
run: block_mesh,
|
||||
};
|
||||
|
||||
@@ -107,7 +109,7 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let region = match (planar, &p.face) {
|
||||
(true, face) => GeometryRef::face(p.body.clone(), face.clone().unwrap_or_else(|| "area".into()))?,
|
||||
(false, None) => GeometryRef::body(p.body.clone())?,
|
||||
(false, Some(_)) => return Err(EngineError::Params("`face` names the region of 2D elements; a solid block fills its body".into())),
|
||||
(false, Some(_)) => return Err(EngineError::invalid("face", "names the region of 2D elements; a solid block fills its body")),
|
||||
};
|
||||
if planar && !p.faces.is_empty() {
|
||||
return Err(EngineError::Params("a 2D patch has no faces to name: name its edges".into()));
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Meshing a partitioned part cell by cell (`mesh.cells`): each cell (a body of the open design,
|
||||
//! typically made by `geometry.partition`) with its own method, all into one mesh, and the cells
|
||||
//! joined across the partitions that divide them.
|
||||
//!
|
||||
//! **Joining.** Two cells on either side of a partition `P` meet on faces named
|
||||
//! `partition:P:a` and `partition:P:b`. Where the element faces of the two sides match node for
|
||||
//! node (the same divisions and kind on both sides, e.g. a mesh and its mirror image) the
|
||||
//! coincident nodes are merged and the mesh is conforming there. Elsewhere — a hex cell next to a
|
||||
//! tet cell, or two cells meshed with different divisions — the two surfaces are tied: a set on
|
||||
//! each side's partition faces and a tie between them (`interaction.tie`; dual mortar
|
||||
//! constraints in the implicit analyses, so a constant stress crosses the interface exactly).
|
||||
//! The tie is the transition between hex and tet cells; pyramid elements are not used, because
|
||||
//! they need their own element formulation in every solver and deck, while the tie works for any
|
||||
//! pair of meshes and keeps each cell's mesh as its method made it. The reply lists every
|
||||
//! interface and how it was joined.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::cdt::CdtOptions;
|
||||
use fieldcraft_mesh::hex::{HexMesh, HexMethod};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, add_hex, hex_body};
|
||||
use super::mesh::{BodyTets, add_tets, mesh_body, quality_options};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const CELLS: CommandSpec = CommandSpec {
|
||||
id: "mesh.cells",
|
||||
label: "Partitioned Mesh",
|
||||
params: r#"{"methods": {"Body1/a": "tet", "Body1/b": "swept"} (per cell; default `default`), "default": "auto", "bodies": ["Body1/a", "Body1/b"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 | 2 (hex8 and tet4, or hex20 and tet10), "tolerance": 0.05 (chordal tolerance, mm)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one mesh of the cells, each with its method: "auto" (structured hex where a method fits, else tet), "hex" (any structured hex method), "swept", "extrude", "sweep", "revolve", "mapped" or "tet"; or "default": "blocks" (with no `methods`): every cell a mapped block, division counts assigned over all cells together, exactly mesh.hex.blocks with the same parameters. Cells on either side of a partition (faces `partition:<name>:a` and `:b`) are joined: where their element faces match node for node the nodes are merged (conforming); otherwise, as between a hex and a tet cell, a set on each side and a tie between them are made (`tie_<partition>_<k>`, sets `<tie>_main` and `<tie>_secondary`: the side with fewer nodes is the main surface), replacing ties of those names from an earlier run. Reports each cell's method (and why auto fell back to tet), and each interface: `merged` with the nodes merged, or `tied`"#,
|
||||
form: Some(super::forms::CELLS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: cells,
|
||||
};
|
||||
|
||||
/// Distance (relative to the cells' bounding-box diagonal) within which interface nodes coincide.
|
||||
const COINCIDENT: f64 = 1e-7;
|
||||
|
||||
/// A cell's method.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Method {
|
||||
Auto,
|
||||
Hex(HexMethod),
|
||||
Tet,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
fn parse(v: &Value) -> Result<Method> {
|
||||
let name = v.as_str().unwrap_or_default();
|
||||
Ok(match name {
|
||||
"auto" => Method::Auto,
|
||||
"hex" => Method::Hex(HexMethod::Auto),
|
||||
"tet" => Method::Tet,
|
||||
_ => match HexMethod::from_name(name).filter(|m| *m != HexMethod::Auto) {
|
||||
Some(m) => Method::Hex(m),
|
||||
None => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"method `{}` is not one of auto, hex, swept, extrude, sweep, revolve, mapped, tet",
|
||||
name.chars().take(40).collect::<String>()
|
||||
)));
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A meshed cell.
|
||||
enum Meshed<'a> {
|
||||
Hex(Input<'a>, HexMesh),
|
||||
Tet(BodyTets),
|
||||
}
|
||||
|
||||
fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("default").and_then(Value::as_str) == Some("blocks") {
|
||||
if p.get("methods").is_some_and(|m| m.as_object().is_none_or(|o| !o.is_empty())) {
|
||||
return Err(EngineError::param(
|
||||
"methods",
|
||||
crate::schema::ProblemKind::Invalid("must be empty with \"default\": \"blocks\" (every cell a block)".into()),
|
||||
));
|
||||
}
|
||||
return super::hex::hex_blocks(s, p);
|
||||
}
|
||||
let size = super::hex::number(p, "size")?;
|
||||
let quadratic = match p.get("order") {
|
||||
None => false,
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(1) => false,
|
||||
Some(2) => true,
|
||||
_ => return Err(EngineError::invalid("order", "must be 1 or 2")),
|
||||
},
|
||||
};
|
||||
let default = p.get("default").map(Method::parse).transpose()?.unwrap_or(Method::Auto);
|
||||
let methods: BTreeMap<String, Method> = match p.get("methods") {
|
||||
None => BTreeMap::new(),
|
||||
Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok((k.clone(), Method::parse(v)?))).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(EngineError::invalid("methods", "must map cell names to methods")),
|
||||
};
|
||||
let design = s.design.clone().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let names: Vec<String> = match p.get("bodies") {
|
||||
None => design.bodies.iter().map(|b| b.name.clone()).collect(),
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
};
|
||||
if names.is_empty() || names.len() > fieldcraft_mesh::hex::MAX_CELLS {
|
||||
return Err(EngineError::Params(format!("{} cells; mesh.cells meshes 1 to {}", names.len(), fieldcraft_mesh::hex::MAX_CELLS)));
|
||||
}
|
||||
for n in methods.keys() {
|
||||
if !names.contains(n) {
|
||||
return Err(EngineError::Params(format!("`methods` names `{n}`, which is not a cell being meshed")));
|
||||
}
|
||||
}
|
||||
// One size and tolerance from the cells' joint extent, as mesh.hex.blocks does.
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
let mut shapes = Vec::with_capacity(names.len());
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
if names.get(..i).is_some_and(|b| b.contains(n)) {
|
||||
return Err(EngineError::Params(format!("cell `{n}` is listed twice")));
|
||||
}
|
||||
let shape = design.body(n).ok_or_else(|| EngineError::Params(format!("no body `{n}`")))?;
|
||||
let m = shape.measure()?;
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(m.bbox_min[k]);
|
||||
hi[k] = hi[k].max(m.bbox_max[k]);
|
||||
}
|
||||
shapes.push(shape);
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt();
|
||||
if !(diagonal > 0.0 && diagonal.is_finite()) {
|
||||
return Err(EngineError::Params("the cells have no extent".into()));
|
||||
}
|
||||
let size = Some(size.unwrap_or(diagonal / 25.0));
|
||||
let tolerance = super::hex::number(p, "tolerance")?.unwrap_or(1e-3 * diagonal);
|
||||
// The tet mesher's parameters: the same size and tolerance, its own order names.
|
||||
let mut tet_params = json!({"size": size, "tolerance": tolerance, "order": if quadratic { "quadratic" } else { "linear" }});
|
||||
for key in ["weld", "weld_distance", "feature_angle"] {
|
||||
if let (Some(v), Some(o)) = (p.get(key), tet_params.as_object_mut()) {
|
||||
o.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let mut hex_params = tet_params.clone();
|
||||
if let Some(o) = hex_params.as_object_mut() {
|
||||
o.remove("order");
|
||||
}
|
||||
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
|
||||
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
|
||||
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
let mut meshed: Vec<(Meshed, Value)> = Vec::with_capacity(shapes.len());
|
||||
for shape in shapes {
|
||||
let method = methods.get(&shape.name).copied().unwrap_or(default);
|
||||
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
|
||||
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
|
||||
let b = input()?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
|
||||
Ok((b, mesh))
|
||||
};
|
||||
let tet = |s: &Session| mesh_body(s, &tet_params, input()?, &cdt, &tet_options, quality, true);
|
||||
let (cell, mut info) = match method {
|
||||
Method::Hex(m) => {
|
||||
let (b, mesh) = hex(m)?;
|
||||
let info = json!({"method": mesh.report.method.name()});
|
||||
(Meshed::Hex(b, mesh), info)
|
||||
}
|
||||
Method::Tet => (Meshed::Tet(tet(s)?), json!({"method": "tet"})),
|
||||
Method::Auto => match hex(HexMethod::Auto) {
|
||||
Ok((b, mesh)) => {
|
||||
let info = json!({"method": mesh.report.method.name()});
|
||||
(Meshed::Hex(b, mesh), info)
|
||||
}
|
||||
Err(e) => (Meshed::Tet(tet(s)?), json!({"method": "tet", "why": e.to_string()})),
|
||||
},
|
||||
};
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("body".into(), json!(shape.name));
|
||||
}
|
||||
meshed.push((cell, info));
|
||||
}
|
||||
|
||||
// Replace the cells' meshes (replace.rs), each after the labels before it; other bodies
|
||||
// and the definitions stay.
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, names.iter().map(String::as_str));
|
||||
let mut report = Vec::with_capacity(meshed.len());
|
||||
for (cell, mut info) in meshed {
|
||||
let (kind, elements, nodes) = match &cell {
|
||||
Meshed::Hex(b, m) => {
|
||||
add_hex(model, b, m)?;
|
||||
(m.kind, m.element_count(), m.nodes.len())
|
||||
}
|
||||
Meshed::Tet(t) => {
|
||||
add_tets(model, &t.name, &t.face_names, &t.mesh)?;
|
||||
(t.mesh.kind, t.mesh.elements.len(), t.mesh.points.len())
|
||||
}
|
||||
};
|
||||
if let Some(o) = info.as_object_mut() {
|
||||
o.insert("kind".into(), json!(kind.name()));
|
||||
o.insert("elements".into(), json!(elements));
|
||||
o.insert("nodes".into(), json!(nodes));
|
||||
}
|
||||
report.push(info);
|
||||
}
|
||||
let interfaces = join(model, &names, COINCIDENT * diagonal)?;
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
Ok(cleared.report(s, json!({"cells": report, "interfaces": interfaces, "nodes": nodes, "elements": elements, "size": size})))
|
||||
}
|
||||
|
||||
/// One side of an interface: a cell's element faces on one side of a partition.
|
||||
struct Side {
|
||||
body: String,
|
||||
/// The face name (`partition:P:a`), which selects its pieces too.
|
||||
face: String,
|
||||
faces: Vec<ElementFace>,
|
||||
nodes: BTreeSet<u32>,
|
||||
lo: [f64; 3],
|
||||
hi: [f64; 3],
|
||||
}
|
||||
|
||||
/// The element faces of each cell tagged with a partition's cut faces, by partition and side.
|
||||
fn sides(model: &Model, cells: &[String]) -> BTreeMap<String, [Vec<Side>; 2]> {
|
||||
let c = model.classification();
|
||||
// Tag → (partition, side), for cut faces of the meshed cells.
|
||||
let mut cut: BTreeMap<u32, (String, usize, String)> = BTreeMap::new();
|
||||
for (t, r) in c.entities().iter().enumerate() {
|
||||
let Entity::Face(name) = &r.entity else { continue };
|
||||
if !cells.contains(&r.body) {
|
||||
continue;
|
||||
}
|
||||
let base = name.split_once('#').map_or(name.as_str(), |(b, _)| b);
|
||||
let Some(rest) = base.strip_prefix("partition:") else { continue };
|
||||
let Some((partition, side)) = rest.rsplit_once(':') else { continue };
|
||||
let side = match side {
|
||||
"a" => 0,
|
||||
"b" => 1,
|
||||
_ => continue,
|
||||
};
|
||||
if let Ok(t) = u32::try_from(t) {
|
||||
cut.insert(t, (partition.to_string(), side, r.body.clone()));
|
||||
}
|
||||
}
|
||||
let mut grouped: BTreeMap<(String, usize, String), Vec<ElementFace>> = BTreeMap::new();
|
||||
for (&element, tags) in c.elements() {
|
||||
for (&face, t) in &tags.faces {
|
||||
if let Some(key) = cut.get(t) {
|
||||
grouped.entry(key.clone()).or_default().push(ElementFace { element, face });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: BTreeMap<String, [Vec<Side>; 2]> = BTreeMap::new();
|
||||
for ((partition, side, body), faces) in grouped {
|
||||
let nodes: BTreeSet<u32> = faces.iter().filter_map(|f| model.face_nodes(*f)).flatten().collect();
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for n in nodes.iter().filter_map(|&n| model.node(n)) {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(n.xyz[k]);
|
||||
hi[k] = hi[k].max(n.xyz[k]);
|
||||
}
|
||||
}
|
||||
let face = format!("partition:{partition}:{}", if side == 0 { "a" } else { "b" });
|
||||
if let Some(slot) = out.entry(partition).or_insert_with(|| [Vec::new(), Vec::new()]).get_mut(side) {
|
||||
slot.push(Side { body, face, faces, nodes, lo, hi });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join the cells across every partition: merge where the two sides match node for node, tie
|
||||
/// where they do not. Returns the interfaces.
|
||||
fn join(model: &mut Model, cells: &[String], tol: f64) -> Result<Vec<Value>> {
|
||||
let mut out = Vec::new();
|
||||
for (partition, [a_sides, b_sides]) in sides(model, cells) {
|
||||
let mut k = 0;
|
||||
for a in &a_sides {
|
||||
for b in &b_sides {
|
||||
let overlap = (0..3).all(|i| a.lo[i] <= b.hi[i] + tol && b.lo[i] <= a.hi[i] + tol);
|
||||
if !overlap {
|
||||
continue;
|
||||
}
|
||||
k += 1;
|
||||
let matched = matching(model, a, b, tol);
|
||||
if let Some(map) = matched {
|
||||
let count = map.len();
|
||||
model.merge_nodes(&map)?;
|
||||
out.push(json!({"partition": partition, "cells": [a.body, b.body], "joined": "merged", "nodes": count}));
|
||||
} else {
|
||||
let name = format!("tie_{partition}_{k}");
|
||||
let (main, secondary) = if a.nodes.len() <= b.nodes.len() { (a, b) } else { (b, a) };
|
||||
let set = |side: &Side| -> Result<Set> { Ok(Set::of_geometry(vec![GeometryRef::face(side.body.as_str(), side.face.as_str())?])) };
|
||||
let (main_set, secondary_set) = (format!("{name}_main"), format!("{name}_secondary"));
|
||||
for (n, side) in [(&main_set, main), (&secondary_set, secondary)] {
|
||||
if model.set(n).is_some() {
|
||||
model.replace_set(n, set(side)?)?;
|
||||
} else {
|
||||
model.add_set(n, set(side)?)?;
|
||||
}
|
||||
}
|
||||
if model.tie(&name).is_some() {
|
||||
model.remove_tie(&name)?;
|
||||
}
|
||||
model.add_tie(&name, Tie::new(&main_set, &secondary_set))?;
|
||||
out.push(json!({
|
||||
"partition": partition,
|
||||
"cells": [a.body, b.body],
|
||||
"joined": "tied",
|
||||
"tie": name,
|
||||
"main": main.body,
|
||||
"secondary": secondary.body,
|
||||
"faces": [a.faces.len(), b.faces.len()],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// When the two sides' nodes coincide one to one (within `tol`) and their faces are the same
|
||||
/// in number, the merge: each node of side `b` to the node of side `a` at its place (or the other
|
||||
/// way round, so the lower label survives).
|
||||
fn matching(model: &Model, a: &Side, b: &Side, tol: f64) -> Option<BTreeMap<u32, u32>> {
|
||||
if a.nodes.len() != b.nodes.len() || a.faces.len() != b.faces.len() {
|
||||
return None;
|
||||
}
|
||||
let both: BTreeSet<u32> = a.nodes.union(&b.nodes).copied().collect();
|
||||
let map = fieldcraft_mesh::join::coincident_nodes(model, Some(&both), tol).ok()?;
|
||||
// Every pair joins one node of each side, and every node of either side is in a pair.
|
||||
let one_to_one = map.len() == a.nodes.len()
|
||||
&& map.iter().all(|(from, to)| (a.nodes.contains(from) && b.nodes.contains(to)) || (b.nodes.contains(from) && a.nodes.contains(to)));
|
||||
one_to_one.then_some(map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sample plate (40 × 30 × 10, a Ø10 hole at (15, 15)) open and cut at x = 30, right of
|
||||
/// the hole: `Body1/a` holds the hole, `Body1/b` is a 10 × 30 × 10 block.
|
||||
fn cut_plate() -> (crate::tests::TempDir, Session) {
|
||||
let (dir, path) = crate::tests::sample_design_file(&fieldcraft_geometry::sample::Plate::default());
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "P", "datum": "X30"})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_and_tet_cells_are_tied_and_matching_hex_cells_merged() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
// A tet cell next to a hex cell: tied, main the coarser side.
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet", "Body1/b": "mapped"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["cells"][0]["kind"], "tet4", "{r}");
|
||||
assert_eq!(r["cells"][1]["kind"], "hex8", "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "tied", "{r}");
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
for set in ["tie_P_1_main", "tie_P_1_secondary"] {
|
||||
assert!(s.model.resolve_set(set).unwrap().faces.len() > 4, "{set}");
|
||||
}
|
||||
// Both cells hex at one size: the plane faces match node for node and merge.
|
||||
let r = s.execute("mesh.cells", &json!({"default": "hex", "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let i = &r["interfaces"][0];
|
||||
assert_eq!(i["joined"], "merged", "{r}");
|
||||
let separate = r["cells"].as_array().unwrap().iter().map(|c| c["nodes"].as_u64().unwrap()).sum::<u64>();
|
||||
assert_eq!(r["nodes"].as_u64().unwrap() + i["nodes"].as_u64().unwrap(), separate, "{r}");
|
||||
// Rerunning replaces the tie it made rather than adding another.
|
||||
s.execute("mesh.cells", &json!({"methods": {"Body1/a": "tet"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(s.model.ties().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_falls_back_to_tet_and_says_why() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
let r = s.execute("mesh.cells", &json!({"default": "mapped", "methods": {"Body1/a": "auto"}, "size": 3})).unwrap();
|
||||
// The holed cell is not a mapped block, but auto sweeps it.
|
||||
assert!(r["cells"][0]["method"].as_str().is_some(), "{r}");
|
||||
assert_eq!(r["cells"][1]["method"], "mapped", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_change_nothing() {
|
||||
let (_dir, mut s) = cut_plate();
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"default": "pyramid"}),
|
||||
json!({"methods": {"Nope": "tet"}}),
|
||||
json!({"methods": ["tet"]}),
|
||||
json!({"bodies": []}),
|
||||
json!({"bodies": ["Body1/a", "Body1/a"]}),
|
||||
json!({"bodies": ["Nope"]}),
|
||||
json!({"order": 3}),
|
||||
json!({"size": -1}),
|
||||
json!({"default": "mapped", "bodies": ["Body1/a"]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.cells", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert!(s.model.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A 40 × 10 × 10 bar in uniaxial tension, cut in the middle: one half hex, the other tet,
|
||||
/// tied. With ν = 0, symmetry conditions on three faces and the end load spread by consistent
|
||||
/// weights, the stress is uniform, which both elements and the dual mortar tie carry exactly:
|
||||
/// the end moves F L / (E A).
|
||||
#[test]
|
||||
fn a_tied_hex_tet_bar_in_tension_stretches_as_the_formula_says() {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let bbox = s.execute("geometry.bodies", &json!({})).unwrap()[0]["bbox"].clone();
|
||||
let lo: Vec<f64> = bbox[0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let hi: Vec<f64> = bbox[1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let (axis, length) = (0..3).map(|k| (k, hi[k] - lo[k])).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap();
|
||||
assert!((length - 40.0).abs() < 1e-9);
|
||||
let mid = (lo[axis] + hi[axis]) / 2.0;
|
||||
let mut normal = [0.0; 3];
|
||||
normal[axis] = 1.0;
|
||||
let mut point = [0.0; 3];
|
||||
point[axis] = mid;
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "point_normal", "point": point, "normal": normal}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": "Cut", "datum": "Mid"})).unwrap();
|
||||
let r = s.execute("mesh.cells", &json!({"methods": {"Bar/a": "mapped", "Bar/b": "tet"}, "size": 2.5})).unwrap();
|
||||
assert_eq!(r["interfaces"][0]["joined"], "tied", "{r}");
|
||||
|
||||
let m = &s.model;
|
||||
let on = |k: usize, v: f64| -> Vec<u32> { m.nodes().iter().filter(|(_, n)| (n.xyz[k] - v).abs() < 1e-9).map(|(l, _)| *l).collect() };
|
||||
let others: Vec<usize> = (0..3).filter(|k| *k != axis).collect();
|
||||
let end: Vec<Value> = m
|
||||
.elements()
|
||||
.iter()
|
||||
.flat_map(|(&e, el)| (1..=el.kind.faces().len() as u8).map(move |f| (e, f)))
|
||||
.filter(|&(e, f)| {
|
||||
m.face_nodes(ElementFace { element: e, face: f })
|
||||
.is_some_and(|ns| ns.iter().all(|n| (m.node(*n).unwrap().xyz[axis] - hi[axis]).abs() < 1e-9))
|
||||
})
|
||||
.map(|(e, f)| json!({"element": e, "face": f}))
|
||||
.collect();
|
||||
let plane = |k: usize| ["x", "y", "z"][k];
|
||||
let (young, area, force) = (70_000.0, 100.0, 1000.0);
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "elements": m.elements().keys().collect::<Vec<_>>()}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "nodes": on(axis, lo[axis])}},
|
||||
{"command": "model.set.create", "params": {"name": "side1", "nodes": on(others[0], lo[others[0]])}},
|
||||
{"command": "model.set.create", "params": {"name": "side2", "nodes": on(others[1], lo[others[1]])}},
|
||||
{"command": "model.set.create", "params": {"name": "end", "faces": end}},
|
||||
{"command": "model.material.create", "params": {"name": "Al", "elastic": {"type": "isotropic", "young": young, "poisson": 0}}},
|
||||
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Al", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s0", "type": "symmetry", "target": {"set": "root"}, "plane": plane(axis)}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s1", "type": "symmetry", "target": {"set": "side1"}, "plane": plane(others[0])}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "s2", "type": "symmetry", "target": {"set": "side2"}, "plane": plane(others[1])}},
|
||||
{"command": "model.load.create", "params": {"name": "Pull", "type": "force", "target": {"set": "end"}, "vector": normal.map(|n| n * force)}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["s0", "s1", "s2"], "loads": ["Pull"]}},
|
||||
]);
|
||||
let tip = on(axis, hi[axis]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip})).unwrap();
|
||||
let expected = force * length / (young * area);
|
||||
let probed = probe["nodes"].as_array().unwrap();
|
||||
assert!(probed.len() > 4, "{probe}");
|
||||
for n in probed {
|
||||
let u = n["displacement"][axis].as_f64().unwrap();
|
||||
assert!((u - expected).abs() < 1e-6 * expected, "{u} vs {expected}: {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! `interaction.contact.*`: contact definitions (pairs of surfaces, general contact) and the
|
||||
//! search for surfaces that touch; `interaction.tie*`: ties between non-matching meshes.
|
||||
//!
|
||||
//! A contact's surfaces are sets of element faces, or of element edges in a 2D model
|
||||
//! ([`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 crate::schema::ProblemKind;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_fem::contact::{Grid, bounds, closest_point};
|
||||
use fieldcraft_model::{Contact, ContactSurfaces, ElementFace, Friction, InitialPenetration, Model, Set, Tie};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The options every contact takes, from the parameters.
|
||||
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::param(key, ProblemKind::NotAName))
|
||||
}
|
||||
|
||||
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 penalty: explicit, the stiffest that leaves the time increment alone; nonlinear static, the augmented Lagrangian's), "initial_penetration": "adjust" (nodes that start inside: explicit moves them onto the surface, strain-free; nonlinear static takes the overlap out of their gap; both report them) | "refuse" (refuse the analysis, naming the first)"#,
|
||||
r#"} — contact between two surfaces: in explicit dynamics each one's nodes are kept out of the other's faces; in nonlinear static steps `secondary` is the mortar (non-mortar) side whose pressure field is enforced against `main` by an augmented Lagrangian, with Coulomb friction by a return map (static coefficient while a node sticks, kinetic once it slips) (fails if the name is taken)"#
|
||||
),
|
||||
form: Some(super::forms::CONTACT_PAIR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surfaces = ContactSurfaces::Pair { main: text(p, "main")?.to_string(), secondary: text(p, "secondary")?.to_string() };
|
||||
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 penalty: explicit, the stiffest that leaves the time increment alone; nonlinear static, the augmented Lagrangian's), "initial_penetration": "adjust" (nodes that start inside: explicit moves them onto the surface, strain-free; nonlinear static takes the overlap out of their gap; both report them) | "refuse" (refuse the analysis, naming the first)"#,
|
||||
r#"} — single-surface contact of everything that may touch (explicit dynamics, and mortar self-contact in nonlinear static steps; fails if the name is taken)"#
|
||||
),
|
||||
form: Some(super::forms::CONTACT_GENERAL),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let surface = match p.get("surface") {
|
||||
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,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
let mut obj = p.as_object().cloned().ok_or_else(|| EngineError::Params("the parameters of a contact are a JSON object".into()))?;
|
||||
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,
|
||||
requires: &[Requirement::Contact],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_contact(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
};
|
||||
|
||||
/// A tie from the parameters.
|
||||
fn tie(p: &Value) -> Result<Tie> {
|
||||
Ok(Tie {
|
||||
main: text(p, "main")?.to_string(),
|
||||
secondary: text(p, "secondary")?.to_string(),
|
||||
position_tolerance: opt::<f64>(p, "position_tolerance")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub const TIE: CommandSpec = CommandSpec {
|
||||
id: "interaction.tie",
|
||||
label: "Tie",
|
||||
params: r#"{"name": "glue", "main": "plate" (a set of element faces), "secondary": "rib" (another), "position_tolerance": 0.1 (optional: how far a secondary node may lie from the main surface and still be tied; default half the main element's thickness)} — bonds the secondary surface to the main one across non-matching meshes: each secondary node follows the main surface under it (dual mortar constraints in the implicit analyses; a constant stress crosses the tie exactly); fails if the name is taken"#,
|
||||
form: Some(super::forms::TIE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Set],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.add_tie(n, tie(p)?)?;
|
||||
Ok(json!({"name": n}))
|
||||
},
|
||||
};
|
||||
|
||||
pub const TIE_EDIT: CommandSpec = CommandSpec {
|
||||
id: "interaction.tie.edit",
|
||||
label: "Edit Tie",
|
||||
params: r#"{"name": "glue", "main": "plate", "secondary": "rib", "position_tolerance": 0.1} — replaces the tie of that name (see interaction.tie)"#,
|
||||
form: Some(super::forms::TIE_EDIT),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.replace_tie(n, tie(p)?)?;
|
||||
Ok(json!({"name": n}))
|
||||
},
|
||||
};
|
||||
|
||||
pub const TIE_DELETE: CommandSpec = CommandSpec {
|
||||
id: "interaction.tie.delete",
|
||||
label: "Delete Tie",
|
||||
params: r#"{"name": "glue"}"#,
|
||||
form: Some(super::forms::TIE_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Tie],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.remove_tie(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
requires: &[Requirement::Mesh],
|
||||
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 ties_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.tie", &json!({"name": "glue", "main": "a", "secondary": "b"})).unwrap();
|
||||
assert_eq!(s.model.tie("glue"), Some(&Tie::new("a", "b")));
|
||||
for bad in [
|
||||
json!({"name": "glue", "main": "a", "secondary": "b"}),
|
||||
json!({"name": "x", "main": "a", "secondary": "a"}),
|
||||
json!({"name": "x", "main": "a", "secondary": "nope"}),
|
||||
json!({"name": "x", "main": "a", "secondary": "b", "position_tolerance": -1}),
|
||||
json!({"name": "x", "main": "a", "secondary": "b", "position_tolerance": "far"}),
|
||||
json!({"name": "x", "main": 3, "secondary": "b"}),
|
||||
] {
|
||||
assert!(s.execute("interaction.tie", &bad).is_err(), "{bad}");
|
||||
}
|
||||
s.execute("interaction.tie.edit", &json!({"name": "glue", "main": "b", "secondary": "a", "position_tolerance": 0.01})).unwrap();
|
||||
assert_eq!(s.model.tie("glue").and_then(|t| t.position_tolerance), Some(0.01));
|
||||
assert!(s.execute("interaction.tie.edit", &json!({"name": "nope", "main": "a", "secondary": "b"})).is_err());
|
||||
assert!(s.execute("model.set.delete", &json!({"name": "a"})).unwrap_err().to_string().contains("tie `glue`"));
|
||||
s.execute("interaction.tie.delete", &json!({"name": "glue"})).unwrap();
|
||||
assert!(s.model.ties().is_empty() && s.execute("interaction.tie.delete", &json!({"name": "glue"})).is_err());
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Datum planes and axes, and partitions of the open design by datum planes.
|
||||
//!
|
||||
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A partition cuts the
|
||||
//! design in memory (`fieldcraft_geometry::partition`); the model's partitions are applied again,
|
||||
//! in order, whenever the design is opened (`geometry.open`, `file.open`, samples), so an edit
|
||||
//! of the design followed by a re-open replays them.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::{Cut, Design};
|
||||
use fieldcraft_model::{Datum, Model, Partition};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const DATUM_PLANE: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum",
|
||||
label: "Datum Plane",
|
||||
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points (normal (b − a) × (c − a)), or through a point with a normal. Partitions (geometry.partition) cut by it; shown in the viewport"#,
|
||||
form: Some(super::forms::DATUM_PLANE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: datum_plane,
|
||||
};
|
||||
|
||||
pub const DATUM_AXIS: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.axis",
|
||||
label: "Datum Axis",
|
||||
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]}} — a named datum axis, shown in the viewport"#,
|
||||
form: Some(super::forms::DATUM_AXIS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: datum_axis,
|
||||
};
|
||||
|
||||
pub const DATUM_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.datum.delete",
|
||||
label: "Delete Datum",
|
||||
params: r#"{"name": "Mid"} — deletes a datum plane or axis no partition uses"#,
|
||||
form: Some(super::forms::DATUM_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Datum],
|
||||
run: datum_delete,
|
||||
};
|
||||
|
||||
pub const PARTITION: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition",
|
||||
label: "Partition",
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a plane that divides no body, or a listed body it does not divide, is an error"#,
|
||||
form: Some(super::forms::PARTITION),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design, Requirement::DatumPlane],
|
||||
run: partition,
|
||||
};
|
||||
|
||||
pub const PARTITION_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition.delete",
|
||||
label: "Delete Partition",
|
||||
params: r#"{"name": "Half"} — removes a partition; the design is cut again by the ones left"#,
|
||||
form: Some(super::forms::PARTITION_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Partition],
|
||||
run: partition_delete,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Principal {
|
||||
Yz,
|
||||
Zx,
|
||||
Xy,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum PlaneBy {
|
||||
Offset {
|
||||
plane: Principal,
|
||||
#[serde(default)]
|
||||
offset: f64,
|
||||
},
|
||||
ThreePoints {
|
||||
a: [f64; 3],
|
||||
b: [f64; 3],
|
||||
c: [f64; 3],
|
||||
},
|
||||
PointNormal {
|
||||
point: [f64; 3],
|
||||
normal: [f64; 3],
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum AxisBy {
|
||||
Principal {
|
||||
axis: Axis,
|
||||
#[serde(default)]
|
||||
through: [f64; 3],
|
||||
},
|
||||
TwoPoints {
|
||||
a: [f64; 3],
|
||||
b: [f64; 3],
|
||||
},
|
||||
PointDirection {
|
||||
point: [f64; 3],
|
||||
direction: [f64; 3],
|
||||
},
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn plane_datum(by: PlaneBy) -> Result<Datum> {
|
||||
Ok(match by {
|
||||
PlaneBy::Offset { plane, offset } => {
|
||||
if !offset.is_finite() {
|
||||
return Err(EngineError::param("offset", ProblemKind::NotANumber));
|
||||
}
|
||||
let normal = match plane {
|
||||
Principal::Yz => [1.0, 0.0, 0.0],
|
||||
Principal::Zx => [0.0, 1.0, 0.0],
|
||||
Principal::Xy => [0.0, 0.0, 1.0],
|
||||
};
|
||||
Datum::Plane { point: normal.map(|n| n * offset), normal }
|
||||
}
|
||||
PlaneBy::ThreePoints { a, b, c } => {
|
||||
let (u, v) = (sub(b, a), sub(c, a));
|
||||
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
||||
// Collinear points: refused relative to the points' spread.
|
||||
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
|
||||
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
|
||||
return Err(EngineError::Params("the three points must not lie on one line".into()));
|
||||
}
|
||||
Datum::Plane { point: a, normal: n }
|
||||
}
|
||||
PlaneBy::PointNormal { point, normal } => Datum::Plane { point, normal },
|
||||
})
|
||||
}
|
||||
|
||||
fn axis_datum(by: AxisBy) -> Datum {
|
||||
match by {
|
||||
AxisBy::Principal { axis, through } => {
|
||||
let direction = match axis {
|
||||
Axis::X => [1.0, 0.0, 0.0],
|
||||
Axis::Y => [0.0, 1.0, 0.0],
|
||||
Axis::Z => [0.0, 0.0, 1.0],
|
||||
};
|
||||
Datum::Axis { point: through, direction }
|
||||
}
|
||||
AxisBy::TwoPoints { a, b } => Datum::Axis { point: a, direction: sub(b, a) },
|
||||
AxisBy::PointDirection { point, direction } => Datum::Axis { point, direction },
|
||||
}
|
||||
}
|
||||
|
||||
fn datum_json(name: &str, d: &Datum) -> Value {
|
||||
let mut v = serde_json::to_value(d).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let datum = plane_datum(super::req(p, "by")?)?;
|
||||
s.model.add_datum(name, datum)?;
|
||||
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
|
||||
}
|
||||
|
||||
fn datum_axis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let datum = axis_datum(super::req(p, "by")?);
|
||||
s.model.add_datum(name, datum)?;
|
||||
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
|
||||
}
|
||||
|
||||
fn datum_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
s.model.remove_datum(name)?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
/// The model's partitions as cuts of the geometry.
|
||||
fn cuts(model: &Model) -> Vec<Cut> {
|
||||
model
|
||||
.partitions()
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let (point, normal) = model.datum(&p.datum)?.plane()?;
|
||||
Some(Cut { name: p.name.clone(), point, normal, bodies: p.bodies.clone() })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `design` cut by `model`'s partitions (the design as opened when the model has none).
|
||||
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
Ok(design.partitioned(&cuts(model))?)
|
||||
}
|
||||
|
||||
/// Cut the session's design by its model's partitions again.
|
||||
fn recut(s: &mut Session) -> Result<()> {
|
||||
if let Some(d) = &s.design {
|
||||
let cut = partitioned(d, &s.model)?;
|
||||
s.design = Some(Arc::new(cut));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let datum: String = super::req(p, "datum")?;
|
||||
let bodies: Vec<String> = super::opt(p, "bodies")?.unwrap_or_default();
|
||||
s.model.add_partition(Partition { name: name.clone(), datum, bodies })?;
|
||||
recut(s)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let feature = format!("partition {name}");
|
||||
let errors: Vec<&str> = design.errors.iter().filter(|e| e.feature == feature).map(|e| e.message.as_str()).collect();
|
||||
if !errors.is_empty() {
|
||||
return Err(EngineError::Params(format!("partition `{name}`: {}", errors.join("; "))));
|
||||
}
|
||||
let report = design.cuts.iter().find(|c| c.name == name);
|
||||
let split = report.map(|r| r.split.clone()).unwrap_or_default();
|
||||
if split.is_empty() {
|
||||
return Err(EngineError::Params(format!("partition `{name}`: the plane divides no body of the design")));
|
||||
}
|
||||
let cells: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
|
||||
Ok(json!({
|
||||
"name": name,
|
||||
"split": split,
|
||||
"cells": cells,
|
||||
"cut_faces": report.map_or(0, |r| r.faces),
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn partition_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
s.model.remove_partition(name)?;
|
||||
recut(s)?;
|
||||
let bodies: Vec<String> = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect();
|
||||
Ok(json!({"deleted": name, "bodies": bodies}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A session with the sample plate (40 × 30 × 10, Ø10 hole at (15, 15)) open.
|
||||
pub(crate) fn plate() -> (Session, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-partition-{}-{:?}", std::process::id(), std::thread::current().id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plate.solidcraft");
|
||||
let plate = fieldcraft_geometry::sample::Plate::default();
|
||||
std::fs::write(&path, fieldcraft_geometry::sample::plate_design(&plate).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
(s, dir)
|
||||
}
|
||||
|
||||
fn bodies(s: &Session) -> Vec<String> {
|
||||
s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datums_by_each_definition() {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
assert_eq!(r, json!({"name": "Mid", "type": "plane", "point": [15.0, 0.0, 0.0], "normal": [1.0, 0.0, 0.0]}));
|
||||
let r = s
|
||||
.execute("geometry.datum", &json!({"name": "Tri", "by": {"type": "three_points", "a": [0, 0, 1], "b": [1, 0, 1], "c": [0, 1, 1]}}))
|
||||
.unwrap();
|
||||
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
|
||||
s.execute("geometry.datum", &json!({"name": "Tilt", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 3, 4]}})).unwrap();
|
||||
assert_eq!(s.model.datum("Tilt"), Some(&Datum::Plane { point: [0.0; 3], normal: [0.0, 0.6, 0.8] }));
|
||||
let r = s.execute("geometry.datum.axis", &json!({"name": "Z", "by": {"type": "principal", "axis": "z", "through": [1, 2, 0]}})).unwrap();
|
||||
assert_eq!(r, json!({"name": "Z", "type": "axis", "point": [1.0, 2.0, 0.0], "direction": [0.0, 0.0, 1.0]}));
|
||||
s.execute("geometry.datum.axis", &json!({"name": "D", "by": {"type": "two_points", "a": [0, 0, 0], "b": [0, 2, 0]}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "E", "by": {"type": "point_direction", "point": [0, 0, 0], "direction": [5, 0, 0]}}))
|
||||
.unwrap();
|
||||
assert_eq!(s.model.datums().len(), 6);
|
||||
s.execute("geometry.datum.delete", &json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.model.datums().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_datum_params_change_nothing() {
|
||||
let mut s = Session::default();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "by": {"type": "offset", "plane": "ab"}}),
|
||||
json!({"name": "A", "by": {"type": "offset", "plane": "xy", "offset": "x"}}),
|
||||
json!({"name": "A", "by": {"type": "three_points", "a": [0, 0, 0], "b": [1, 1, 1], "c": [2, 2, 2]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 0]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [1e300, 0, 0], "normal": [0, 0, 1]}}),
|
||||
json!({"name": "A", "by": {"type": "point_normal", "point": [0, 0, 0], "normal": [0, 0, 1], "extra": 1}}),
|
||||
json!({"name": "1A", "by": {"type": "offset", "plane": "xy"}}),
|
||||
json!({"name": "A", "by": {"type": "nope"}}),
|
||||
] {
|
||||
assert!(s.execute("geometry.datum", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"name": "A", "by": {"type": "two_points", "a": [1, 1, 1], "b": [1, 1, 1]}}),
|
||||
json!({"name": "A", "by": {"type": "principal", "axis": "w"}}),
|
||||
] {
|
||||
assert!(s.execute("geometry.datum.axis", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.model.datums().is_empty());
|
||||
assert!(s.journal.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partition_cuts_the_design_and_is_replayed_when_it_opens_again() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
let r = s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
let body = r["split"][0].as_str().unwrap().to_string();
|
||||
assert_eq!(r["cells"], json!([format!("{body}/a"), format!("{body}/b")]));
|
||||
assert_eq!(r["cut_faces"], json!(4));
|
||||
// The datum is in use.
|
||||
assert!(s.execute("geometry.datum.delete", &json!({"name": "Mid"})).is_err());
|
||||
|
||||
// A set on a cut face selects the cut face's pieces.
|
||||
let r = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let faces: Vec<&str> = r[0]["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert!(faces.contains(&"partition:Half:a#0"), "{faces:?}");
|
||||
|
||||
// Saved and opened again: the design is cut again by the model's partition.
|
||||
let file = dir.join("plate.fieldcraft");
|
||||
s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(bodies(&t), bodies(&s));
|
||||
// geometry.open again keeps the model, so its partition.
|
||||
let design = dir.join("plate.solidcraft");
|
||||
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["bodies"], json!(bodies(&s)));
|
||||
// A new model drops the partitions with the rest of the model.
|
||||
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap(), "model": "new"})).unwrap();
|
||||
assert_eq!(r["bodies"], json!([body]));
|
||||
|
||||
// Undo brings the whole design back; deleting the partition does too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bodies(&s), std::slice::from_ref(&body));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(bodies(&s).len(), 2);
|
||||
s.execute("geometry.partition.delete", &json!({"name": "Half"})).unwrap();
|
||||
assert_eq!(bodies(&s), [body]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partition_that_cuts_nothing_changes_nothing() {
|
||||
let (mut s, dir) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "Out", "by": {"type": "offset", "plane": "yz", "offset": 100}})).unwrap();
|
||||
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({"name": "P", "datum": "Out"}),
|
||||
json!({"name": "P", "datum": "Ax"}),
|
||||
json!({"name": "P", "datum": "Nope"}),
|
||||
json!({"name": "P", "datum": "Out", "bodies": ["Nope"]}),
|
||||
json!({"name": "P", "datum": "Out", "bodies": "x"}),
|
||||
json!({"name": "", "datum": "Out"}),
|
||||
] {
|
||||
assert!(s.execute("geometry.partition", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.model.partitions().is_empty());
|
||||
assert_eq!(bodies(&s).len(), 1);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ use fieldcraft_io::roots::Access;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::req;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const WRITE: CommandSpec = CommandSpec {
|
||||
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,
|
||||
requires: &[Requirement::Analysable, Requirement::ElementMaterial, Requirement::ElementSection],
|
||||
run: write,
|
||||
};
|
||||
|
||||
@@ -59,7 +62,7 @@ mod tests {
|
||||
assert!(s.execute("deck.write", &json!({"path": other})).is_err());
|
||||
assert!(s.execute("deck.write", &json!({"format": "abaqus"})).is_err());
|
||||
let mut empty = Session::default();
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Deck(_))));
|
||||
assert!(matches!(empty.execute("deck.write", &json!({"format": "abaqus", "path": other})), Err(EngineError::Unavailable(_))));
|
||||
assert!(!dir.join("other.inp").exists());
|
||||
assert!(matches!(s.execute("deck.write", &json!({"format": "abaqus", "path": dir.to_string_lossy()})), Err(EngineError::File(_))));
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{name, opt, req};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// The parameters other than `name`, read as a definition's JSON form.
|
||||
@@ -25,13 +26,15 @@ 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,
|
||||
$CREATE:ident, $EDIT:ident, $DELETE: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, requires: $requires:expr, exists: $exists:ident) => {
|
||||
pub const $CREATE: CommandSpec = CommandSpec {
|
||||
id: concat!("model.", $id, ".create"),
|
||||
label: concat!("New ", $label),
|
||||
params: concat!($doc, " — fails if the name is taken"),
|
||||
form: Some($form),
|
||||
kind: CommandKind::Model,
|
||||
requires: $requires,
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$add(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -42,7 +45,9 @@ 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,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
let n = name(p)?;
|
||||
s.model.$replace(n, fields::<$ty>(p, $what)?)?;
|
||||
@@ -53,7 +58,9 @@ 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,
|
||||
requires: &[Requirement::$exists],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
s.model.$remove(name(p)?)?;
|
||||
Ok(Value::Null)
|
||||
@@ -68,12 +75,16 @@ 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,
|
||||
SET_CREATE,
|
||||
SET_EDIT,
|
||||
SET_DELETE
|
||||
SET_DELETE,
|
||||
requires: &[],
|
||||
exists: Set
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -82,12 +93,16 @@ 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,
|
||||
MATERIAL_CREATE,
|
||||
MATERIAL_EDIT,
|
||||
MATERIAL_DELETE
|
||||
MATERIAL_DELETE,
|
||||
requires: &[],
|
||||
exists: Material
|
||||
);
|
||||
|
||||
/// A copy of material `name` with `change` applied, put back through the model's validation.
|
||||
@@ -103,7 +118,9 @@ 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,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
m.plastic = req(p, "plastic")?;
|
||||
@@ -116,7 +133,9 @@ 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,
|
||||
requires: &[Requirement::Material],
|
||||
run: |s: &mut Session, p: &Value| {
|
||||
change_material(s, p, |m, p| {
|
||||
// Absent leaves a field as it is; null removes it.
|
||||
@@ -141,12 +160,16 @@ 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,
|
||||
SECTION_CREATE,
|
||||
SECTION_EDIT,
|
||||
SECTION_DELETE
|
||||
SECTION_DELETE,
|
||||
requires: &[Requirement::Material, Requirement::Set],
|
||||
exists: Section
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -155,12 +178,16 @@ 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,
|
||||
REFERENCE_POINT_CREATE,
|
||||
REFERENCE_POINT_EDIT,
|
||||
REFERENCE_POINT_DELETE
|
||||
REFERENCE_POINT_DELETE,
|
||||
requires: &[],
|
||||
exists: ReferencePoint
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -169,12 +196,16 @@ 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,
|
||||
COUPLING_CREATE,
|
||||
COUPLING_EDIT,
|
||||
COUPLING_DELETE
|
||||
COUPLING_DELETE,
|
||||
requires: &[Requirement::ReferencePoint, Requirement::Set],
|
||||
exists: Coupling
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -183,12 +214,16 @@ 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,
|
||||
MASS_CREATE,
|
||||
MASS_EDIT,
|
||||
MASS_DELETE
|
||||
MASS_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Mass
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -197,12 +232,16 @@ 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,
|
||||
SPRING_CREATE,
|
||||
SPRING_EDIT,
|
||||
SPRING_DELETE
|
||||
SPRING_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: Spring
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -211,12 +250,16 @@ 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,
|
||||
BOUNDARY_CONDITION_CREATE,
|
||||
BOUNDARY_CONDITION_EDIT,
|
||||
BOUNDARY_CONDITION_DELETE
|
||||
BOUNDARY_CONDITION_DELETE,
|
||||
requires: &[Requirement::Target],
|
||||
exists: BoundaryCondition
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -225,12 +268,16 @@ 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,
|
||||
LOAD_CREATE,
|
||||
LOAD_EDIT,
|
||||
LOAD_DELETE
|
||||
LOAD_DELETE,
|
||||
requires: &[],
|
||||
exists: Load
|
||||
);
|
||||
|
||||
definition_commands!(
|
||||
@@ -239,19 +286,25 @@ 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,
|
||||
FLUID_DOMAIN_CREATE,
|
||||
FLUID_DOMAIN_EDIT,
|
||||
FLUID_DOMAIN_DELETE
|
||||
FLUID_DOMAIN_DELETE,
|
||||
requires: &[Requirement::Material],
|
||||
exists: FluidDomain
|
||||
);
|
||||
|
||||
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,
|
||||
requires: &[Requirement::FluidDomain],
|
||||
run: fluid_boundary,
|
||||
};
|
||||
|
||||
@@ -266,8 +319,10 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "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,
|
||||
requires: &[],
|
||||
run: step_create,
|
||||
};
|
||||
|
||||
@@ -275,7 +330,9 @@ 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,
|
||||
requires: &[Requirement::Step],
|
||||
run: step_edit,
|
||||
};
|
||||
|
||||
@@ -283,7 +340,9 @@ 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,
|
||||
requires: &[Requirement::Step],
|
||||
run: |s, p| s.model.remove_step(name(p)?).map(|_| Value::Null).map_err(Into::into),
|
||||
};
|
||||
|
||||
@@ -291,12 +350,21 @@ 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,
|
||||
requires: &[Requirement::Step],
|
||||
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,
|
||||
requires: &[],
|
||||
run: |s, _| Ok(activity(&s.model)),
|
||||
};
|
||||
|
||||
fn step_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = name(p)?;
|
||||
|
||||
@@ -36,7 +36,9 @@ 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,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::undo),
|
||||
};
|
||||
|
||||
@@ -44,7 +46,9 @@ 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,
|
||||
requires: &[],
|
||||
run: |s, p| repeat(s, p, Session::redo),
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "file.open",
|
||||
label: "Open Model",
|
||||
params: r#"{"path": "bracket.fieldcraft"} — opens the model, its mesh and its journal, and opens its design and imported meshes again from their files (one that is missing or changed is listed in `warnings`; the model still opens); unsaved changes of the current model are discarded and the undo history starts empty"#,
|
||||
form: Some(super::forms::FILE_OPEN),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
@@ -31,7 +33,9 @@ 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,
|
||||
requires: &[],
|
||||
run: save,
|
||||
};
|
||||
|
||||
@@ -39,15 +43,19 @@ 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,
|
||||
requires: &[],
|
||||
run: save_as,
|
||||
};
|
||||
|
||||
pub const RECOVERY_LIST: CommandSpec = CommandSpec {
|
||||
id: "file.recovery.list",
|
||||
label: "Recoverable Models",
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#,
|
||||
params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first, each with its id, path, saved_at, age (seconds) and node and element counts (default folder: FIELDCRAFT_RECOVERY_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
|
||||
form: Some(super::forms::RECOVERY_LIST),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: recovery_list,
|
||||
};
|
||||
|
||||
@@ -55,7 +63,9 @@ 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,
|
||||
requires: &[],
|
||||
run: recover,
|
||||
};
|
||||
|
||||
@@ -63,15 +73,19 @@ 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,
|
||||
requires: &[],
|
||||
run: recovery_discard,
|
||||
};
|
||||
|
||||
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)"#,
|
||||
params: r#"{"name": "cantilever"} — opens a sample model (fieldcraft_engine::sample; the list is in docs/examples.md) in place of the current one, unsaved changes discarded; every sample but `parts` is ready to solve (solve.run)"#,
|
||||
form: Some(super::forms::SAMPLE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[],
|
||||
run: open_sample,
|
||||
};
|
||||
|
||||
@@ -80,7 +94,15 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::<Vec<_>>().join(", ");
|
||||
let sample = crate::sample::find(name).ok_or_else(|| EngineError::Params(format!("no sample `{name}` (samples: {})", names())))?;
|
||||
let mut opened = Session::default();
|
||||
opened.run_script(&(sample.script)())?;
|
||||
if let Some(recipe) = sample.design {
|
||||
opened.design = Some(Arc::new(open_design(&fieldcraft_geometry::script_design(recipe)?)?));
|
||||
}
|
||||
if let Some(prepare) = sample.prepare {
|
||||
opened.run_script(&prepare())?;
|
||||
}
|
||||
let bodies = opened.design.as_ref().map_or(&[][..], |d| d.bodies.as_slice());
|
||||
let script = (sample.script)(bodies);
|
||||
opened.run_script(&script)?;
|
||||
// A sample just opened has nothing to save: it opens again the same way.
|
||||
opened.saved_revision = Some(opened.revision);
|
||||
s.replace_with(opened);
|
||||
@@ -89,6 +111,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 +147,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 +160,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 +201,22 @@ 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);
|
||||
design = opened.map(Arc::new);
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
|
||||
design = opened.and_then(|d| match super::datum::partitioned(&d, &file.model) {
|
||||
Ok(cut) => Some(Arc::new(cut)),
|
||||
Err(e) => {
|
||||
warnings.push(format!("the design's partitions could not be applied ({e}); the model opens without the design"));
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(d) = &design {
|
||||
warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message)));
|
||||
}
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
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);
|
||||
}
|
||||
@@ -195,6 +232,7 @@ impl Session {
|
||||
opened.limits = self.limits;
|
||||
opened.file_access = std::mem::take(&mut self.file_access);
|
||||
opened.clear_history();
|
||||
opened.mark_opened();
|
||||
*self = opened;
|
||||
}
|
||||
}
|
||||
@@ -226,8 +264,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}")))
|
||||
}
|
||||
|
||||
@@ -271,8 +308,15 @@ fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let entries = fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?);
|
||||
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
|
||||
let mut listed = Vec::new();
|
||||
for e in fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?) {
|
||||
let mut v = serde_json::to_value(&e).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("age".into(), json!(e.age()));
|
||||
}
|
||||
listed.push(v);
|
||||
}
|
||||
Ok(Value::Array(listed))
|
||||
}
|
||||
|
||||
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -388,6 +432,9 @@ mod tests {
|
||||
refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input})));
|
||||
s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap();
|
||||
defined(&mut s);
|
||||
// A section, so deck.write has all it needs and reaches its path.
|
||||
s.execute("model.set.create", &json!({"name": "all", "elements": [1]})).unwrap();
|
||||
s.execute("model.section.create", &json!({"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}})).unwrap();
|
||||
// Writes go to the write root only: not the read root, not elsewhere.
|
||||
refused(s.execute("file.save_as", &json!({"path": input.join("m.fieldcraft")})));
|
||||
refused(s.execute("file.save_as", &json!({"path": outside.join("m.fieldcraft")})));
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
use crate::schema::ProblemKind;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::tagged_mesh;
|
||||
use fieldcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use fieldcraft_geometry::{Design, MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
|
||||
use fieldcraft_io::SourceFile;
|
||||
use fieldcraft_io::roots::Access;
|
||||
use fieldcraft_model::Model;
|
||||
use fieldcraft_model::geometry::{Entity, GeometryRef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
params: r#"{"path": "part.solidcraft", "model"?: "keep"} — rebuilds the SolidCraft design and makes its bodies the geometry. `model`: "keep" (default) keeps the mesh and every definition, so opening the same design again after an edit picks up the change and sets follow their persistent face names; "new" starts a new model with the design (no mesh, sets, materials, sections, BCs, loads or steps; the unit system stays; the model is untitled until saved, and undo restores the previous file). The reply's `related` is false when the model (before the open) refers to bodies (mesh tags, set geometry) and the design has none of them, and `stale` lists what no longer resolves: `mesh_bodies` (bodies the mesh is tagged with that the design lacks) `sets` (sets naming a body or face it lacks) and `seeds` (edge seeds on edges it lacks). One undo step"#,
|
||||
form: Some(super::forms::OPEN_DESIGN),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: open,
|
||||
};
|
||||
|
||||
pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
params: "{} — the open design's bodies: measures, persistent face names (by face index) and edges (persistent name `<face>|<face>`, `~k` when several separate the same faces, and length)",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[], // No design is an answer too: no bodies.
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
@@ -28,7 +37,9 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
form: Some(super::forms::TESSELLATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
@@ -36,30 +47,102 @@ 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,
|
||||
requires: &[],
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_model = match p.get("model") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("keep") => false,
|
||||
Some("new") => true,
|
||||
_ => return Err(EngineError::Params(r#"`model` must be "keep" or "new""#.into())),
|
||||
},
|
||||
};
|
||||
let path = super::file_param(s, p, "path", Access::Read)?;
|
||||
let bytes = 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 opened = open_design(&bytes)?;
|
||||
// The model's partitions cut the design again (a new model has none).
|
||||
let design = if new_model { opened } else { super::datum::partitioned(&opened, &s.model)? };
|
||||
let mut fit = fit(s, &design);
|
||||
if new_model {
|
||||
let mut model = Model::default();
|
||||
model.set_units(s.model.units());
|
||||
s.model = model;
|
||||
s.surfaces.clear();
|
||||
s.sources.meshes.clear();
|
||||
s.results_view = None;
|
||||
// A new model is untitled: saving asks for a file instead of overwriting the old model's
|
||||
// (undo brings the old file back with the old model).
|
||||
s.set_document(Default::default());
|
||||
fit.mesh_bodies.clear();
|
||||
fit.sets.clear();
|
||||
fit.seeds.clear();
|
||||
}
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
"model": if new_model { "new" } else { "kept" },
|
||||
"related": fit.related,
|
||||
"stale": {"mesh_bodies": fit.mesh_bodies, "sets": fit.sets, "seeds": fit.seeds},
|
||||
});
|
||||
// Another design file (or a new model) is another document; the same file opened again after
|
||||
// an edit is the same one.
|
||||
if new_model || s.sources.design.as_ref().is_none_or(|d| d.path() != source.path()) {
|
||||
s.mark_opened();
|
||||
}
|
||||
s.design = Some(Arc::new(design));
|
||||
s.sources.design = Some(source);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How the model's geometry references fit a design about to become the geometry.
|
||||
struct Fit {
|
||||
/// Whether the design (or an imported surface) has a body the model refers to, or the model
|
||||
/// refers to none: `false` means the model is of another part.
|
||||
related: bool,
|
||||
/// Bodies the mesh is tagged with that neither the design nor a surface has.
|
||||
mesh_bodies: Vec<String>,
|
||||
/// Sets naming a body or a face that is not there.
|
||||
sets: Vec<String>,
|
||||
/// Edge seeds on an edge the design lacks.
|
||||
seeds: Vec<String>,
|
||||
}
|
||||
|
||||
fn fit(s: &Session, design: &Design) -> Fit {
|
||||
let has = |body: &str| design.body(body).is_some() || s.surfaces.iter().any(|t| t.name == body);
|
||||
let resolves = |r: &GeometryRef| match (&r.entity, design.body(&r.body)) {
|
||||
(Entity::Face(face), Some(b)) => b.faces().iter().any(|f| &f.name == face),
|
||||
_ => has(&r.body),
|
||||
};
|
||||
let tagged: BTreeSet<&str> = s.model.classification().entities().iter().map(|r| r.body.as_str()).collect();
|
||||
let mut referenced = tagged.clone();
|
||||
referenced.extend(s.model.sets().values().flat_map(|set| set.geometry.iter().map(|r| r.body.as_str())));
|
||||
Fit {
|
||||
related: referenced.is_empty() || referenced.iter().any(|b| has(b)),
|
||||
mesh_bodies: tagged.into_iter().filter(|b| !has(b)).map(str::to_string).collect(),
|
||||
sets: s.model.sets().iter().filter(|(_, set)| !set.geometry.iter().all(resolves)).map(|(n, _)| n.clone()).collect(),
|
||||
seeds: s
|
||||
.model
|
||||
.seeds()
|
||||
.iter()
|
||||
.filter(|seed| super::seeds::seeded_edges(design, &seed.edge).map_or(true, |e| e.is_empty()))
|
||||
.map(|seed| seed.edge.to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(design) = &s.design else { return Ok(json!([])) };
|
||||
let mut out = Vec::with_capacity(design.bodies.len());
|
||||
for b in &design.bodies {
|
||||
let m = b.measure()?;
|
||||
let edges: Vec<Value> = b.edges()?.iter().map(|e| json!({"name": e.name, "length": e.length()})).collect();
|
||||
out.push(json!({
|
||||
"name": b.name,
|
||||
"volume": m.volume,
|
||||
@@ -67,20 +150,21 @@ fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"centroid": m.centroid,
|
||||
"bbox": [m.bbox_min, m.bbox_max],
|
||||
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
|
||||
"edges": edges,
|
||||
}));
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?;
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
|
||||
}
|
||||
|
||||
/// The body named by `body`, tessellated within `tolerance`.
|
||||
pub(crate) fn body_mesh<'a>(s: &'a Session, p: &Value) -> Result<(&'a Shape, SurfaceMesh)> {
|
||||
let shape = body(s, p)?;
|
||||
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::Params("`tolerance` must be a number (mm)".into()))?;
|
||||
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::param("tolerance", ProblemKind::NotANumber))?;
|
||||
Ok((shape, shape.tessellate(tolerance)?))
|
||||
}
|
||||
|
||||
@@ -101,7 +185,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();
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
//! Structured hexahedral meshing of the open design's bodies or of imported surfaces.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
|
||||
use fieldcraft_mesh::hex::{Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
|
||||
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
|
||||
use fieldcraft_mesh::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.generate",
|
||||
label: "Generate Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the mesh with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
label: "Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_generate,
|
||||
};
|
||||
|
||||
@@ -28,13 +32,16 @@ const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
|
||||
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
|
||||
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
|
||||
|
||||
/// One body to mesh.
|
||||
struct Input<'a> {
|
||||
name: String,
|
||||
mesh: Cow<'a, SurfaceMesh>,
|
||||
surfaces: Vec<Option<Surface>>,
|
||||
face_names: Vec<String>,
|
||||
diagonal: f64,
|
||||
/// One body to mesh (also the input of `mesh.hex.octree`).
|
||||
pub(super) struct Input<'a> {
|
||||
pub(super) name: String,
|
||||
pub(super) mesh: Cow<'a, SurfaceMesh>,
|
||||
pub(super) surfaces: Vec<Option<Surface>>,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) diagonal: f64,
|
||||
/// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its
|
||||
/// edges.
|
||||
pub(super) seeds: Vec<fieldcraft_mesh::seeds::EdgeSeed>,
|
||||
}
|
||||
|
||||
fn analytic(s: FaceSurface) -> Surface {
|
||||
@@ -47,14 +54,10 @@ fn analytic(s: FaceSurface) -> Surface {
|
||||
}
|
||||
}
|
||||
|
||||
fn number(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
pub(super) fn number(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && *x > 0.0)
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive number (mm)"))),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x > 0.0).map(Some).ok_or_else(|| EngineError::param(key, ProblemKind::NotPositive)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ fn diagonal(lo: [f64; 3], hi: [f64; 3]) -> f64 {
|
||||
(0..3).map(|i| (hi[i] - lo[i]).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>> {
|
||||
pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>> {
|
||||
let diag = shape.size()?;
|
||||
let tol = tolerance.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * diag);
|
||||
if tol < MIN_RELATIVE_TOLERANCE * diag {
|
||||
@@ -70,26 +73,33 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
|
||||
}
|
||||
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
|
||||
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
|
||||
}
|
||||
|
||||
fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
/// The imported surface `surface` names, with the body name its mesh is tagged with.
|
||||
pub(super) fn surface<'a>(s: &'a Session, p: &Value) -> Result<(String, &'a fieldcraft_geometry::tagged_mesh::TaggedSurface)> {
|
||||
let n = s.surfaces.len();
|
||||
let index = p.get("surface").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n);
|
||||
let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
|
||||
let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?;
|
||||
let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() };
|
||||
Ok((name, t))
|
||||
}
|
||||
|
||||
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
let tolerance = number(p, "tolerance")?;
|
||||
match (p.get("body"), p.get("surface")) {
|
||||
(Some(b), None) => {
|
||||
let name = b.as_str().ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?;
|
||||
let name = b.as_str().ok_or_else(|| EngineError::param("body", ProblemKind::NotAName))?;
|
||||
let shape = s
|
||||
.design
|
||||
.as_deref()
|
||||
.and_then(|d| d.body(name))
|
||||
.ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))?;
|
||||
Ok(vec![shape_input(shape, tolerance)?])
|
||||
Ok(vec![Input { seeds: super::seeds::edge_seeds(s)?, ..shape_input(shape, tolerance)? }])
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
let n = s.surfaces.len();
|
||||
let index = i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < n);
|
||||
let index = index.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
|
||||
let t = s.surfaces.get(index).ok_or_else(|| EngineError::Params("no such surface".into()))?;
|
||||
(None, Some(_)) => {
|
||||
let (name, t) = surface(s, p)?;
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for q in &t.mesh.positions {
|
||||
for k in 0..3 {
|
||||
@@ -97,17 +107,17 @@ fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
hi[k] = hi[k].max(q[k]);
|
||||
}
|
||||
}
|
||||
let name = if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() };
|
||||
// Faces named by index, as mesh.tet.generate names them.
|
||||
let face_names = (0..t.faces).map(|f| f.to_string()).collect();
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi) }])
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }])
|
||||
}
|
||||
(None, None) => {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?;
|
||||
if design.bodies.is_empty() || design.bodies.len() > MAX_BODIES {
|
||||
return Err(EngineError::Params(format!("the design has {} bodies; meshing takes 1 to {MAX_BODIES}", design.bodies.len())));
|
||||
}
|
||||
design.bodies.iter().map(|b| shape_input(b, tolerance)).collect()
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
design.bodies.iter().map(|b| Ok(Input { seeds: seeds.clone(), ..shape_input(b, tolerance)? })).collect()
|
||||
}
|
||||
_ => Err(EngineError::Params("give `body` or `surface`, not both".into())),
|
||||
}
|
||||
@@ -117,7 +127,9 @@ 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,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_sweep,
|
||||
};
|
||||
|
||||
@@ -125,33 +137,37 @@ 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,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_mapped,
|
||||
};
|
||||
|
||||
pub const HEX_BLOCKS: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.blocks",
|
||||
label: "Block Hex Mesh",
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the mesh with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
form: Some(super::forms::HEX_BLOCKS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: hex_blocks,
|
||||
};
|
||||
|
||||
fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = number(p, "size")?;
|
||||
let quadratic = order(p)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let shapes: Vec<&Shape> = match p.get("bodies") {
|
||||
None => design.bodies.iter().collect(),
|
||||
Some(v) => {
|
||||
let names = v.as_array().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
|
||||
let names = v.as_array().ok_or_else(|| EngineError::param("bodies", ProblemKind::NotNames))?;
|
||||
// Bounded before the duplicate check, which is quadratic in the list.
|
||||
if names.len() > MAX_CELLS {
|
||||
return Err(EngineError::Params(format!("`bodies` lists {} bodies; at most {MAX_CELLS} are meshed together", names.len())));
|
||||
}
|
||||
let mut out: Vec<&Shape> = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
let name = n.as_str().ok_or_else(|| EngineError::Params("`bodies` must be a list of body names".into()))?;
|
||||
let name = n.as_str().ok_or_else(|| EngineError::param("bodies", ProblemKind::NotNames))?;
|
||||
let shape = design.body(name).ok_or_else(|| EngineError::Params(format!("no body `{name}`")))?;
|
||||
if out.iter().any(|b| b.name == shape.name) {
|
||||
return Err(EngineError::Params(format!("body `{name}` is listed twice")));
|
||||
@@ -201,14 +217,17 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
quadratic,
|
||||
chordal_tolerance: tolerance,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: super::seeds::edge_seeds(s)?,
|
||||
..HexOptions::default()
|
||||
};
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
let m = &cm.mesh;
|
||||
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
// Replace the cells' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, inputs.iter().map(|b| b.name.as_str()));
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(label(i)?, *xyz)?;
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let mut body_tags = Vec::with_capacity(inputs.len());
|
||||
for b in &inputs {
|
||||
@@ -216,15 +235,15 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for e in 0..m.element_count() {
|
||||
let cell = cm.element_cell.get(e).map_or(0, |&c| c as usize);
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| i + 1).collect();
|
||||
model.set_element(label(e)?, m.kind, nodes)?;
|
||||
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), m, 1)?;
|
||||
s.model = model;
|
||||
tag_faces(model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, first_element)?;
|
||||
let blocks: Vec<Value> =
|
||||
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
|
||||
Ok(json!({
|
||||
let reply = json!({
|
||||
"cells": inputs.len(),
|
||||
"interfaces": cm.interfaces,
|
||||
"description": m.report.description,
|
||||
@@ -237,12 +256,8 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"surface_volume": m.report.surface_volume,
|
||||
"size": h,
|
||||
"blocks": blocks,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A 1-based model label for 0-based index `i`.
|
||||
fn label(i: usize) -> Result<u32> {
|
||||
u32::try_from(i).ok().and_then(|x| x.checked_add(1)).ok_or_else(|| EngineError::Params("too many nodes or elements".into()))
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// The `order` parameter: false for hex8, true for hex20.
|
||||
@@ -252,7 +267,7 @@ fn order(p: &Value) -> Result<bool> {
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(1) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
_ => Err(EngineError::Params("`order` must be 1 (hex8) or 2 (hex20)".into())),
|
||||
_ => Err(EngineError::invalid("order", "must be 1 (hex8) or 2 (hex20)")),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -284,41 +299,16 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
let bodies = inputs(s, p)?;
|
||||
let mut meshes: Vec<(Input, HexMesh)> = Vec::with_capacity(bodies.len());
|
||||
for b in bodies {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
|
||||
if h < MIN_RELATIVE_SIZE * b.diagonal {
|
||||
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
|
||||
}
|
||||
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) };
|
||||
let options =
|
||||
HexOptions { method, quadratic, chordal_tolerance: chord, max_elements: fieldcraft_model::MAX_ELEMENTS / 8, ..HexOptions::default() };
|
||||
let mesh = hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let mesh = hex_body(p, &b, method, quadratic, size)?;
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
// A copy: the inputs borrow the session.
|
||||
let mut model = s.model.clone();
|
||||
let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str()));
|
||||
let mut report = Vec::with_capacity(meshes.len());
|
||||
let (mut node_label, mut element_label) = (1u32, 1u32);
|
||||
for (b, m) in &meshes {
|
||||
let first_node = node_label;
|
||||
for xyz in &m.nodes {
|
||||
model.set_node(node_label, *xyz)?;
|
||||
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
let first_element = element_label;
|
||||
for e in 0..m.element_count() {
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
model.set_element(element_label, m.kind, nodes)?;
|
||||
model.tag_element(element_label, body_tag)?;
|
||||
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
|
||||
}
|
||||
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, m, first_element)?;
|
||||
add_hex(&mut model, b, m)?;
|
||||
report.push(json!({
|
||||
"body": b.name,
|
||||
"method": m.report.method.name(),
|
||||
@@ -332,15 +322,66 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
"surface_volume": m.report.surface_volume,
|
||||
}));
|
||||
}
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
s.model = model;
|
||||
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent);
|
||||
/// the model is not touched. Errors name the body.
|
||||
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>) -> Result<HexMesh> {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
|
||||
if h < MIN_RELATIVE_SIZE * b.diagonal {
|
||||
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
|
||||
}
|
||||
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let chord = if b.surfaces.is_empty() { 0.0 } else { number(p, "tolerance")?.unwrap_or(DEFAULT_RELATIVE_TOLERANCE * b.diagonal) };
|
||||
let options = HexOptions {
|
||||
method,
|
||||
quadratic,
|
||||
chordal_tolerance: chord,
|
||||
max_elements: fieldcraft_model::MAX_ELEMENTS / 8,
|
||||
seeds: b.seeds.clone(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))
|
||||
}
|
||||
|
||||
/// Add one body's hex mesh after the model's largest labels, tagging the elements with the body
|
||||
/// and the boundary element faces with its B-rep faces; returns the first node and element
|
||||
/// labels.
|
||||
pub(super) fn add_hex(model: &mut Model, b: &Input, m: &HexMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(model, m.nodes.len(), m.element_count())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
for e in 0..m.element_count() {
|
||||
let nodes: Vec<u32> = m.element(e).unwrap_or(&[]).iter().map(|&i| first_node + i).collect();
|
||||
let l = first_element + e as u32;
|
||||
model.set_element(l, m.kind, nodes)?;
|
||||
model.tag_element(l, body_tag)?;
|
||||
}
|
||||
tag_faces(model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
Ok((first_node, first_element))
|
||||
}
|
||||
|
||||
/// Tag every boundary element face with the B-rep face it lies on, of the body
|
||||
/// (`bodies[cell(element)]`) its element meshes.
|
||||
fn tag_faces(model: &mut Model, bodies: &[Input<'_>], cell: impl Fn(u32) -> usize, m: &HexMesh, first_element: u32) -> Result<()> {
|
||||
pub(super) fn tag_faces(
|
||||
model: &mut Model,
|
||||
bodies: &[Input<'_>],
|
||||
cell: impl Fn(u32) -> usize,
|
||||
boundary: &[BoundaryFace],
|
||||
first_element: u32,
|
||||
) -> Result<()> {
|
||||
let mut tags: std::collections::BTreeMap<(usize, u32), u32> = std::collections::BTreeMap::new();
|
||||
for f in &m.boundary {
|
||||
for f in boundary {
|
||||
let c = cell(f.element);
|
||||
let tag = match tags.get(&(c, f.brep_face)) {
|
||||
Some(&t) => t,
|
||||
@@ -461,6 +502,22 @@ mod tests {
|
||||
assert_eq!(two["cells"], 2);
|
||||
}
|
||||
|
||||
/// The Partitioned Mesh form's "All hex" option is mesh.hex.blocks.
|
||||
#[test]
|
||||
fn mesh_cells_all_blocks_is_mesh_hex_blocks() {
|
||||
let (_dir, mut s) = split_box(false);
|
||||
let blocks = s.execute("mesh.hex.blocks", &json!({"size": 2.5})).unwrap();
|
||||
let mut cells = s.execute("mesh.cells", &json!({"default": "blocks", "size": 2.5})).unwrap();
|
||||
let mut blocks = blocks;
|
||||
// The same mesh, in place of the first one.
|
||||
let replaced = cells.as_object_mut().and_then(|o| o.remove("replaced")).unwrap();
|
||||
assert_eq!(replaced.as_array().map(Vec::len), Some(4), "{replaced}");
|
||||
blocks.as_object_mut().and_then(|o| o.remove("replaced"));
|
||||
assert_eq!(cells, blocks);
|
||||
let e = s.execute("mesh.cells", &json!({"default": "blocks", "methods": {"Box": "tet"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("methods"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_that_do_not_conform_and_hostile_params_are_errors() {
|
||||
let (_dir, mut s) = split_box(true);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::schema::ProblemKind;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -6,17 +7,23 @@ use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
|
||||
use fieldcraft_mesh::delaunay::Triangulation;
|
||||
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, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
|
||||
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{label, labels, listed, optional_number, points, xyz};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const NODE_ADD: CommandSpec = CommandSpec {
|
||||
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,
|
||||
requires: &[],
|
||||
run: node_add,
|
||||
};
|
||||
|
||||
@@ -24,7 +31,9 @@ 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,
|
||||
requires: &[Requirement::Nodes],
|
||||
run: node_remove,
|
||||
};
|
||||
|
||||
@@ -32,18 +41,29 @@ 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,
|
||||
requires: &[Requirement::Nodes],
|
||||
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,
|
||||
requires: &[Requirement::Mesh],
|
||||
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,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
@@ -51,7 +71,9 @@ 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,
|
||||
requires: &[],
|
||||
run: delaunay_points,
|
||||
};
|
||||
|
||||
@@ -76,32 +98,41 @@ 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,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: surface_check,
|
||||
};
|
||||
|
||||
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 "max_steiner": 50000 (0..=10000000), "max_tets": 20000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary: every boundary triangle is a union of tet faces, Steiner points only on boundary edges, no quality refinement yet. Adds tet4 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 rounded to doubles 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. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: tet_generate,
|
||||
};
|
||||
|
||||
/// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for.
|
||||
const TET_MAX_STEINER: u64 = 10_000_000;
|
||||
const TET_MAX_TETS: u64 = 50_000_000;
|
||||
const TET_MAX_POINTS: u64 = 20_000_000;
|
||||
const TET_MAX_PASSES: u64 = 200;
|
||||
|
||||
/// An optional whole-number parameter in `range`.
|
||||
fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
|
||||
pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| range.contains(n))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a whole number in {}..={}", range.start(), range.end())))?,
|
||||
Some(v) => v.as_u64().filter(|n| range.contains(n)).ok_or_else(|| EngineError::param(key, whole_in(&range)))?,
|
||||
};
|
||||
usize::try_from(v).map_err(|_| EngineError::Params(format!("`{key}` is too large")))
|
||||
usize::try_from(v).map_err(|_| EngineError::param(key, whole_in(&range)))
|
||||
}
|
||||
|
||||
/// A whole number in `range`, as a problem's kind.
|
||||
fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
|
||||
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
|
||||
ProblemKind::NotAWholeNumber { min: int(*range.start()), max: int(*range.end()) }
|
||||
}
|
||||
|
||||
/// The boundary options of `mesh.surface.check`, `mesh.tet.generate` and the hex meshers.
|
||||
@@ -114,66 +145,234 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
|
||||
})
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cdt_options = CdtOptions {
|
||||
max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?,
|
||||
max_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?,
|
||||
};
|
||||
let (surface, body, face_names) = {
|
||||
let Input { mesh, body, faces } = surface_input(s, p)?;
|
||||
let options = surface_options(p, &mesh)?;
|
||||
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces)
|
||||
};
|
||||
let cdt = tetrahedralize(&surface, &cdt_options)?;
|
||||
let gate = cdt.gate(surface.volume())?.into_result()?;
|
||||
/// An optional number in `range`.
|
||||
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
let v = optional_number(p, key, default)?;
|
||||
if range.contains(&v) { Ok(v) } else { Err(EngineError::param(key, ProblemKind::NotInRange { min: *range.start(), max: *range.end() })) }
|
||||
}
|
||||
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, cdt.points.len(), cdt.tets.len())?;
|
||||
for (i, xyz) in cdt.points.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, *xyz)?;
|
||||
/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all.
|
||||
pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
|
||||
let d = QualityOptions::default();
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD)
|
||||
.ok_or_else(|| EngineError::param("size", ProblemKind::NotPositive))?,
|
||||
),
|
||||
};
|
||||
let order = match p.get("order") {
|
||||
None => Order::Linear,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("linear") => Order::Linear,
|
||||
Some("quadratic") => Order::Quadratic,
|
||||
_ => return Err(EngineError::param("order", ProblemKind::NotAChoice)),
|
||||
},
|
||||
};
|
||||
let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?;
|
||||
let improve_options = ImproveOptions {
|
||||
max_passes: optional_count(p, "max_passes", d.improve_options.max_passes as u64, 0..=TET_MAX_PASSES)?,
|
||||
good: fieldcraft_mesh::tet::geometry::sin_cos(target.to_radians()).0,
|
||||
..d.improve_options
|
||||
};
|
||||
let quality = super::opt::<bool>(p, "quality")?.unwrap_or(true);
|
||||
if !quality && order == Order::Quadratic {
|
||||
return Err(EngineError::Params("quadratic elements need the quality stage (`quality`: true)".into()));
|
||||
}
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
|
||||
for (i, t) in cdt.tets.iter().enumerate() {
|
||||
let options = QualityOptions {
|
||||
size,
|
||||
curvature_angle: number_in(p, "curvature_angle", d.curvature_angle.to_degrees(), 0.0..=90.0)?.to_radians(),
|
||||
grading: number_in(p, "grading", d.grading, 0.05..=2.0)?,
|
||||
improve_options,
|
||||
order,
|
||||
max_tets: optional_count(p, "max_tets", max_tets, 1..=TET_MAX_TETS)?,
|
||||
max_points: optional_count(p, "max_points", d.max_points as u64, 1..=TET_MAX_POINTS)?,
|
||||
..d
|
||||
};
|
||||
Ok((options, quality))
|
||||
}
|
||||
|
||||
/// The exact surfaces of a body's faces, for the quality stage's projections (none when the
|
||||
/// kernel cannot give them: the stage then keeps to the tessellation).
|
||||
pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec<Option<AnalyticSurface>> {
|
||||
use fieldcraft_geometry::FaceSurface as F;
|
||||
shape
|
||||
.face_surfaces()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
f.map(|f| match f {
|
||||
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
|
||||
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
|
||||
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
|
||||
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
|
||||
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn quality_json(q: &TetQuality) -> Value {
|
||||
json!({
|
||||
"tets": q.tets,
|
||||
"min_dihedral": q.min_dihedral,
|
||||
"max_dihedral": q.max_dihedral,
|
||||
"min_radius_ratio": q.min_radius_ratio,
|
||||
"mean_radius_ratio": q.mean_radius_ratio,
|
||||
"slivers": q.slivers,
|
||||
"inadmissible": q.inadmissible,
|
||||
})
|
||||
}
|
||||
|
||||
/// One body's tetrahedra, ready to add, with what its report needs of the surface and the CDT.
|
||||
pub(super) struct BodyTets {
|
||||
pub(super) name: String,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) mesh: QualityMesh,
|
||||
steiner: usize,
|
||||
cavities: usize,
|
||||
surface_volume: f64,
|
||||
}
|
||||
|
||||
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
|
||||
/// names the body.
|
||||
pub(super) 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 {
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
|
||||
quality_mesh(&surface, &tets, analytic, &options)?
|
||||
} else {
|
||||
raw_mesh(&surface, &tets)?
|
||||
};
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
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 };
|
||||
// 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)?);
|
||||
}
|
||||
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let cleared = super::replace::clear(&mut s.model, meshed.iter().map(|b| b.name.as_str()));
|
||||
let mut bodies = Vec::with_capacity(meshed.len());
|
||||
for b in &meshed {
|
||||
let (first_node, first_element) = add_tets(&mut s.model, &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,
|
||||
},
|
||||
"seeds": r.seeds.iter().map(|(name, count, edges)| json!({"edge": name, "count": count, "mesh_edges": edges})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
|
||||
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
|
||||
let first = bodies.first();
|
||||
let reply = json!({
|
||||
"kind": meshed.first().map(|b| b.mesh.kind.name()),
|
||||
"nodes": count(|b| b.mesh.points.len()),
|
||||
"elements": count(|b| b.mesh.elements.len()),
|
||||
"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,
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// Add one body's elements after the model's largest labels, tagging them with the body and each
|
||||
/// boundary element face with its geometry face; returns the first node and element labels.
|
||||
pub(super) fn add_tets(model: &mut fieldcraft_model::Model, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(model, mesh.points.len(), mesh.elements.len())?;
|
||||
for (i, xyz) in mesh.points.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(body)?)?;
|
||||
for (i, e) in mesh.elements.iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, ElementKind::Tet4, t.iter().map(|&v| first_node + v).collect())?;
|
||||
s.model.tag_element(label, body_tag)?;
|
||||
model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
|
||||
model.tag_element(label, body_tag)?;
|
||||
}
|
||||
let mut face_tags: Vec<Option<u32>> = vec![None; face_names.len()];
|
||||
for b in &cdt.boundary {
|
||||
let face = surface.tri_face().get(b.triangle as usize).copied().unwrap_or(u32::MAX) as usize;
|
||||
for b in &mesh.boundary {
|
||||
let face = b.face as usize;
|
||||
let tag = match face_tags.get(face).copied().flatten() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let name =
|
||||
face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary triangle {} has no geometry face", b.triangle)))?;
|
||||
let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?;
|
||||
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
|
||||
let t = model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
|
||||
if let Some(slot) = face_tags.get_mut(face) {
|
||||
*slot = Some(t);
|
||||
}
|
||||
t
|
||||
}
|
||||
};
|
||||
let number = b.tet4_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no tet4 face number", b.opposite)))?;
|
||||
s.model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
|
||||
model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"body": body,
|
||||
"nodes": cdt.points.len(),
|
||||
"elements": cdt.tets.len(),
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"steiner": cdt.steiner.len(),
|
||||
"cavities": cdt.stats.cavities,
|
||||
"boundary_faces": cdt.boundary.len(),
|
||||
"volume": gate.volume,
|
||||
"boundary_volume": surface.volume(),
|
||||
}))
|
||||
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"#,
|
||||
params: r#"{"size": 1.0, "kind": "quad4|quad8|tri3|tri6" (default quad4), and either "polygon": [[x, y], ...] with optional "holes": [[[x, y], ...], ...] and "name": "Plane" (the body name its tags use), or "body": "Body1", "face": "F2:top", "tolerance": 0.05 (a planar face of an open design, parallel to XY); optional "max_elements": 1000000 (1..=4000000)} — meshes the planar region with 2D elements in the XY plane (a face at z = c is meshed at z = 0) for plane stress, plane strain or axisymmetric sections (x the radius, Y the axis): free quadrilaterals (or constrained Delaunay triangles) of about `size`, mid-side nodes on the boundary for quadratic kinds. Replaces the body's mesh (as mesh.tet.generate), then adds nodes and elements after the largest existing labels and tags them: the elements with the face (polygon: face `area` of body `name`), the boundary element edges with edges named after the neighbouring face of the body (polygon: `e1`, `e2`, … side by side, the outer loop then each hole, and vertices `v1`, … at the corners), so sets on those edges carry loads and boundary conditions"#,
|
||||
form: Some(super::forms::PLANAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: planar,
|
||||
};
|
||||
|
||||
@@ -203,14 +402,14 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| *v > 0.0 && v.is_finite())
|
||||
.ok_or_else(|| EngineError::Params("`size` must be a positive number (the element size)".into()))?;
|
||||
.ok_or_else(|| super::wrong(p, "size", ProblemKind::NotPositive))?;
|
||||
let kind = match p.get("kind") {
|
||||
None => ElementKind::Quad4,
|
||||
Some(k) => k
|
||||
.as_str()
|
||||
.and_then(ElementKind::from_name)
|
||||
.filter(|k| k.dimension() == 2)
|
||||
.ok_or_else(|| EngineError::Params("`kind` must be quad4, quad8, tri3 or tri6".into()))?,
|
||||
.ok_or_else(|| EngineError::param("kind", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
let opts = fieldcraft_mesh::quad::QuadOptions {
|
||||
max_elements: optional_count(p, "max_elements", 1_000_000, 1..=PLANAR_MAX_ELEMENTS)?,
|
||||
@@ -232,7 +431,7 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let name = match p.get("name") {
|
||||
None => "Plane".to_string(),
|
||||
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`name` must be a body name".into()))?,
|
||||
Some(n) => n.as_str().map(str::to_string).ok_or_else(|| EngineError::param("name", ProblemKind::NotAName))?,
|
||||
};
|
||||
GeometryRef::body(name.clone())?;
|
||||
let mut id = 0u32;
|
||||
@@ -252,7 +451,7 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(loops, name, "area".to_string(), names)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`face` must be a face name".into()))?;
|
||||
let face_name = p.get("face").and_then(Value::as_str).ok_or_else(|| EngineError::param("face", ProblemKind::NotAName))?;
|
||||
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
|
||||
let face = shape.face_index(face_name).ok_or_else(|| EngineError::Params(format!("body `{}` has no face `{face_name}`", shape.name)))?;
|
||||
let b = face_boundary(&mesh.positions, &mesh.triangles, &mesh.tri_face, face, mesh.weld_distance)?;
|
||||
@@ -263,6 +462,8 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let m = mesh_region(&loops, kind, size, &opts)?;
|
||||
|
||||
// Replace the body's mesh (replace.rs); other bodies and the definitions stay.
|
||||
let cleared = super::replace::clear(&mut s.model, [body.as_str()]);
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, m.nodes.len(), m.elements.len())?;
|
||||
for (i, xy) in m.nodes.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?;
|
||||
@@ -293,8 +494,8 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.model.tag_node(first_node + node, t)?;
|
||||
}
|
||||
}
|
||||
let edges: Vec<&String> = tags.keys().filter_map(|id| names.get(id)).collect();
|
||||
Ok(json!({
|
||||
let edges: Vec<String> = tags.keys().filter_map(|id| names.get(id)).cloned().collect();
|
||||
let reply = json!({
|
||||
"body": body,
|
||||
"face": face,
|
||||
"kind": kind.name(),
|
||||
@@ -303,7 +504,18 @@ fn planar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"edges": edges,
|
||||
}))
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// The body `mesh.planar` fills: `body`, or the polygon's `name` (default `Plane`).
|
||||
pub(super) fn planar_body(_: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
let name = match (p.get("polygon"), p.get("body")) {
|
||||
(Some(_), None) => p.get("name").map_or(Some("Plane"), Value::as_str),
|
||||
(None, Some(b)) => b.as_str(),
|
||||
_ => None,
|
||||
};
|
||||
Ok(vec![name.ok_or_else(|| EngineError::Params("give either `polygon` or `body`".into()))?.to_string()])
|
||||
}
|
||||
|
||||
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -342,7 +554,7 @@ fn number(v: f64) -> Value {
|
||||
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bins = match p.get("bins") {
|
||||
None => 10,
|
||||
Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::Params("`bins` must be a whole number".into()))?,
|
||||
Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::invalid("bins", "must be a whole number"))?,
|
||||
};
|
||||
let selection = match p.get("elements") {
|
||||
None => None,
|
||||
@@ -367,6 +579,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,
|
||||
@@ -378,21 +591,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
|
||||
@@ -400,7 +625,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())),
|
||||
}
|
||||
@@ -411,7 +636,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,
|
||||
@@ -556,8 +781,9 @@ mod tests {
|
||||
// A tagged tetrahedron, and the same with a face missing.
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet =
|
||||
SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
}
|
||||
@@ -587,7 +813,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tet_generate_meshes_a_body_with_tags_and_appends() {
|
||||
fn tet_generate_meshes_a_body_with_tags_and_replaces_its_mesh() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::open_design;
|
||||
@@ -612,11 +838,13 @@ 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);
|
||||
// A second run appends after the existing labels.
|
||||
assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}");
|
||||
// A second run replaces the body's mesh (#267).
|
||||
let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap();
|
||||
assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1);
|
||||
assert_eq!(s.model.elements().len(), 2 * elements);
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
assert_eq!(again["replaced"][0]["elements"].as_u64().unwrap() as usize, elements, "{again}");
|
||||
assert_eq!(s.model.elements().len(), elements);
|
||||
// The same mesh again, under the same labels: nothing changed, so no undo step.
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
let journal = s.journal.len();
|
||||
let model = s.model.clone();
|
||||
@@ -626,16 +854,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("Needs geometry"));
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
|
||||
let bodies = r["bodies"].as_array().unwrap();
|
||||
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!(matches!(s.execute("mesh.quality", &json!({})), Err(crate::EngineError::Unavailable(_))), "no elements to measure");
|
||||
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
|
||||
}
|
||||
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() {
|
||||
@@ -656,6 +928,53 @@ mod tests {
|
||||
assert!(r["elements"].as_u64().unwrap() > 0, "{r}");
|
||||
}
|
||||
|
||||
/// The quality stage: a graded, improved tet4 mesh with its report, and tet10 with mid-edge
|
||||
/// nodes on the bore; the raw tetrahedralization on request.
|
||||
#[test]
|
||||
fn tet_generate_quality_report_and_quadratic_elements() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::open_design;
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
|
||||
let plate = Plate::default();
|
||||
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["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}");
|
||||
assert!(q["mesh"]["max_dihedral"].as_f64().unwrap() < 165.0, "{r}");
|
||||
assert!(q["cdt"]["slivers"].as_u64().unwrap() > 0, "{r}");
|
||||
// The mesh fills what its boundary encloses, which is the plate within the chordal error.
|
||||
let (volume, boundary) = (r["volume"].as_f64().unwrap(), r["boundary_volume"].as_f64().unwrap());
|
||||
assert!((volume - boundary).abs() <= 1e-9 * boundary, "{r}");
|
||||
let exact = plate.width * plate.depth * plate.thickness - std::f64::consts::PI * 0.25 * plate.hole_diameter.powi(2) * plate.thickness;
|
||||
// The bore's chordal error at 0.2 mm is at most 2/3 · 0.2 · its area (≈ 42 mm³).
|
||||
assert!((volume - exact).abs() < 42.0, "{volume} vs {exact}");
|
||||
|
||||
// Quadratic: mid-edge nodes on the bore's cylinder.
|
||||
let mut s = Session { design: Some(design), ..Session::default() };
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6, "order": "quadratic"})).unwrap();
|
||||
assert_eq!(r["kind"], json!("tet10"), "{r}");
|
||||
assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Tet10));
|
||||
let report = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(report["valid"].as_u64().unwrap(), r["elements"].as_u64().unwrap(), "{report}");
|
||||
// Nodes on the bore (within the tessellation's reach of its axis) lie on its radius.
|
||||
let (cx, cy) = (plate.depth / 2.0, plate.depth / 2.0);
|
||||
let radius = plate.hole_diameter / 2.0;
|
||||
let mut on_bore = 0;
|
||||
for n in s.model.nodes().values() {
|
||||
let d = ((n.xyz[0] - cx).powi(2) + (n.xyz[1] - cy).powi(2)).sqrt();
|
||||
if (d - radius).abs() < 0.05 {
|
||||
on_bore += 1;
|
||||
assert!((d - radius).abs() < 1e-9, "a bore node at {:?} is {d} from the axis", n.xyz);
|
||||
}
|
||||
}
|
||||
assert!(on_bore > 20, "{on_bore}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tet_generate_on_an_imported_surface_and_an_open_one() {
|
||||
use std::sync::Arc;
|
||||
@@ -665,18 +984,24 @@ mod tests {
|
||||
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet =
|
||||
SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let mut s = Session::default();
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
}
|
||||
let r = s.execute("mesh.tet.generate", &json!({"surface": 0})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap();
|
||||
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4)));
|
||||
let element = s.model.element(1).unwrap();
|
||||
assert_eq!(element.nodes.len(), 4);
|
||||
// Refined to a size, the flat-faced tetrahedron keeps its volume exactly.
|
||||
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "size": 0.3})).unwrap();
|
||||
assert!(r["elements"].as_u64().unwrap() > 1, "{r}");
|
||||
assert!((r["volume"].as_f64().unwrap() - 1.0 / 6.0).abs() < 1e-12, "{r}");
|
||||
let total = s.model.elements().len();
|
||||
assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume");
|
||||
assert_eq!(s.model.elements().len(), 1);
|
||||
assert_eq!(s.model.elements().len(), total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
//! `mesh.mirror`: the mesh of some bodies reflected across a plane, joined to them on it.
|
||||
//!
|
||||
//! The geometry tags of the image name what it is the image of: the body of the open design that
|
||||
//! occupies the reflected place (the other half of a part cut on its symmetry plane, found by its
|
||||
//! volume and centroid, or named in `as`), and on it the face each reflected boundary face lies
|
||||
//! on (found by position). Without such a body the image is `<body>/mirror` with the source's
|
||||
//! face names.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_geometry::Shape;
|
||||
use fieldcraft_mesh::mirror::{mirror, reflected_edges, reflected_faces};
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const MIRROR: CommandSpec = CommandSpec {
|
||||
id: "mesh.mirror",
|
||||
label: "Mirror Mesh",
|
||||
params: r#"{"plane": "Sym" (a datum plane) or {"point": [x, y, z], "normal": [nx, ny, nz]}, "bodies": ["Part/a"] (default: every body the mesh is tagged with), "as": {"Part/a": "Part/b"} (optional: the body each image is; default the design's body at the reflected place, else "<body>/mirror")} — replaces each image body's mesh (as mesh.tet.generate) with the reflection of the bodies' elements (tet4, tet10, wedge6, hex8, hex20) across the plane: each copy right-handed (its nodes in reflected order), the copies of the nodes on the plane merged with them so the mesh is conforming there, geometry tags carried to the image (its body, and on it the faces the reflected faces lie on, so sets by face name select both halves). Reports the elements and nodes added, the nodes shared on the plane and each image's body"#,
|
||||
form: Some(super::forms::MIRROR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: mirror_mesh,
|
||||
};
|
||||
|
||||
/// Relative difference of volume and centroid (to the body's size) under which a body is the
|
||||
/// image of another.
|
||||
const SAME_PLACE: f64 = 1e-4;
|
||||
|
||||
/// Distance (relative to the mesh's bounding-box diagonal) within which nodes are on the plane.
|
||||
const ON_PLANE: f64 = 1e-7;
|
||||
|
||||
fn not_a_plane() -> EngineError {
|
||||
EngineError::invalid("plane", r#"must be a datum plane's name or {"point": [x, y, z], "normal": [x, y, z]}"#)
|
||||
}
|
||||
|
||||
/// The plane: a datum plane by name, or a point and a normal.
|
||||
fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> {
|
||||
match p.get("plane") {
|
||||
Some(Value::String(name)) => s
|
||||
.model
|
||||
.datum(name)
|
||||
.and_then(|d| d.plane())
|
||||
.ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", name.chars().take(64).collect::<String>()))),
|
||||
Some(v @ Value::Object(_)) => match (super::xyz(v, "point"), super::xyz(v, "normal")) {
|
||||
(Ok(point), Ok(normal)) => Ok((point, normal)),
|
||||
_ => Err(not_a_plane()),
|
||||
},
|
||||
None | Some(Value::Null) => Err(EngineError::param("plane", ProblemKind::Required)),
|
||||
_ => Err(not_a_plane()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reflect(x: [f64; 3], point: [f64; 3], n: [f64; 3]) -> [f64; 3] {
|
||||
let d: f64 = (0..3).map(|i| (x[i] - point[i]) * n[i]).sum();
|
||||
[x[0] - 2.0 * d * n[0], x[1] - 2.0 * d * n[1], x[2] - 2.0 * d * n[2]]
|
||||
}
|
||||
|
||||
/// The design body at the reflected place of `source`: the same volume and the reflected
|
||||
/// centroid, within [`SAME_PLACE`] (the kernel measures curved bodies on a tessellation, so the
|
||||
/// two halves of a symmetric part measure alike only to its accuracy).
|
||||
fn image_body<'a>(bodies: &'a [Shape], source: &Shape, point: [f64; 3], n: [f64; 3]) -> Result<Option<&'a Shape>> {
|
||||
let m = source.measure()?;
|
||||
let size = source.size()?.max(1e-9);
|
||||
let want = reflect(m.centroid, point, n);
|
||||
for b in bodies.iter().filter(|b| b.name != source.name) {
|
||||
let o = b.measure()?;
|
||||
let d = (0..3).map(|k| (o.centroid[k] - want[k]).powi(2)).sum::<f64>().sqrt();
|
||||
if (o.volume - m.volume).abs() <= SAME_PLACE * m.volume.abs().max(1e-12) && d <= SAME_PLACE * size {
|
||||
return Ok(Some(b));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// What `mesh.mirror` reflects, across which plane, into which bodies.
|
||||
struct Plan<'d> {
|
||||
point: [f64; 3],
|
||||
/// The plane's unit normal.
|
||||
n: [f64; 3],
|
||||
/// The elements reflected.
|
||||
region: BTreeSet<u32>,
|
||||
/// Each mirrored body's image: its name and, when the design has it, its shape.
|
||||
image: BTreeMap<String, (String, Option<&'d Shape>)>,
|
||||
}
|
||||
|
||||
fn plan<'d>(s: &Session, design: Option<&'d fieldcraft_geometry::Design>, p: &Value) -> Result<Plan<'d>> {
|
||||
let (point, normal) = plane(s, p)?;
|
||||
let len = normal.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
if !(len > 1e-12 && len.is_finite() && point.iter().all(|x| x.is_finite())) {
|
||||
return Err(EngineError::Params("the plane needs a finite point and a non-zero normal".into()));
|
||||
}
|
||||
let n = normal.map(|x| x / len);
|
||||
let c = s.model.classification();
|
||||
// The bodies the mesh is tagged with, by tag.
|
||||
let tagged: BTreeMap<u32, String> = c
|
||||
.entities()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.entity == Entity::Body)
|
||||
.filter_map(|(t, r)| Some((u32::try_from(t).ok()?, r.body.clone())))
|
||||
.collect();
|
||||
let bodies: Vec<String> = match p.get("bodies") {
|
||||
None => {
|
||||
let mut v: Vec<String> = tagged.values().cloned().collect();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
Some(_) => super::req(p, "bodies")?,
|
||||
};
|
||||
if bodies.is_empty() {
|
||||
return Err(EngineError::Params("no body to mirror: the mesh is not tagged with a body".into()));
|
||||
}
|
||||
for b in &bodies {
|
||||
if !tagged.values().any(|t| t == b) {
|
||||
return Err(EngineError::Params(format!("the mesh has no elements of body `{}`", b.chars().take(64).collect::<String>())));
|
||||
}
|
||||
}
|
||||
let named: BTreeMap<String, String> = super::opt(p, "as")?.unwrap_or_default();
|
||||
let region: BTreeSet<u32> =
|
||||
c.elements().iter().filter(|(_, t)| t.region.and_then(|r| tagged.get(&r)).is_some_and(|b| bodies.contains(b))).map(|(e, _)| *e).collect();
|
||||
|
||||
// The image of each body: named, found in the design, or its own `/mirror`.
|
||||
let mut image: BTreeMap<String, (String, Option<&Shape>)> = BTreeMap::new();
|
||||
for b in &bodies {
|
||||
let source = design.and_then(|d| d.body(b));
|
||||
let target = match named.get(b) {
|
||||
Some(t) => (t.clone(), design.and_then(|d| d.body(t))),
|
||||
None => match (design, source) {
|
||||
(Some(d), Some(src)) => match image_body(&d.bodies, src, point, n)? {
|
||||
Some(t) => (t.name.clone(), Some(t)),
|
||||
None => (format!("{b}/mirror"), None),
|
||||
},
|
||||
_ => (format!("{b}/mirror"), None),
|
||||
},
|
||||
};
|
||||
if bodies.contains(&target.0) {
|
||||
return Err(EngineError::Params(format!("the image of `{b}` would be `{}`, which is mirrored too", target.0)));
|
||||
}
|
||||
image.insert(b.clone(), target);
|
||||
}
|
||||
Ok(Plan { point, n, region, image })
|
||||
}
|
||||
|
||||
/// The bodies `mesh.mirror` fills: the images.
|
||||
pub(super) fn image_names(s: &Session, p: &Value) -> Result<Vec<String>> {
|
||||
Ok(plan(s, s.design.as_deref(), p)?.image.into_values().map(|(name, _)| name).collect())
|
||||
}
|
||||
|
||||
fn mirror_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let design = s.design.clone();
|
||||
let Plan { point, n, region, image } = plan(s, design.as_deref(), p)?;
|
||||
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for node in s.model.nodes().values() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(node.xyz[k]);
|
||||
hi[k] = hi[k].max(node.xyz[k]);
|
||||
}
|
||||
}
|
||||
let diagonal = (0..3).map(|k| (hi[k] - lo[k]).powi(2)).sum::<f64>().sqrt().max(1e-9);
|
||||
// An image body's earlier mesh is replaced (replace.rs).
|
||||
let model = &mut s.model;
|
||||
let cleared = super::replace::clear(model, image.values().map(|(name, _)| name.as_str()));
|
||||
let before = model.clone();
|
||||
let r = mirror(model, ®ion, point, n, ON_PLANE * diagonal).map_err(|e| EngineError::Params(e.to_string()))?;
|
||||
tag_image(model, &before, &r, &image, point, n, diagonal)?;
|
||||
let added_nodes = model.nodes().len() - before.nodes().len();
|
||||
let images: Vec<Value> = image.iter().map(|(b, (t, shape))| json!({"body": b, "image": t, "in_design": shape.is_some()})).collect();
|
||||
let reply = json!({
|
||||
"elements": r.elements.len(),
|
||||
"nodes": added_nodes,
|
||||
"on_plane": r.on_plane,
|
||||
"images": images,
|
||||
"total": {"nodes": s.model.nodes().len(), "elements": s.model.elements().len()},
|
||||
});
|
||||
Ok(cleared.report(s, reply))
|
||||
}
|
||||
|
||||
/// Tag the image: each copy with its image body, its faces with the faces of that body they lie
|
||||
/// on (or the source's names), its edges and the images of tagged nodes with the source's names.
|
||||
fn tag_image(
|
||||
model: &mut Model,
|
||||
before: &Model,
|
||||
r: &fieldcraft_mesh::mirror::Mirrored,
|
||||
image: &BTreeMap<String, (String, Option<&Shape>)>,
|
||||
point: [f64; 3],
|
||||
n: [f64; 3],
|
||||
diagonal: f64,
|
||||
) -> Result<()> {
|
||||
let c = before.classification();
|
||||
let entity = |t: u32| c.entity(t).cloned();
|
||||
// A source face's image: by the image body's face under its reflected centroid, one lookup per
|
||||
// face name (the first element face of it stands for all).
|
||||
let mut face_points: BTreeMap<(String, String), [f64; 3]> = BTreeMap::new();
|
||||
for (&e, tags) in c.elements() {
|
||||
if !r.elements.contains_key(&e) {
|
||||
continue;
|
||||
}
|
||||
for (&f, &t) in &tags.faces {
|
||||
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(t) else { continue };
|
||||
if face_points.contains_key(&(body.clone(), name.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Some(nodes) = before.face_nodes(ElementFace { element: e, face: f }) else { continue };
|
||||
let pts: Vec<[f64; 3]> = nodes.iter().filter_map(|n| before.node(*n).map(|x| x.xyz)).collect();
|
||||
let k = pts.len().max(1) as f64;
|
||||
let centre = [0, 1, 2].map(|i| pts.iter().map(|p| p[i]).sum::<f64>() / k);
|
||||
face_points.insert((body, name), reflect(centre, point, n));
|
||||
}
|
||||
}
|
||||
let mut face_names: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
|
||||
let mut by_body: BTreeMap<String, Vec<(String, [f64; 3])>> = BTreeMap::new();
|
||||
for ((body, name), p) in &face_points {
|
||||
by_body.entry(body.clone()).or_default().push((name.clone(), *p));
|
||||
}
|
||||
for (body, faces) in by_body {
|
||||
let Some((target, shape)) = image.get(&body) else { continue };
|
||||
let found = match shape {
|
||||
Some(shape) => {
|
||||
let pts: Vec<[f64; 3]> = faces.iter().map(|(_, p)| *p).collect();
|
||||
shape.nearest_faces(&pts, (1e-3 * diagonal).max(1e-4))?
|
||||
}
|
||||
None => vec![None; faces.len()],
|
||||
};
|
||||
for ((name, _), f) in faces.iter().zip(found) {
|
||||
let mapped = f.and_then(|f| shape.and_then(|s| s.faces().get(f as usize))).map_or_else(|| name.clone(), |x| x.name.clone());
|
||||
face_names.insert((body.clone(), name.clone()), (target.clone(), mapped));
|
||||
}
|
||||
}
|
||||
let mut tags: BTreeMap<GeometryRef, u32> = BTreeMap::new();
|
||||
let mut tag = |model: &mut Model, r: GeometryRef| -> Result<u32> {
|
||||
if let Some(&t) = tags.get(&r) {
|
||||
return Ok(t);
|
||||
}
|
||||
let t = model.geometry_tag(&r)?;
|
||||
tags.insert(r, t);
|
||||
Ok(t)
|
||||
};
|
||||
for (&e, ©) in &r.elements {
|
||||
let Some(el) = before.element(e) else { continue };
|
||||
let Some(t) = c.elements().get(&e) else { continue };
|
||||
let faces = reflected_faces(el.kind).unwrap_or_default();
|
||||
let edges = reflected_edges(el.kind).unwrap_or_default();
|
||||
if let Some(GeometryRef { body, .. }) = t.region.and_then(entity)
|
||||
&& let Some((target, _)) = image.get(&body)
|
||||
{
|
||||
let tg = tag(model, GeometryRef::body(target.as_str())?)?;
|
||||
model.tag_element(copy, tg)?;
|
||||
}
|
||||
for (&f, &ft) in &t.faces {
|
||||
let Some(GeometryRef { body, entity: Entity::Face(name) }) = entity(ft) else { continue };
|
||||
let Some((tb, tn)) = face_names.get(&(body, name)) else { continue };
|
||||
let g = faces.get(usize::from(f).saturating_sub(1)).copied().unwrap_or(f);
|
||||
let tg = tag(model, GeometryRef::face(tb.as_str(), tn.as_str())?)?;
|
||||
model.tag_element_face(copy, g, tg)?;
|
||||
}
|
||||
for (&k, &et) in &t.edges {
|
||||
let Some(GeometryRef { body, entity: ent }) = entity(et) else { continue };
|
||||
let Some((target, _)) = image.get(&body) else { continue };
|
||||
let g = edges.get(usize::from(k).saturating_sub(1)).copied().unwrap_or(k);
|
||||
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
|
||||
model.tag_element_edge(copy, g, tg)?;
|
||||
}
|
||||
}
|
||||
for (&node, ©) in &r.nodes {
|
||||
if copy == node {
|
||||
continue;
|
||||
}
|
||||
let Some(GeometryRef { body, entity: ent }) = c.nodes().get(&node).and_then(|&t| entity(t)) else { continue };
|
||||
let Some((target, _)) = image.get(&body) else { continue };
|
||||
let tg = tag(model, GeometryRef::new(target.as_str(), ent)?)?;
|
||||
model.tag_node(copy, tg)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A session with a 40 × 10 × 10 bar (along x from the origin) open, partitioned at
|
||||
/// x = 20 when `half`.
|
||||
fn bar(half: bool) -> (crate::tests::TempDir, Session) {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 10, "height": 10, "body_name": "Bar"}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let b = s.execute("geometry.bodies", &json!({})).unwrap()[0]["bbox"].clone();
|
||||
let lo: Vec<f64> = b[0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let hi: Vec<f64> = b[1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!(lo, [0.0; 3]);
|
||||
assert_eq!(hi, [40.0, 10.0, 10.0]);
|
||||
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 20}})).unwrap();
|
||||
if half {
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
}
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// The face of `body` nearest to `point`.
|
||||
fn face(s: &Session, body: &str, point: [f64; 3]) -> String {
|
||||
let shape = s.design.as_ref().unwrap().body(body).unwrap();
|
||||
let f = shape.nearest_faces(&[point], 0.01).unwrap()[0].unwrap();
|
||||
shape.faces()[f as usize].name.clone()
|
||||
}
|
||||
|
||||
/// Clamped at x = 0, 1 kN down on the end at x = 40 (sets by face name on the bodies given),
|
||||
/// solved; the largest downward deflection and the mesh's volume and counts.
|
||||
fn bend(s: &mut Session, root: (&str, String), tip: (&str, String)) -> (f64, f64, usize, usize) {
|
||||
let all: Vec<Value> = s.design.as_ref().unwrap().bodies.iter().map(|b| json!({"body": b.name})).collect();
|
||||
let script = json!([
|
||||
{"command": "model.set.create", "params": {"name": "all", "geometry": all}},
|
||||
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": root.0, "face": root.1}]}},
|
||||
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": tip.0, "face": tip.1}]}},
|
||||
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}},
|
||||
{"command": "model.section.create", "params": {"name": "Bar", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
|
||||
{"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}},
|
||||
{"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}},
|
||||
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.static", &json!({})).unwrap();
|
||||
let tip_nodes = s.model.resolve_set("tip").unwrap().nodes;
|
||||
assert!(!tip_nodes.is_empty());
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": tip_nodes})).unwrap();
|
||||
let w = probe["nodes"].as_array().unwrap().iter().map(|n| -n["displacement"][2].as_f64().unwrap()).fold(f64::MIN, f64::max);
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
|
||||
(w, q["volume"].as_f64().unwrap(), s.model.nodes().len(), s.model.elements().len())
|
||||
}
|
||||
|
||||
/// Half the bar meshed and mirrored is the whole bar meshed: the same counts and volume, and
|
||||
/// the same bending deflection to round-off, hex8 and hex20; the image's faces carry the
|
||||
/// whole bar's face names, so the load on the far end (a face of the image only) is found.
|
||||
#[test]
|
||||
fn a_mirrored_half_is_the_whole_mesh() {
|
||||
for order in [1, 2] {
|
||||
let (_d1, mut whole) = bar(false);
|
||||
whole.execute("mesh.hex.mapped", &json!({"body": "Bar", "size": 2.5, "order": order})).unwrap();
|
||||
let (root, tip) = (face(&whole, "Bar", [0.0, 5.0, 5.0]), face(&whole, "Bar", [40.0, 5.0, 5.0]));
|
||||
let full = bend(&mut whole, ("Bar", root), ("Bar", tip));
|
||||
|
||||
let (_d2, mut half) = bar(true);
|
||||
half.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 2.5, "order": order})).unwrap();
|
||||
let r = half.execute("mesh.mirror", &json!({"plane": "Mid"})).unwrap();
|
||||
assert_eq!(r["images"][0], json!({"body": "Bar/a", "image": "Bar/b", "in_design": true}), "{r}");
|
||||
// 5 × 5 corner nodes on the plane (and 40 mid-edge nodes on it for hex20).
|
||||
assert_eq!(r["on_plane"], json!(if order == 1 { 25 } else { 65 }), "{r}");
|
||||
let (root, tip) = (face(&half, "Bar/a", [0.0, 5.0, 5.0]), face(&half, "Bar/b", [40.0, 5.0, 5.0]));
|
||||
let mirrored = bend(&mut half, ("Bar/a", root), ("Bar/b", tip));
|
||||
assert_eq!((mirrored.2, mirrored.3), (full.2, full.3), "order {order}: node and element counts");
|
||||
assert!((mirrored.1 - full.1).abs() < 1e-9 * full.1, "order {order}: volume {} vs {}", mirrored.1, full.1);
|
||||
assert!((mirrored.0 - full.0).abs() < 1e-9 * full.0, "order {order}: deflection {} vs {}", mirrored.0, full.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tets of either order mirror into valid elements sharing the plane's nodes; the volume
|
||||
/// doubles.
|
||||
#[test]
|
||||
fn mirrored_tets_are_valid_and_conforming() {
|
||||
for order in ["linear", "quadratic"] {
|
||||
let (_d, mut s) = bar(true);
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Bar/a", "size": 4, "order": order})).unwrap();
|
||||
let v = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
|
||||
let r = s.execute("mesh.mirror", &json!({"plane": {"point": [20, 0, 0], "normal": [1, 0, 0]}, "bodies": ["Bar/a"]})).unwrap();
|
||||
assert!(r["on_plane"].as_u64().unwrap() > 0, "{r}");
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, s.model.elements().len(), "{q}");
|
||||
assert!((q["volume"].as_f64().unwrap() - 2.0 * v).abs() < 1e-9 * v, "{q}");
|
||||
// The plane's faces are interior now: the skin of both bodies is the bar's.
|
||||
s.execute("model.set.create", &json!({"name": "a", "geometry": [{"body": "Bar/a"}]})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "b", "geometry": [{"body": "Bar/b"}]})).unwrap();
|
||||
let (a, b) = (s.model.resolve_set("a").unwrap(), s.model.resolve_set("b").unwrap());
|
||||
assert_eq!(a.elements.len(), b.elements.len());
|
||||
assert_eq!(a.faces.len(), b.faces.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_change_nothing() {
|
||||
let (_d, mut s) = bar(true);
|
||||
s.execute("mesh.hex.mapped", &json!({"body": "Bar/a", "size": 5})).unwrap();
|
||||
let model = s.model.clone();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"plane": "Nope"}),
|
||||
json!({"plane": {"point": [0, 0, 0], "normal": [0, 0, 0]}}),
|
||||
json!({"plane": {"point": [f64::MAX, 0, 0], "normal": [1, 0, 0]}}),
|
||||
json!({"plane": {"point": [0, 0], "normal": [1, 0, 0]}}),
|
||||
json!({"plane": "Mid", "bodies": ["Nope"]}),
|
||||
json!({"plane": "Mid", "bodies": []}),
|
||||
json!({"plane": "Mid", "as": {"Bar/a": "Bar/a"}}),
|
||||
json!({"plane": "Mid", "as": "x"}),
|
||||
] {
|
||||
assert!(s.execute("mesh.mirror", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
mod block;
|
||||
mod cells;
|
||||
mod contact;
|
||||
pub(crate) mod datum;
|
||||
pub(crate) mod deck;
|
||||
pub(crate) mod define;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod forms;
|
||||
mod geometry;
|
||||
mod hex;
|
||||
pub(crate) mod mesh;
|
||||
|
||||
pub use mesh::analytic_surfaces;
|
||||
mod mirror;
|
||||
mod model;
|
||||
pub(crate) mod octree;
|
||||
pub mod replace;
|
||||
mod results;
|
||||
pub(crate) mod seeds;
|
||||
pub(crate) mod solve;
|
||||
mod symmetry;
|
||||
mod tags;
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use fieldcraft_model::MAX_LABEL;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -27,6 +39,11 @@ pub static ALL: &[CommandSpec] = &[
|
||||
geometry::BODIES,
|
||||
geometry::TESSELLATE,
|
||||
geometry::IMPORT_MESH,
|
||||
datum::DATUM_PLANE,
|
||||
datum::DATUM_AXIS,
|
||||
datum::DATUM_DELETE,
|
||||
datum::PARTITION,
|
||||
datum::PARTITION_DELETE,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
@@ -36,11 +53,17 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
mesh::TET_GENERATE,
|
||||
octree::HEX_OCTREE,
|
||||
block::BLOCK,
|
||||
hex::HEX_GENERATE,
|
||||
hex::HEX_SWEEP,
|
||||
hex::HEX_MAPPED,
|
||||
hex::HEX_BLOCKS,
|
||||
cells::CELLS,
|
||||
seeds::SEED_EDGE,
|
||||
seeds::SEED_CLEAR,
|
||||
seeds::SEED_LIST,
|
||||
mirror::MIRROR,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
model::UNITS,
|
||||
@@ -63,6 +86,14 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::COUPLING_CREATE,
|
||||
define::COUPLING_EDIT,
|
||||
define::COUPLING_DELETE,
|
||||
contact::PAIR,
|
||||
contact::GENERAL,
|
||||
contact::EDIT,
|
||||
contact::DELETE,
|
||||
contact::FIND,
|
||||
contact::TIE,
|
||||
contact::TIE_EDIT,
|
||||
contact::TIE_DELETE,
|
||||
define::MASS_CREATE,
|
||||
define::MASS_EDIT,
|
||||
define::MASS_DELETE,
|
||||
@@ -72,6 +103,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::BOUNDARY_CONDITION_CREATE,
|
||||
define::BOUNDARY_CONDITION_EDIT,
|
||||
define::BOUNDARY_CONDITION_DELETE,
|
||||
symmetry::SYMMETRY,
|
||||
define::LOAD_CREATE,
|
||||
define::LOAD_EDIT,
|
||||
define::LOAD_DELETE,
|
||||
@@ -98,6 +130,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
solve::STATIC,
|
||||
solve::NONLINEAR,
|
||||
solve::FREQUENCY,
|
||||
solve::BUCKLE,
|
||||
solve::HEAT,
|
||||
solve::EXPLICIT,
|
||||
solve::HISTORY,
|
||||
@@ -112,12 +145,18 @@ pub static ALL: &[CommandSpec] = &[
|
||||
results::CLEAR,
|
||||
];
|
||||
|
||||
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
|
||||
/// else `kind`.
|
||||
pub(crate) fn wrong(p: &Value, key: &str, kind: ProblemKind) -> EngineError {
|
||||
EngineError::param(key, if p.get(key).is_none_or(Value::is_null) { ProblemKind::Required } else { kind })
|
||||
}
|
||||
|
||||
/// A required label parameter (1..=u32::MAX; the model checks its own range).
|
||||
pub(crate) fn label(p: &Value, key: &str) -> Result<u32> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive whole number")))
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAWholeNumber { min: 1, max: i64::from(u32::MAX) }))
|
||||
}
|
||||
|
||||
/// The first node and element labels after the model's largest, for `nodes` new nodes and
|
||||
@@ -135,7 +174,7 @@ pub(crate) fn fresh_labels(model: &fieldcraft_model::Model, nodes: usize, elemen
|
||||
|
||||
/// A required `[x, y, z]` parameter.
|
||||
pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
|
||||
p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::Params(format!("`{key}` must be [x, y, z]")))
|
||||
p.get(key).and_then(xyz_value).ok_or_else(|| wrong(p, key, ProblemKind::NotAVector { len: 3 }))
|
||||
}
|
||||
|
||||
/// A JSON `[x, y, z]`.
|
||||
@@ -148,9 +187,12 @@ fn xyz_value(v: &Value) -> Option<[f64; 3]> {
|
||||
|
||||
/// A required list of `[x, y, z]` points, at most `max_len` long.
|
||||
pub(crate) fn points(p: &Value, key: &str, max_len: usize) -> Result<Vec<[f64; 3]>> {
|
||||
let bad = |what: String| EngineError::Params(format!("`{key}` must be a list of at most {max_len} [x, y, z]{what}"));
|
||||
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len).ok_or_else(|| bad(String::new()))?;
|
||||
raw.iter().enumerate().map(|(i, v)| xyz_value(v).ok_or_else(|| bad(format!(" (entry {i} is not)")))).collect()
|
||||
let raw = p.get(key).and_then(Value::as_array).filter(|a| a.len() <= max_len);
|
||||
let raw = raw.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "[x, y, z]" }))?;
|
||||
raw.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| xyz_value(v).ok_or_else(|| EngineError::invalid(key, format!("must be a list of [x, y, z] (entry {i} is not)"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A required list of labels (each in 1..=MAX_LABEL), at most `max_len` long.
|
||||
@@ -159,11 +201,11 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| a.len() <= max_len)
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a list of at most {max_len} labels")))?;
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAList { max: max_len, what: "labels" }))?;
|
||||
raw.iter()
|
||||
.map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n)))
|
||||
.collect::<Option<Vec<u32>>>()
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
|
||||
.ok_or_else(|| EngineError::invalid(key, format!("must be labels in 1..={MAX_LABEL}")))
|
||||
}
|
||||
|
||||
/// A required path parameter, as given.
|
||||
@@ -171,7 +213,7 @@ fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
|
||||
p.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= fieldcraft_io::MAX_PATH_LEN && !s.contains('\0'))
|
||||
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
|
||||
.ok_or_else(|| wrong(p, key, ProblemKind::NotAPath))
|
||||
}
|
||||
|
||||
/// A required path parameter naming a file (or folder) the command reads or writes: the path
|
||||
@@ -186,23 +228,23 @@ pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> R
|
||||
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v.as_f64().ok_or_else(|| EngineError::Params(format!("`{key}` must be a number"))),
|
||||
Some(v) => v.as_f64().ok_or_else(|| EngineError::param(key, ProblemKind::NotANumber)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `name` parameter.
|
||||
pub(crate) fn name(p: &Value) -> Result<&str> {
|
||||
p.get("name").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`name` must be a name".into()))
|
||||
p.get("name").and_then(Value::as_str).ok_or_else(|| wrong(p, "name", ProblemKind::NotAName))
|
||||
}
|
||||
|
||||
/// An optional parameter read as `T`'s JSON form; `None` when it is absent.
|
||||
pub(crate) fn opt<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::Params(format!("`{key}`: {e}")))).transpose()
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|e| EngineError::invalid(key, format!("is not valid: {e}")))).transpose()
|
||||
}
|
||||
|
||||
/// A required parameter read as `T`'s JSON form.
|
||||
pub(crate) fn req<'a, T: Deserialize<'a>>(p: &'a Value, key: &str) -> Result<T> {
|
||||
opt(p, key)?.ok_or_else(|| EngineError::Params(format!("`{key}` is required")))
|
||||
opt(p, key)?.ok_or_else(|| EngineError::param(key, ProblemKind::Required))
|
||||
}
|
||||
|
||||
/// Entries listed in a report: the first ones, and how many there are.
|
||||
|
||||
@@ -2,25 +2,32 @@ use fieldcraft_model::{Severity, UnitSystem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{listed, name};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const INFO: CommandSpec = CommandSpec { id: "model.info", label: "Model Info", params: "{}", kind: CommandKind::Model, run: info };
|
||||
pub const INFO: CommandSpec =
|
||||
CommandSpec { id: "model.info", label: "Model Info", params: "{}", form: Some(&[]), kind: CommandKind::Model, requires: &[], 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,
|
||||
requires: &[],
|
||||
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, requires: &[], 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,
|
||||
requires: &[Requirement::Set],
|
||||
run: set_resolve,
|
||||
};
|
||||
|
||||
@@ -50,11 +57,14 @@ fn units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn check(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let diagnostics = s.model.check();
|
||||
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
|
||||
let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string);
|
||||
let progress = crate::progress::to_json(&s.progress_given(first_error));
|
||||
Ok(json!({
|
||||
"ready": errors == 0,
|
||||
"errors": errors,
|
||||
"warnings": diagnostics.len() - errors,
|
||||
"diagnostics": diagnostics,
|
||||
"progress": progress,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Unstructured all-hexahedral meshing (`mesh.hex.octree`) of the open design's bodies or of
|
||||
//! imported surfaces.
|
||||
|
||||
use fieldcraft_mesh::octree::{OctreeHexMesh, OctreeOptions, octree_hex_mesh};
|
||||
use fieldcraft_mesh::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::hex::{Input, inputs, number, tag_faces};
|
||||
use super::mesh::optional_count;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.octree",
|
||||
label: "Octree Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
form: Some(super::forms::HEX_OCTREE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: hex_octree,
|
||||
};
|
||||
|
||||
/// Smallest element size, relative to the bounding-box diagonal (the grid's cell count grows
|
||||
/// with the cube of its inverse).
|
||||
const MIN_RELATIVE_SIZE: f64 = 1e-3;
|
||||
/// Default element size, relative to the bounding-box diagonal.
|
||||
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
|
||||
/// Caps on the options.
|
||||
const MAX_DEPTH: u64 = 5;
|
||||
const MAX_ATTEMPTS: u64 = 8;
|
||||
const MAX_SWEEPS: u64 = 10_000;
|
||||
const MAX_HEX_ELEMENTS: u64 = 10_000_000;
|
||||
/// The session default of `max_elements` ([`crate::Limits::max_hex_elements`]).
|
||||
pub(crate) const DEFAULT_MAX_ELEMENTS: usize = 2_000_000;
|
||||
|
||||
fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let size = number(p, "size")?;
|
||||
let defaults = OctreeOptions::default();
|
||||
let options = OctreeOptions {
|
||||
max_depth: u8::try_from(optional_count(p, "max_depth", u64::from(defaults.max_depth), 0..=MAX_DEPTH)?).unwrap_or(defaults.max_depth),
|
||||
max_elements: optional_count(p, "max_elements", s.limits.max_hex_elements as u64, 1..=MAX_HEX_ELEMENTS)?.min(MAX_ELEMENTS),
|
||||
attempts: optional_count(p, "attempts", defaults.attempts as u64, 1..=MAX_ATTEMPTS)?,
|
||||
max_sweeps: optional_count(p, "max_sweeps", defaults.max_sweeps as u64, 0..=MAX_SWEEPS)?,
|
||||
..defaults
|
||||
};
|
||||
let bodies = inputs(s, p)?;
|
||||
let mut meshes: Vec<(Input, OctreeHexMesh)> = Vec::with_capacity(bodies.len());
|
||||
let mut total = 0usize;
|
||||
for b in bodies {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
|
||||
if h < MIN_RELATIVE_SIZE * b.diagonal {
|
||||
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
|
||||
}
|
||||
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
total += mesh.hexes.len();
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
let mut model = s.model.clone();
|
||||
let cleared = super::replace::clear(&mut model, meshes.iter().map(|(b, _)| b.name.as_str()));
|
||||
let mut report = Vec::with_capacity(meshes.len());
|
||||
for (b, m) in &meshes {
|
||||
let (first_node, first_element) = super::fresh_labels(&model, m.nodes.len(), m.hexes.len())?;
|
||||
for (i, xyz) in m.nodes.iter().enumerate() {
|
||||
model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
|
||||
for (i, hx) in m.hexes.iter().enumerate() {
|
||||
let l = first_element + i as u32;
|
||||
model.set_element(l, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
|
||||
model.tag_element(l, body_tag)?;
|
||||
}
|
||||
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
let r = &m.report;
|
||||
report.push(json!({
|
||||
"body": b.name,
|
||||
"kind": "hex8",
|
||||
"elements": m.hexes.len(),
|
||||
"nodes": m.nodes.len(),
|
||||
"boundary_faces": m.boundary.len(),
|
||||
"quality": {"min_scaled_jacobian": r.min_scaled_jacobian, "mean_scaled_jacobian": r.mean_scaled_jacobian},
|
||||
"volume": r.volume,
|
||||
"surface_volume": r.surface_volume,
|
||||
"grid": {"cells": r.grid_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
|
||||
"features": {"corners": r.corners, "curves": r.curves, "patches": r.patches},
|
||||
"attempts": r.attempts,
|
||||
"retries": r.retries,
|
||||
"sweeps": r.sweeps,
|
||||
}));
|
||||
}
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
s.model = model;
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::open_design;
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn opened() -> Session {
|
||||
let plate = Plate::default();
|
||||
Session { design: Some(Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap())), ..Session::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plate_with_a_hole_meshes_all_hex_and_face_sets_resolve() {
|
||||
let mut s = opened();
|
||||
let r = s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 5.0, "max_depth": 2})).unwrap();
|
||||
let body = &r["bodies"][0];
|
||||
let n = body["elements"].as_u64().unwrap() as usize;
|
||||
assert!(n > 50, "{r}");
|
||||
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.0, "{r}");
|
||||
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, n)]);
|
||||
let (v, sv) = (body["volume"].as_f64().unwrap(), body["surface_volume"].as_f64().unwrap());
|
||||
assert!((v - sv).abs() < 0.02 * sv, "{r}");
|
||||
// The certified quality report agrees.
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, n);
|
||||
// Every boundary face is tagged: the skin of the body set; each named face resolves.
|
||||
s.execute("model.set.create", &json!({"name": "skin", "geometry": [{"body": "Body1"}]})).unwrap();
|
||||
let skin = s.execute("model.set.resolve", &json!({"name": "skin"})).unwrap();
|
||||
assert_eq!(skin["faces"]["count"], body["boundary_faces"], "{skin}");
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
for f in bodies[0]["faces"].as_array().unwrap() {
|
||||
let name = f["name"].as_str().unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "f", "geometry": [{"body": "Body1", "face": name}]})).unwrap();
|
||||
let set = s.execute("model.set.resolve", &json!({"name": "f"})).unwrap();
|
||||
assert!(set["faces"]["count"].as_u64().unwrap() > 0, "face {name}: {set}");
|
||||
s.execute("model.set.delete", &json!({"name": "f"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors_and_change_nothing() {
|
||||
let mut s = opened();
|
||||
let before = s.model.clone();
|
||||
for p in [
|
||||
json!({"body": "Body1", "size": 0}),
|
||||
json!({"body": "Body1", "size": -1}),
|
||||
json!({"body": "Body1", "size": 1e-300}),
|
||||
json!({"body": "Body1", "size": "big"}),
|
||||
json!({"body": "Body1", "tolerance": 1e-300}),
|
||||
json!({"body": "Body1", "tolerance": -1}),
|
||||
json!({"body": "Body1", "max_depth": 9}),
|
||||
json!({"body": "Body1", "max_depth": -1}),
|
||||
json!({"body": "Body1", "max_elements": 0}),
|
||||
json!({"body": "Body1", "max_elements": 1e300}),
|
||||
json!({"body": "Body1", "attempts": 0}),
|
||||
json!({"body": "Body1", "attempts": 100}),
|
||||
json!({"body": "Body1", "max_sweeps": "many"}),
|
||||
json!({"body": "Body1", "size": 5.0, "max_depth": 2, "max_elements": 10}),
|
||||
json!({"body": "Nope"}),
|
||||
json!({"body": 7}),
|
||||
json!({"surface": 0}),
|
||||
json!({"surface": -1}),
|
||||
json!({"body": "Body1", "surface": 0}),
|
||||
] {
|
||||
let r = s.execute("mesh.hex.octree", &p);
|
||||
assert!(r.is_err(), "{p}: {r:?}");
|
||||
assert_eq!(s.model, before, "{p}");
|
||||
}
|
||||
assert!(Session::default().execute("mesh.hex.octree", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||