Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.5 (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,253 @@
|
||||
# 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 and .AppImage 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 }}
|
||||
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
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-x86_64
|
||||
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Build and package (tar.gz, AppImage)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
list="$(tar -tzf dist/release/*.tar.gz)"
|
||||
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
|
||||
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
|
||||
grep -Fx -- "fieldcraft-$VERSION-linux-x86_64/$f" <<<"$list"
|
||||
done
|
||||
ldd target/release/fieldcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
done
|
||||
- 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-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
|
||||
|
||||
@@ -15,6 +15,7 @@ is committed and listed here with its exports.
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| 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/bracket-partition.png, docs/media/bracket-hex.png, docs/media/bracket-stress.png, docs/media/setup-symbols.png | FieldCraft contributors | Original: FieldCraft renders (headless MCP screenshots and a window capture) of corpus parts and the cantilever sample | MIT OR Apache-2.0 |
|
||||
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
|
||||
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
@@ -133,12 +134,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 +153,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
|
||||
@@ -170,6 +171,15 @@ is committed and listed here with its exports.
|
||||
| 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 |
|
||||
| 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]]
|
||||
@@ -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"
|
||||
@@ -1173,10 +1237,11 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
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.5"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1197,7 +1262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1206,7 +1271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1228,7 +1293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1239,7 +1304,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1247,7 +1312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml",
|
||||
@@ -1263,20 +1328,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
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.5"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1286,7 +1351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
@@ -1297,7 +1362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"num-bigint",
|
||||
@@ -1309,7 +1374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1318,14 +1383,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1335,9 +1400,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-parallel",
|
||||
"serde",
|
||||
@@ -1346,7 +1412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
@@ -1357,7 +1423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1374,6 +1440,25 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.5.5"
|
||||
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 +1819,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -3206,6 +3300,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 +3316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3381,7 +3495,7 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-sketch",
|
||||
@@ -3423,7 +3537,7 @@ dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -3658,6 +3772,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 +3817,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"
|
||||
@@ -3981,6 +4116,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"
|
||||
@@ -4750,6 +4905,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.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@@ -4821,7 +4986,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.5.5"
|
||||
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"
|
||||
@@ -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"
|
||||
|
||||
@@ -6,8 +6,13 @@ 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).
|
||||
|
||||

|
||||
|
||||
@@ -17,34 +22,70 @@ 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)
|
||||
|
||||
- **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`.
|
||||
- **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)).
|
||||
|
||||

|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
@@ -65,6 +106,7 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
|
||||
- [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,11 +28,11 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
|---|---|---|
|
||||
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
|
||||
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, 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, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| 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 |
|
||||
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
|
||||
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection with a bounding-volume search shared with explicit contact; friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | planned |
|
||||
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
@@ -52,7 +52,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: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
|
||||
|
||||
## Next
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in FieldCraft
|
||||
|
||||
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 444 crates below.
|
||||
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 452 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
FieldCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -41,11 +41,13 @@ atomic-waker 1.1.2 (Apache-2.0 OR MIT)
|
||||
atspi 0.29.0 (Apache-2.0 OR MIT)
|
||||
atspi-common 0.13.0 (Apache-2.0 OR MIT)
|
||||
atspi-proxies 0.13.0 (Apache-2.0 OR MIT)
|
||||
base64 0.22.1 (MIT OR Apache-2.0)
|
||||
bit-set 0.10.0 (Apache-2.0 OR MIT)
|
||||
bit-vec 0.9.1 (Apache-2.0 OR MIT)
|
||||
bitflags 1.3.2 (MIT/Apache-2.0)
|
||||
bitflags 2.13.2 (MIT OR Apache-2.0)
|
||||
block-buffer 0.10.4 (MIT OR Apache-2.0)
|
||||
block-buffer 0.12.1 (MIT OR Apache-2.0)
|
||||
block2 0.5.1 (MIT)
|
||||
block2 0.6.2 (MIT)
|
||||
blocking 1.7.0 (Apache-2.0 OR MIT)
|
||||
@@ -68,12 +70,14 @@ codespan-reporting 0.13.1 (Apache-2.0)
|
||||
color 0.3.3 (Apache-2.0 OR MIT)
|
||||
combine 4.6.8 (MIT)
|
||||
concurrent-queue 2.5.0 (Apache-2.0 OR MIT)
|
||||
const-oid 0.10.2 (Apache-2.0 OR MIT)
|
||||
convert_case 0.4.0 (MIT)
|
||||
core-foundation 0.9.4 (MIT OR Apache-2.0)
|
||||
core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
|
||||
core-graphics 0.23.2 (MIT OR Apache-2.0)
|
||||
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.2.17 (MIT OR Apache-2.0)
|
||||
cpufeatures 0.3.1 (MIT OR Apache-2.0)
|
||||
craft-ui 0.1.0 (MIT OR Apache-2.0)
|
||||
crc32fast 1.5.2 (MIT OR Apache-2.0)
|
||||
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
|
||||
@@ -81,11 +85,13 @@ crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
|
||||
crossbeam-utils 0.8.23 (MIT OR Apache-2.0)
|
||||
crunchy 0.2.4 (MIT)
|
||||
crypto-common 0.1.7 (MIT OR Apache-2.0)
|
||||
crypto-common 0.2.2 (MIT OR Apache-2.0)
|
||||
cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib)
|
||||
data-url 0.3.2 (MIT OR Apache-2.0)
|
||||
derive_arbitrary 1.5.0 (MIT OR Apache-2.0)
|
||||
derive_more 0.99.20 (MIT)
|
||||
digest 0.10.7 (MIT OR Apache-2.0)
|
||||
digest 0.11.3 (MIT OR Apache-2.0)
|
||||
dispatch 0.2.0 (MIT)
|
||||
dispatch2 0.3.1 (Zlib OR Apache-2.0 OR MIT)
|
||||
dlib 0.5.3 (MIT)
|
||||
@@ -148,6 +154,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0)
|
||||
hashbrown 0.17.1 (MIT OR Apache-2.0)
|
||||
hermit-abi 0.5.3 (MIT OR Apache-2.0)
|
||||
hex 0.4.3 (MIT OR Apache-2.0)
|
||||
hybrid-array 0.4.15 (MIT OR Apache-2.0)
|
||||
iana-time-zone 0.1.65 (MIT OR Apache-2.0)
|
||||
iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0)
|
||||
image 0.25.10 (MIT OR Apache-2.0)
|
||||
@@ -290,6 +297,7 @@ serde_derive 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_json 1.0.154 (MIT OR Apache-2.0)
|
||||
serde_repr 0.1.21 (MIT OR Apache-2.0)
|
||||
sha2 0.10.9 (MIT OR Apache-2.0)
|
||||
sha2 0.11.1 (MIT OR Apache-2.0)
|
||||
signal-hook-registry 1.4.8 (MIT OR Apache-2.0)
|
||||
simd-adler32 0.3.10 (MIT)
|
||||
simd_cesu8 1.2.0 (Apache-2.0 OR MIT)
|
||||
@@ -746,14 +754,14 @@ DEALINGS IN THE SOFTWARE.
|
||||
================================================================================
|
||||
ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io
|
||||
2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task
|
||||
4.7.1, atomic-waker 1.1.2, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo 3.20.3, cfg-if
|
||||
1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys 0.8.7,
|
||||
core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21,
|
||||
crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent 1.0.2,
|
||||
errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0,
|
||||
flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown
|
||||
0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
|
||||
khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
4.7.1, atomic-waker 1.1.2, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
|
||||
3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys
|
||||
0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch
|
||||
0.9.21, crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent
|
||||
1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand
|
||||
2.5.0, flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1,
|
||||
hashbrown 0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
|
||||
0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
|
||||
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png
|
||||
0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
|
||||
@@ -2219,6 +2227,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
base64 0.22.1
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Alice Maz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
bit-set 0.10.0
|
||||
================================================================================
|
||||
@@ -2546,7 +2580,9 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9
|
||||
block-buffer 0.10.4, block-buffer 0.12.1, const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1,
|
||||
crypto-common 0.1.7, crypto-common 0.2.2, digest 0.10.7, digest 0.11.3, hybrid-array 0.4.15, sha2
|
||||
0.10.9, sha2 0.11.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -2751,6 +2787,36 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
block-buffer 0.12.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2018-2025 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
branches 0.3.0, rclite 0.2.8
|
||||
================================================================================
|
||||
@@ -3379,6 +3445,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
const-oid 0.10.2, cpufeatures 0.3.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2020-2026 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
cpufeatures 0.2.17
|
||||
================================================================================
|
||||
@@ -3731,6 +3827,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
crypto-common 0.2.2
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2021-2026 RustCrypto Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
cursor-icon 1.2.0
|
||||
================================================================================
|
||||
@@ -4063,6 +4189,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
digest 0.11.3
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2017-2025 RustCrypto Developers
|
||||
Copyright (c) 2017 Artyom Pavlov
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
dlib 0.5.3
|
||||
================================================================================
|
||||
@@ -6093,6 +6250,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
hybrid-array 0.4.15
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2022-2026 The RustCrypto Project Developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2
|
||||
================================================================================
|
||||
@@ -9890,6 +10077,39 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
sha2 0.11.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2016-2026 The RustCrypto Project Developers
|
||||
Copyright (c) 2016 Artyom Pavlov
|
||||
Copyright (c) 2009-2013 Mozilla Foundation
|
||||
Copyright (c) 2006-2009 Graydon Hoare
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
signal-hook-registry 1.4.8
|
||||
================================================================================
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -26,6 +27,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).
|
||||
@@ -46,6 +48,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,54 @@
|
||||
<!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.">
|
||||
<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 rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
|
||||
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#fieldcraft_loading img { width: 96px; height: 96px; }
|
||||
#fieldcraft_loading p { margin: 0; max-width: 32em; }
|
||||
#fieldcraft_loading.failed { pointer-events: auto; }
|
||||
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
|
||||
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fieldcraft_canvas"></canvas>
|
||||
<div id="fieldcraft_loading">
|
||||
<img src="icons/icon_128x128@2x.png" alt="">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading FieldCraft…</p>
|
||||
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
|
||||
</div>
|
||||
<script>
|
||||
// The page could not load or start the app (no WebAssembly, a failed download): say so
|
||||
// instead of loading forever. Once the app runs it removes the loading screen.
|
||||
(function () {
|
||||
function fail(why) {
|
||||
var el = document.getElementById("fieldcraft_loading");
|
||||
if (!el || el.classList.contains("failed")) return;
|
||||
el.classList.add("failed");
|
||||
el.textContent = "";
|
||||
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
|
||||
var p = document.createElement("p");
|
||||
p.textContent = t;
|
||||
el.appendChild(p);
|
||||
});
|
||||
}
|
||||
if (typeof WebAssembly !== "object") fail("this browser has no WebAssembly.");
|
||||
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
//! FieldCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`fieldcraft_ui_egui::ModelApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`); docs/web.md says what works in the browser.
|
||||
//!
|
||||
//! Differences from the desktop app, all behind the UI's host services:
|
||||
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
|
||||
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
|
||||
//! a download;
|
||||
//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
|
||||
//! - no control channel (a page can't listen on a socket);
|
||||
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
|
||||
//! that they run.
|
||||
//!
|
||||
//! Address flags: `?sample` or `?sample=cantilever` opens a sample, `?webgl` forces WebGL2.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod storage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("fieldcraft-web only runs in the browser: build it with `trunk build --release` in apps/fieldcraft-web (docs/web.md)");
|
||||
}
|
||||
|
||||
/// The sample an address asks for (`?sample` opens the first sample, `?sample=NAME` that one).
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn sample_in_query(query: &str) -> Option<String> {
|
||||
let first = fieldcraft_engine::sample::SAMPLES.first().map_or("", |s| s.name);
|
||||
query.trim_start_matches('?').split('&').find_map(|kv| {
|
||||
let (key, value) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
(key == "sample").then(|| if value.is_empty() { first } else { value }.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// The name a file from the computer is kept under in the page: its last path part, without
|
||||
/// characters a path would read as folders.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn safe_name(name: &str) -> String {
|
||||
let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let clean: String = last.chars().filter(|c| !c.is_control()).take(200).collect();
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "file".to_string() } else { clean }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn samples_from_the_address() {
|
||||
assert_eq!(sample_in_query(""), None);
|
||||
assert_eq!(sample_in_query("?sample").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?webgl&sample=cantilever").as_deref(), Some("cantilever"));
|
||||
assert_eq!(sample_in_query("?sample=").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?samples=x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_stay_in_their_folder() {
|
||||
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
||||
assert_eq!(safe_name("../../etc/passwd"), "passwd");
|
||||
assert_eq!(safe_name("C:\\work\\plate.solidcraft"), "plate.solidcraft");
|
||||
assert_eq!(safe_name(".."), "file");
|
||||
assert_eq!(safe_name(""), "file");
|
||||
assert_eq!(safe_name("a\u{0}b.3mf"), "ab.3mf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Autosaves kept in the browser.
|
||||
//!
|
||||
//! The engine's recovery folder is [`fieldcraft_io::recovery::BROWSER_DIR`] among the page's
|
||||
//! files, which last only as long as the page. This mirrors it to the browser's local storage
|
||||
//! (kept across visits, shared by the site's tabs): each file as base64 under
|
||||
//! [`PREFIX`]`<file name>`. At start the stored files go back into the folder, so the desktop's
|
||||
//! recovery flow (File › Recover…, `file.recovery.*`) offers what a closed or crashed page left.
|
||||
//!
|
||||
//! Local storage holds a few megabytes per site: an autosave larger than [`MAX_AUTOSAVE_BYTES`]
|
||||
//! (or one the browser refuses) is not kept, and the status bar says so.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use fieldcraft_io::recovery::BROWSER_DIR;
|
||||
|
||||
const PREFIX: &str = "fieldcraft-recovery/";
|
||||
|
||||
/// Largest stored file taken back (local storage itself holds less).
|
||||
const MAX_STORED_CHARS: usize = 64 << 20;
|
||||
|
||||
/// Largest autosave kept: base64 makes it a third larger, and browsers give a site about 5 MB.
|
||||
pub const MAX_AUTOSAVE_BYTES: usize = 3 << 20;
|
||||
|
||||
fn local_storage() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().ok_or("no window")?.local_storage().map_err(crate::web::js_err)?.ok_or_else(|| "browser storage is turned off".into())
|
||||
}
|
||||
|
||||
/// The mirror: the stored files this page knows of (took back or wrote), by file name.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
known: BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub fn dir() -> &'static Path {
|
||||
Path::new(BROWSER_DIR)
|
||||
}
|
||||
|
||||
/// A file name of the recovery folder that is safe as a key (the engine's ids and extensions).
|
||||
fn valid(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 100 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') && !name.starts_with('.')
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Put the stored files back into the recovery folder.
|
||||
pub fn load() -> Store {
|
||||
let mut store = Store::default();
|
||||
let s = match local_storage() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("autosaves: {e}");
|
||||
return store;
|
||||
}
|
||||
};
|
||||
let keys: Vec<String> = (0..s.length().unwrap_or(0)).filter_map(|i| s.key(i).ok().flatten()).collect();
|
||||
for key in keys {
|
||||
let Some(name) = key.strip_prefix(PREFIX).filter(|n| valid(n)) else { continue };
|
||||
let Some(text) = s.get_item(&key).ok().flatten().filter(|t| t.len() <= MAX_STORED_CHARS) else { continue };
|
||||
let Ok(bytes) = STANDARD.decode(text.as_bytes()).map_err(|e| log::warn!("autosave {name} is damaged: {e}")) else { continue };
|
||||
match fieldcraft_io::fs::insert(&dir().join(name), bytes) {
|
||||
Ok(()) => {
|
||||
store.known.insert(name.to_string());
|
||||
}
|
||||
Err(e) => log::warn!("autosave {name}: {e}"),
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// Keep a file the engine wrote in the recovery folder.
|
||||
pub fn put(&mut self, path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name =
|
||||
path.file_name().and_then(|n| n.to_str()).filter(|n| valid(n)).ok_or_else(|| format!("{}: not a recovery file", path.display()))?;
|
||||
if bytes.len() > MAX_AUTOSAVE_BYTES {
|
||||
return Err(format!("the model ({} MB) is too large for the browser's storage", bytes.len() >> 20));
|
||||
}
|
||||
self.known.insert(name.to_string());
|
||||
local_storage()?
|
||||
.set_item(&format!("{PREFIX}{name}"), &STANDARD.encode(bytes))
|
||||
.map_err(|e| format!("browser storage is full or turned off ({})", crate::web::js_err(e)))
|
||||
}
|
||||
|
||||
/// Drop the stored files whose entries left the recovery folder (cleared once saved,
|
||||
/// recovered and discarded). Only files this page knows of: another tab's stay.
|
||||
pub fn sync_removals(&mut self) {
|
||||
let present: BTreeSet<String> =
|
||||
fieldcraft_io::fs::list(dir()).iter().filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)).collect();
|
||||
let gone: Vec<String> = self.known.difference(&present).cloned().collect();
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(s) = local_storage() else { return };
|
||||
for name in gone {
|
||||
if s.remove_item(&format!("{PREFIX}{name}")).is_ok() {
|
||||
self.known.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The browser shell: the web services of the UI (file pickers, downloads), dropped files, the
|
||||
//! autosave, and eframe's web runner.
|
||||
//!
|
||||
//! Structure follows VectorCraft's `apps/vectorcraft-web/src/web.rs` and SolidCraft's
|
||||
//! `apps/solidcraft-web/src/main.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::Autosaver;
|
||||
use fieldcraft_ui_egui::files::Kind;
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::storage::{self, Store};
|
||||
|
||||
const CANVAS_ID: &str = "fieldcraft_canvas";
|
||||
const LOADING_ID: &str = "fieldcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// The page's folder for files from the computer and the files commands write.
|
||||
const FILES_DIR: &str = "/files";
|
||||
|
||||
/// How often unsaved changes are autosaved for recovery (seconds), as on the desktop.
|
||||
const AUTOSAVE_SECONDS: f64 = 30.0;
|
||||
|
||||
/// Files read from the computer (picked together or dropped together), waiting for the next
|
||||
/// frame: (name, bytes or why they could not be read) each.
|
||||
type Batch = Vec<(String, Result<Vec<u8>, String>)>;
|
||||
type Inbox = Rc<RefCell<Vec<Batch>>>;
|
||||
|
||||
/// Largest file read from the computer: what the page can hold.
|
||||
const MAX_READ_BYTES: f64 = fieldcraft_io::memory::MAX_TOTAL_BYTES as f64;
|
||||
|
||||
/// Why a file of `size` bytes is not read, if it is too large.
|
||||
fn too_large(size: f64) -> Option<String> {
|
||||
(size > MAX_READ_BYTES)
|
||||
.then(|| format!("{} MB is more than this page holds ({} MB)", (size / 1048576.0).round(), fieldcraft_io::memory::MAX_TOTAL_BYTES >> 20))
|
||||
}
|
||||
|
||||
/// Hand files read from the computer to the next frame.
|
||||
fn deliver(inbox: &Inbox, ctx: &egui::Context, batch: Batch) {
|
||||
inbox.borrow_mut().push(batch);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where a file named `name` is kept in the page.
|
||||
fn page_path(name: &str) -> String {
|
||||
format!("{FILES_DIR}/{}", crate::safe_name(name))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The model has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub fn js_err(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn element(id: &str) -> Option<web_sys::Element> {
|
||||
web_sys::window()?.document()?.get_element_by_id(id)
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// The browser's clock for the recovery entries (the plain wasm target has none).
|
||||
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options(),
|
||||
Box::new(|cc| {
|
||||
let inbox = Inbox::default();
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
// The autosaves a closed or crashed page left, back in the recovery folder.
|
||||
let store = Store::load();
|
||||
let listed = app.session.execute("file.recovery.list", &serde_json::json!({}));
|
||||
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
|
||||
let autosaver = Autosaver::new(storage::dir()).map_err(|e| log::warn!("autosave unavailable: {e}")).ok();
|
||||
if let Some(name) = crate::sample_in_query(&query()) {
|
||||
app.open_sample(&name);
|
||||
}
|
||||
if recoverable > 0 {
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
||||
}
|
||||
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
|
||||
fn web_options() -> eframe::WebOptions {
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().split(['?', '&']).any(|kv| kv == "webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Replace the loading screen with why the app could not start.
|
||||
fn failed(why: &str) {
|
||||
log::error!("{why}");
|
||||
let Some(el) = element(LOADING_ID) else { return };
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
el.set_text_content(None);
|
||||
// Best effort: the console has the message too.
|
||||
let _ = el.class_list().add_1("failed");
|
||||
for line in
|
||||
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
|
||||
{
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
let _ = el.append_child(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while the model has
|
||||
/// unsaved changes (a page that is closing can't show its own dialog).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
if let Err(e) = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()) {
|
||||
log::warn!("couldn't ask before leaving: {}", js_err(e));
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// The UI's host services in the browser.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
// The browser's file picker: the files arrive in a later frame (the picker and the reads
|
||||
// are asynchronous), so the call itself picks nothing.
|
||||
pick_open: Some(Box::new(move |filters: &[Filter]| {
|
||||
let (inbox, ctx) = (inbox.clone(), ctx.clone());
|
||||
let dialog = filters.iter().fold(rfd::AsyncFileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(files) = dialog.pick_files().await else { return };
|
||||
let mut batch = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let read = match too_large(f.inner().size()) {
|
||||
Some(why) => Err(why),
|
||||
None => Ok(f.read().await),
|
||||
};
|
||||
batch.push((f.file_name(), read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
None
|
||||
})),
|
||||
// Saving writes into the page; the shell hands each written file out as a download.
|
||||
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
|
||||
// The recent list holds the page's files, which go with the page.
|
||||
recent_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The app with the browser's files around it.
|
||||
struct Shell {
|
||||
app: ModelApp,
|
||||
inbox: Inbox,
|
||||
store: Store,
|
||||
/// `None` when autosave could not start.
|
||||
autosaver: Option<Autosaver>,
|
||||
/// App time of the last autosave check.
|
||||
autosaved_at: f64,
|
||||
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
||||
storage_error: bool,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files read from the computer go into the page's folder, then open: a model with the
|
||||
/// design and meshes it refers to picked or dropped with it, else each file in turn.
|
||||
fn open_arrived(&mut self) {
|
||||
let batches: Vec<_> = self.inbox.borrow_mut().drain(..).collect();
|
||||
for batch in batches {
|
||||
let mut paths = Vec::with_capacity(batch.len());
|
||||
for (name, read) in batch {
|
||||
let path = page_path(&name);
|
||||
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
|
||||
Ok(()) => paths.push(path),
|
||||
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
|
||||
}
|
||||
}
|
||||
let model = paths.iter().find(|p| Kind::of(p) == Some(Kind::Model)).cloned();
|
||||
match model {
|
||||
// Its geometry files open with it, from the same folder.
|
||||
Some(model) => self.app.open_path(&model),
|
||||
None => {
|
||||
for p in &paths {
|
||||
self.app.open_path(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What commands wrote: autosaves to the browser's storage, everything else downloaded.
|
||||
fn hand_out_writes(&mut self) {
|
||||
for (path, bytes) in fieldcraft_io::fs::take_writes() {
|
||||
if path.parent() == Some(storage::dir()) {
|
||||
match self.store.put(&path, &bytes) {
|
||||
Ok(()) => self.storage_error = false,
|
||||
Err(e) if !self.storage_error => {
|
||||
self.storage_error = true;
|
||||
self.app.set_status(format!("Autosave: {e}. Save the model to keep it (File › Save downloads it)"), true);
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
}
|
||||
} else if let Err(e) = download(&path, &bytes) {
|
||||
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
|
||||
}
|
||||
}
|
||||
self.store.sync_removals();
|
||||
}
|
||||
|
||||
fn autosave(&mut self, ctx: &egui::Context) {
|
||||
let Some(a) = &mut self.autosaver else { return };
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
self.autosaved_at = self.app.now;
|
||||
if let Err(e) = a.save(&self.app.session) {
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.open_arrived();
|
||||
self.app.logic(ctx);
|
||||
self.autosave(ctx);
|
||||
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
||||
}
|
||||
|
||||
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
|
||||
/// inbox; the app never sees their names as paths.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let dropped = std::mem::take(&mut raw.dropped_files);
|
||||
if !dropped.is_empty() {
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let mut batch = Vec::with_capacity(dropped.len());
|
||||
for f in dropped {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
let read = match f.web_file().and_then(|w| too_large(w.size())) {
|
||||
Some(why) => Err(why),
|
||||
None => f.bytes_async().await,
|
||||
};
|
||||
batch.push((name, read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
}
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
// Once a frame, after everything that writes: autosaves, saves and decks.
|
||||
self.hand_out_writes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `bytes` out as a download named after the last part of `path`.
|
||||
fn download(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("fieldcraft").to_string();
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type("application/octet-stream");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js_err)?.dyn_into().map_err(|_| "not a link")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js_err)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js_err)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked once the click has been dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// The download has started: a timer that can't be set leaves the URL allocated, no more.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
Ok(())
|
||||
}
|
||||
@@ -18,3 +18,7 @@ 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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -22,6 +22,9 @@ 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");
|
||||
@@ -148,6 +151,7 @@ fn main() -> eframe::Result {
|
||||
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 |
@@ -244,7 +244,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"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)"),
|
||||
@@ -281,7 +281,13 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
),
|
||||
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"),
|
||||
|
||||
@@ -89,6 +89,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 +125,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 +138,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 +179,13 @@ impl Session {
|
||||
let mut sources = Geometry::default();
|
||||
let mut design: Option<Arc<Design>> = None;
|
||||
if let Some(stored) = &file.geometry.design {
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings);
|
||||
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
|
||||
design = opened.map(Arc::new);
|
||||
sources.design = Some(source);
|
||||
}
|
||||
let mut surfaces = Vec::new();
|
||||
for stored in &file.geometry.meshes {
|
||||
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
|
||||
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES as u64, tagged_mesh::read_3mf, &mut warnings);
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
@@ -226,8 +232,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}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = super::file_param(s, p, "path", Access::Read)?;
|
||||
let bytes = fieldcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?;
|
||||
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
|
||||
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
|
||||
let design = open_design(&bytes)?;
|
||||
let out = json!({
|
||||
@@ -101,7 +101,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();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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};
|
||||
@@ -28,13 +28,13 @@ 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>,
|
||||
/// 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>,
|
||||
surfaces: Vec<Option<Surface>>,
|
||||
face_names: Vec<String>,
|
||||
diagonal: f64,
|
||||
pub(super) face_names: Vec<String>,
|
||||
pub(super) diagonal: f64,
|
||||
}
|
||||
|
||||
fn analytic(s: FaceSurface) -> Surface {
|
||||
@@ -47,7 +47,7 @@ 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
|
||||
@@ -73,7 +73,7 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
|
||||
}
|
||||
|
||||
fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
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) => {
|
||||
@@ -220,7 +220,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
model.set_element(label(e)?, m.kind, nodes)?;
|
||||
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
|
||||
}
|
||||
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), m, 1)?;
|
||||
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?;
|
||||
s.model = model;
|
||||
let blocks: Vec<Value> =
|
||||
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
|
||||
@@ -318,7 +318,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
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)?;
|
||||
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
|
||||
report.push(json!({
|
||||
"body": b.name,
|
||||
"method": m.report.method.name(),
|
||||
@@ -338,9 +338,15 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -6,6 +6,9 @@ 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};
|
||||
|
||||
@@ -83,7 +86,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
|
||||
pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.tet.generate",
|
||||
label: "Generate Tet Mesh",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "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. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
|
||||
kind: CommandKind::Model,
|
||||
run: tet_generate,
|
||||
};
|
||||
@@ -91,9 +94,11 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
|
||||
/// 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
|
||||
@@ -114,59 +119,216 @@ 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::Params(format!("`{key}` must be a number in {}..={}", range.start(), 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() {
|
||||
/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all.
|
||||
fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
|
||||
let d = QualityOptions::default();
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD)
|
||||
.ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?,
|
||||
),
|
||||
};
|
||||
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::Params("`order` must be \"linear\" or \"quadratic\"".into())),
|
||||
},
|
||||
};
|
||||
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 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()
|
||||
}
|
||||
|
||||
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.
|
||||
struct BodyTets {
|
||||
name: String,
|
||||
face_names: Vec<String>,
|
||||
mesh: QualityMesh,
|
||||
steiner: usize,
|
||||
cavities: usize,
|
||||
surface_volume: f64,
|
||||
}
|
||||
|
||||
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
|
||||
/// names the body.
|
||||
fn mesh_body(
|
||||
s: &Session,
|
||||
p: &Value,
|
||||
input: super::hex::Input,
|
||||
cdt: &CdtOptions,
|
||||
options: &QualityOptions,
|
||||
quality: bool,
|
||||
several: bool,
|
||||
) -> Result<BodyTets> {
|
||||
let mesh = || -> Result<BodyTets> {
|
||||
// The exact faces of a design body; an imported surface keeps to its triangles.
|
||||
let analytic = match p.get("surface") {
|
||||
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
|
||||
Some(_) => Vec::new(),
|
||||
};
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
mesh,
|
||||
steiner: tets.steiner.len(),
|
||||
cavities: tets.stats.cavities,
|
||||
surface_volume: surface.volume(),
|
||||
})
|
||||
};
|
||||
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
|
||||
}
|
||||
|
||||
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (options, quality) = quality_options(p, s.limits.max_tets as u64)?;
|
||||
let cdt_options =
|
||||
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
|
||||
// Every body is meshed before the model changes; with several, an error names its body.
|
||||
let inputs = super::hex::inputs(s, p)?;
|
||||
let several = inputs.len() > 1;
|
||||
let mut meshed = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
|
||||
}
|
||||
|
||||
let mut bodies = Vec::with_capacity(meshed.len());
|
||||
for b in &meshed {
|
||||
let (first_node, first_element) = add_tets(s, &b.name, &b.face_names, &b.mesh)?;
|
||||
let r = &b.mesh.report;
|
||||
bodies.push(json!({
|
||||
"body": b.name,
|
||||
"nodes": b.mesh.points.len(),
|
||||
"elements": b.mesh.elements.len(),
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"steiner": b.steiner,
|
||||
"cavities": b.cavities,
|
||||
"boundary_faces": b.mesh.boundary.len(),
|
||||
"volume": r.volume,
|
||||
"boundary_volume": r.boundary_volume,
|
||||
"surface_volume": b.surface_volume,
|
||||
"size": r.size,
|
||||
"quality": {
|
||||
"cdt": quality_json(&r.input),
|
||||
"mesh": quality_json(&r.output),
|
||||
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
|
||||
"odt_moves": r.odt_moves,
|
||||
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
|
||||
"straightened": r.straightened,
|
||||
"stopped": r.stopped,
|
||||
},
|
||||
}));
|
||||
}
|
||||
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
|
||||
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
|
||||
let first = bodies.first();
|
||||
Ok(json!({
|
||||
"kind": meshed.first().map(|b| b.mesh.kind.name()),
|
||||
"nodes": count(|b| b.mesh.points.len()),
|
||||
"elements": count(|b| b.mesh.elements.len()),
|
||||
"first_node": first.map(|b| b["first_node"].clone()),
|
||||
"first_element": first.map(|b| b["first_element"].clone()),
|
||||
"boundary_faces": count(|b| b.mesh.boundary.len()),
|
||||
"volume": sum(|b| b.mesh.report.volume),
|
||||
"boundary_volume": sum(|b| b.mesh.report.boundary_volume),
|
||||
"surface_volume": sum(|b| b.surface_volume),
|
||||
"bodies": bodies,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Add one body's elements after the model's largest labels, tagging them with the body and each
|
||||
/// boundary element face with its geometry face; returns the first node and element labels.
|
||||
fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
|
||||
for (i, xyz) in mesh.points.iter().enumerate() {
|
||||
s.model.set_node(first_node + i as u32, *xyz)?;
|
||||
}
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
|
||||
for (i, t) in cdt.tets.iter().enumerate() {
|
||||
let body_tag = s.model.geometry_tag(&GeometryRef::body(body)?)?;
|
||||
for (i, e) in mesh.elements.iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, ElementKind::Tet4, t.iter().map(|&v| first_node + v).collect())?;
|
||||
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
|
||||
s.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 = s.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)))?;
|
||||
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
|
||||
s.model.tag_element_face(first_element + b.tet, number, tag)?;
|
||||
}
|
||||
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 {
|
||||
@@ -367,6 +529,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 +541,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 +575,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 +586,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,
|
||||
@@ -612,6 +787,7 @@ mod tests {
|
||||
// The certified quality report agrees.
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements);
|
||||
assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}");
|
||||
// A second run appends after the existing labels.
|
||||
let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap();
|
||||
assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1);
|
||||
@@ -626,16 +802,60 @@ mod tests {
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 10}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "max_tets": "many"}),
|
||||
json!({"body": "Body1"}),
|
||||
json!({"body": "Body1", "tolerance": -1}),
|
||||
json!({"body": "Nope", "tolerance": 0.5}),
|
||||
json!({"body": "Body1", "surface": 0}),
|
||||
json!({"surface": 0}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("mesh.tet.generate", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing");
|
||||
}
|
||||
|
||||
/// Without parameters every body of the open design is meshed, each at a tolerance from its
|
||||
/// size; an error says which body it is about.
|
||||
#[test]
|
||||
fn tet_generate_defaults_to_every_body() {
|
||||
let commands = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}},
|
||||
{"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}},
|
||||
]});
|
||||
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
|
||||
let (_dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("open a design first"));
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
|
||||
let bodies = r["bodies"].as_array().unwrap();
|
||||
assert_eq!(bodies.len(), 2, "{r}");
|
||||
assert_eq!(r["kind"], "tet4");
|
||||
assert!((r["volume"].as_f64().unwrap() - 2000.0).abs() < 1e-6, "{r}");
|
||||
assert_eq!(s.model.elements().len() as u64, r["elements"].as_u64().unwrap());
|
||||
assert_eq!(s.model.classification().elements().len(), s.model.elements().len(), "every element is on its body");
|
||||
// The second body's labels follow the first's.
|
||||
assert_eq!(bodies[1]["first_element"].as_u64(), Some(bodies[0]["elements"].as_u64().unwrap() + 1));
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
// A body named alone takes the same default tolerance.
|
||||
let one = s.execute("mesh.tet.generate", &json!({"body": bodies[0]["body"]})).unwrap();
|
||||
assert_eq!(one["elements"], bodies[0]["elements"]);
|
||||
let e = s.execute("mesh.tet.generate", &json!({"max_tets": 1})).unwrap_err().to_string();
|
||||
assert!(e.starts_with("body `Box"), "{e}");
|
||||
}
|
||||
|
||||
/// The quality report's one-line summary counts the inverted elements.
|
||||
#[test]
|
||||
fn quality_summary_counts_invalid_elements() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.execute("mesh.quality", &json!({})).unwrap()["summary"], "no elements to measure");
|
||||
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
|
||||
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
|
||||
}
|
||||
s.execute("mesh.element.add", &json!({"label": 1, "kind": "tet4", "nodes": [1, 2, 3, 4]})).unwrap();
|
||||
s.execute("mesh.element.add", &json!({"label": 2, "kind": "tet4", "nodes": [1, 3, 2, 4]})).unwrap();
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert!(q["summary"].as_str().unwrap().starts_with("2 elements, 1 invalid; scaled Jacobian min -"), "{q}");
|
||||
}
|
||||
|
||||
/// The session's caps are the defaults of `max_steiner` and `max_tets`; a parameter overrides them.
|
||||
#[test]
|
||||
fn tet_generate_honours_the_session_caps() {
|
||||
@@ -656,6 +876,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;
|
||||
@@ -671,12 +938,17 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -8,7 +8,10 @@ pub(crate) mod file;
|
||||
mod geometry;
|
||||
mod hex;
|
||||
pub(crate) mod mesh;
|
||||
|
||||
pub use mesh::analytic_surfaces;
|
||||
mod model;
|
||||
pub(crate) mod octree;
|
||||
mod results;
|
||||
pub(crate) mod solve;
|
||||
mod tags;
|
||||
@@ -36,6 +39,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
mesh::TET_GENERATE,
|
||||
octree::HEX_OCTREE,
|
||||
block::BLOCK,
|
||||
hex::HEX_GENERATE,
|
||||
hex::HEX_SWEEP,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 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::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_OCTREE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.octree",
|
||||
label: "Octree Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
kind: CommandKind::Model,
|
||||
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 mesh: definitions stay, nodes, elements and geometry tags are new.
|
||||
let mut model = s.model.without_mesh();
|
||||
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 hx in &m.hexes {
|
||||
model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
|
||||
model.tag_element(element_label, body_tag)?;
|
||||
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
|
||||
}
|
||||
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,
|
||||
}));
|
||||
}
|
||||
s.model = model;
|
||||
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
pub const PROBE: CommandSpec = CommandSpec {
|
||||
id: "solve.probe",
|
||||
label: "Probe Results",
|
||||
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
kind: CommandKind::Model,
|
||||
run: probe,
|
||||
};
|
||||
@@ -489,13 +489,21 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (nodes, located) = match (p.get("nodes"), p.get("at")) {
|
||||
(Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None),
|
||||
(None, None) if p.get("set").is_some() => {
|
||||
let name = opt::<String>(p, "set")?.unwrap_or_default();
|
||||
let set = s.model.resolve_set(&name)?;
|
||||
if set.nodes.len() > MAX_PROBE {
|
||||
return Err(EngineError::Params(format!("set `{name}` has {} nodes; a probe takes at most {MAX_PROBE}", set.nodes.len())));
|
||||
}
|
||||
(set.nodes, None)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let at = points(p, "at", MAX_PROBE)?;
|
||||
let found = nearest_nodes(&s.model, result.nodes(), &at)?;
|
||||
(found.iter().map(|(l, _, _)| *l).collect(), Some(found))
|
||||
}
|
||||
(None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) || p.get("elements").is_some() => (Vec::new(), None),
|
||||
_ => return Err(EngineError::Params("give either `nodes` or `at`".into())),
|
||||
_ => return Err(EngineError::Params("give one of `nodes`, `at` or `set`".into())),
|
||||
};
|
||||
let mut out = match result.as_ref() {
|
||||
StepResult::Explicit(r) => {
|
||||
@@ -505,6 +513,16 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?,
|
||||
StepResult::Fluid(r) => probe_fluid(r, p)?,
|
||||
};
|
||||
// The largest von Mises stress among the probed nodes (static results).
|
||||
let peak = out.get("nodes").and_then(Value::as_array).and_then(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|e| Some((e.get("node")?.as_u64()?, e.get("von_mises")?.as_f64()?)))
|
||||
.fold(None, |best: Option<(u64, f64)>, (n, v)| if best.is_none_or(|b| v > b.1) { Some((n, v)) } else { best })
|
||||
});
|
||||
if let (Some((node, value)), Some(obj)) = (peak, out.as_object_mut()) {
|
||||
obj.insert("max_von_mises".into(), json!({"node": node, "value": value}));
|
||||
}
|
||||
if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) {
|
||||
for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) {
|
||||
if let Some(obj) = entry.as_object_mut() {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
pub mod autosave;
|
||||
pub mod catalogue;
|
||||
pub mod cmd;
|
||||
|
||||
pub use cmd::analytic_surfaces;
|
||||
pub mod history;
|
||||
pub mod results;
|
||||
pub mod sample;
|
||||
@@ -135,6 +137,8 @@ pub struct Limits {
|
||||
pub max_steiner: usize,
|
||||
/// `mesh.tet.generate`'s `max_tets`.
|
||||
pub max_tets: usize,
|
||||
/// `mesh.hex.octree`'s `max_elements`.
|
||||
pub max_hex_elements: usize,
|
||||
/// Threads the solvers use (the `solve.*` commands' `threads`): by default
|
||||
/// `FIELDCRAFT_THREADS` or the available cores, 1 to [`fieldcraft_parallel::MAX_THREADS`].
|
||||
/// Results do not depend on it.
|
||||
@@ -144,7 +148,12 @@ pub struct Limits {
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
let cdt = fieldcraft_mesh::cdt::CdtOptions::default();
|
||||
Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets, threads: fieldcraft_parallel::default_threads() }
|
||||
Limits {
|
||||
max_steiner: cdt.max_steiner,
|
||||
max_tets: cdt.max_tets,
|
||||
max_hex_elements: cmd::octree::DEFAULT_MAX_ELEMENTS,
|
||||
threads: fieldcraft_parallel::default_threads(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +367,8 @@ mod tests {
|
||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
|
||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
|
||||
json!({"path": 7}),
|
||||
json!({"body": "Body1", "tolerance": 0.05}),
|
||||
// (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.)
|
||||
json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}),
|
||||
json!({"body": null, "tolerance": "fine"}),
|
||||
json!({"elements": [u32::MAX, -1, 1.5]}),
|
||||
json!({"elements": {}, "bins": -5}),
|
||||
@@ -378,7 +388,7 @@ mod tests {
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": [[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]], [[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]], "size": 0.2}),
|
||||
json!({"polygon": [["a", 0], [1, null], {}], "size": "big", "kind": 7}),
|
||||
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e300, "kind": "tri6", "max_elements": 1e300}),
|
||||
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1}),
|
||||
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1, "max_points": 3000, "max_passes": 1}),
|
||||
// Structured hex meshing.
|
||||
json!({"size": -1, "order": 9, "method": "x"}),
|
||||
json!({"size": 1e-300}),
|
||||
@@ -471,9 +481,11 @@ mod tests {
|
||||
json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}),
|
||||
];
|
||||
// Small work caps, so input that would mesh up to the production caps (needle triangles
|
||||
// need Steiner points all along them) is refused at once. The caps themselves are what
|
||||
// need Steiner points all along them, an octree hex mesh is fitted and untangled) is
|
||||
// refused at once. The caps themselves are what
|
||||
// `tet_generate_honours_the_session_caps` checks.
|
||||
let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000, ..Limits::default() }, ..Session::default() };
|
||||
let capped =
|
||||
|| Session { limits: Limits { max_steiner: 64, max_tets: 100_000, max_hex_elements: 500, ..Limits::default() }, ..Session::default() };
|
||||
for spec in commands() {
|
||||
for p in &hostile {
|
||||
let mut s = capped();
|
||||
@@ -506,15 +518,34 @@ mod tests {
|
||||
json!({"body": "Body1", "tolerance": 1e-300}),
|
||||
json!({"body": "Body1", "tolerance": 1e300}),
|
||||
json!({"body": "Body2", "tolerance": 0.1}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "surface": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld": 1e-3}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld": f64::MIN_POSITIVE}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld": 0, "weld_distance": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 1e300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 0.05}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": 1e-300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": null}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "surface": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 1e-3}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": f64::MIN_POSITIVE}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0, "weld_distance": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 1e300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 0.05}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": 1e-300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": null}),
|
||||
// The quality stage's own parameters, hostile, with small caps so this stays fast.
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": -1}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 1e300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 1e-300, "max_points": 2000, "max_tets": 5000}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": "fine"}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "cubic"}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "quadratic", "quality": false}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality": "yes"}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 1e300}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": -1}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": 1e-300, "max_points": 2000}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": f64::MAX}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1e9}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 0}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 30, "max_passes": 1}),
|
||||
json!({"body": "Body1", "tolerance": 0.5, "size": 0.5, "max_tets": 3000, "max_passes": 1}),
|
||||
json!({"body": "Body1", "size": 1e-300}),
|
||||
json!({"body": "Body1", "size": 1e300, "order": 2}),
|
||||
json!({"body": "Body1", "size": 4, "method": "sweep", "order": 2}),
|
||||
|
||||
@@ -106,11 +106,13 @@ pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
|
||||
}
|
||||
|
||||
/// The inverse and determinant of a Jacobian that maps a reference element onto a valid one:
|
||||
/// `None` unless det J is positive beyond round-off relative to the product of its row norms.
|
||||
/// `None` unless it is admissible by FieldCraft's one validity criterion
|
||||
/// ([`fieldcraft_model::admissible_jacobian`], the one the meshers' validity gate proves).
|
||||
pub fn positive_inverse(j: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
|
||||
let (inv, det) = inverse3(j)?;
|
||||
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
|
||||
(det > 1e-12 * norms).then_some((inv, det))
|
||||
if !fieldcraft_model::admissible_jacobian(j) {
|
||||
return None;
|
||||
}
|
||||
inverse3(j)
|
||||
}
|
||||
|
||||
/// The eigenvalues and unit eigenvectors (the columns of the matrix returned) of a symmetric
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
use crate::basis::Basis;
|
||||
use crate::dense::{Cholesky, Matrix, inverse3, positive_inverse};
|
||||
use crate::dense::{Cholesky, Matrix, positive_inverse};
|
||||
use crate::dof::{DofSet, ElementDofs, Internal};
|
||||
use crate::material::{Elasticity, stress};
|
||||
use crate::quadrature::{Point, Rule};
|
||||
@@ -356,7 +356,7 @@ impl<'a> Solid<'a> {
|
||||
|
||||
fn centre_jacobian(&self) -> Result<([[f64; 3]; 3], f64)> {
|
||||
let s = (self.ty.shape)([0.0; 3]);
|
||||
inverse3(&self.jacobian(&s)).filter(|(_, d)| *d > 0.0).ok_or_else(|| self.degenerate())
|
||||
positive_inverse(&self.jacobian(&s)).ok_or_else(|| self.degenerate())
|
||||
}
|
||||
|
||||
/// The parts of the stiffness with the incompatible modes: K_uu (when `with_uu`), K_ua
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
use fieldcraft_model::Elastic;
|
||||
|
||||
use crate::dense::inverse3;
|
||||
use crate::dense::positive_inverse;
|
||||
use crate::material::Elasticity;
|
||||
use crate::quadrature::Rule;
|
||||
use crate::shape::{self, HEX_CORNERS};
|
||||
@@ -72,11 +72,7 @@ impl UniformStrain<8> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("hex8"))?;
|
||||
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
|
||||
if !(det > 1e-12 * norms) {
|
||||
return Err(degenerate("hex8"));
|
||||
}
|
||||
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("hex8"))?;
|
||||
let w = det * p.weight;
|
||||
volume += w;
|
||||
for (((bi, vi), d), n) in b.iter_mut().zip(nodal_volume.iter_mut()).zip(s.derivatives()).zip(s.values()) {
|
||||
@@ -172,15 +168,9 @@ impl UniformStrain<4> {
|
||||
/// The linear tetrahedron of corners `xyz` (the model's tet4 node order).
|
||||
pub fn tet4(xyz: &[[f64; 3]; 4]) -> Result<Tet4> {
|
||||
fieldcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
let [x0, x1, x2, x3] = *xyz;
|
||||
let e = |x: [f64; 3]| [x[0] - x0[0], x[1] - x0[1], x[2] - x0[2]];
|
||||
// J[a][c] = ∂x_c/∂ξ_a: the edges from corner 1.
|
||||
let j = [e(x1), e(x2), e(x3)];
|
||||
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("tet4"))?;
|
||||
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
|
||||
if !(det > 1e-12 * norms) {
|
||||
return Err(degenerate("tet4"));
|
||||
}
|
||||
let j = fieldcraft_model::tet4_jacobian(xyz);
|
||||
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("tet4"))?;
|
||||
let volume = det / 6.0;
|
||||
let s = shape::tet4([0.25; 3]);
|
||||
let mut b = [[0.0; 3]; 4];
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
//! with SolidCraft, and hand back its bodies with persistent face names.
|
||||
//!
|
||||
//! The design is rebuilt from its authoritative inputs (sketches, features, parameters); cached
|
||||
//! display meshes in the file are never used. [`open_design`] takes bytes; [`open_design_file`]
|
||||
//! reads a path through SolidCraft's file access, which in the browser is the in-memory files the
|
||||
//! web host loaded.
|
||||
//! display meshes in the file are never used. [`open_design`] takes the file's bytes (reading
|
||||
//! files is `fieldcraft_io::fs`'s job, which in the browser holds the page's files).
|
||||
|
||||
use solidcraft_doc::{Document, Model, naming};
|
||||
use solidcraft_kernel::{KernelError, guard};
|
||||
@@ -45,11 +44,6 @@ pub fn open_design(bytes: &[u8]) -> Result<Design> {
|
||||
rebuild(&doc)
|
||||
}
|
||||
|
||||
/// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it.
|
||||
pub fn open_design_file(path: &str) -> Result<Design> {
|
||||
open_design(&crate::read_file(path, MAX_DESIGN_BYTES)?)
|
||||
}
|
||||
|
||||
/// Rebuild an in-memory document (the design half of [`open_design`]).
|
||||
pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
let model = guard("rebuild design", || {
|
||||
@@ -165,8 +159,5 @@ mod tests {
|
||||
assert!(open_design(bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
assert!(open_design(&vec![b' '; MAX_DESIGN_BYTES + 1]).is_err());
|
||||
for path in ["", "/", "/dev/zero", "/no/such/design.solidcraft"] {
|
||||
assert!(open_design_file(path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub mod sample;
|
||||
#[cfg(feature = "scripts")]
|
||||
mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
pub use script::{MAX_SCRIPT_BYTES, script_design};
|
||||
@@ -47,14 +47,6 @@ impl From<KernelError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max_bytes` through SolidCraft's file access (in the browser,
|
||||
/// the in-memory files the web host loaded). Opening a design or a 3MF package from these bytes
|
||||
/// ([`open_design`], [`tagged_mesh::read_3mf`]) lets the caller record a hash of exactly what it
|
||||
/// opened.
|
||||
pub fn read_file(path: &str, max_bytes: usize) -> Result<Vec<u8>> {
|
||||
solidcraft_io::vfs::read(path, max_bytes).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))
|
||||
}
|
||||
|
||||
/// SolidCraft's linear modelling tolerance (mm): points closer than this are the same point.
|
||||
const LINEAR_TOLERANCE: f64 = solidcraft_geom::LINEAR_TOL;
|
||||
|
||||
|
||||
@@ -344,12 +344,6 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<Tagge
|
||||
Ok(TaggedSurface { name, mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags, weld_distance: 0.0 }, faces })
|
||||
}
|
||||
|
||||
/// [`read_3mf`] of a file: a regular file of at most [`MAX_FILE_BYTES`] (SolidCraft's virtual
|
||||
/// file system, so the browser's in-memory files work too).
|
||||
pub fn read_3mf_file(path: &str) -> Result<Vec<TaggedSurface>> {
|
||||
read_3mf(&crate::read_file(path, MAX_FILE_BYTES)?)
|
||||
}
|
||||
|
||||
/// The surfaces of a 3MF package, one per build item, in millimetres.
|
||||
pub fn read_3mf(bytes: &[u8]) -> Result<Vec<TaggedSurface>> {
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
|
||||
@@ -7,17 +7,18 @@
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
//!
|
||||
//! In the browser (`wasm32`) the same calls read and write files kept in memory
|
||||
//! ([`crate::memory`]): the web host puts files in ([`insert`]) and takes out what commands wrote
|
||||
//! ([`take_writes`]). Everything else in FieldCraft reaches files only through this module.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use disk::*;
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn io_error(path: &Path, e: std::io::Error) -> Error {
|
||||
Error::Io(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The backup of a file written with `backup: true`.
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
@@ -25,185 +26,276 @@ pub fn backup_path(path: &Path) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
use crate::Result;
|
||||
use crate::memory::MemoryFs;
|
||||
|
||||
/// Flush the directory entry (the rename) to disk where the platform allows it.
|
||||
fn sync_dir(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
|
||||
&& let Ok(d) = fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
thread_local! {
|
||||
// The browser runs the app on one thread.
|
||||
static FILES: RefCell<MemoryFs> = RefCell::new(MemoryFs::default());
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` (a backup is not kept: the host hands each save out as a
|
||||
/// download).
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], _backup: bool) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().write(path, bytes))
|
||||
}
|
||||
|
||||
/// Read a file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
FILES.with(|f| f.borrow().read(path, max))
|
||||
}
|
||||
|
||||
pub fn is_file(path: &Path) -> bool {
|
||||
FILES.with(|f| f.borrow().is_file(path))
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().remove(path))
|
||||
}
|
||||
|
||||
/// The files directly in `dir`.
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
FILES.with(|f| f.borrow().list(dir))
|
||||
}
|
||||
|
||||
/// Folders are implied by the files' paths.
|
||||
pub fn create_dir_all(_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The host puts a file in (one picked, dropped or kept in the browser's storage).
|
||||
pub fn insert(path: &Path, bytes: Vec<u8>) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().insert(path, bytes))
|
||||
}
|
||||
|
||||
/// What commands wrote since the last call, for the host to hand out or store.
|
||||
pub fn take_writes() -> Vec<(PathBuf, std::sync::Arc<[u8]>)> {
|
||||
FILES.with(|f| f.borrow_mut().take_writes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Steps of a save, so tests can stop one part way (as a crash would).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Step {
|
||||
TempWritten,
|
||||
BackupWritten,
|
||||
Replaced,
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod disk {
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
|
||||
let tmp = temp_beside(path, "new");
|
||||
if let Err(e) = write_synced(&tmp, bytes) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
use super::backup_path;
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn io_error(path: &Path, e: std::io::Error) -> Error {
|
||||
Error::Io(format!("{}: {e}", path.display()))
|
||||
}
|
||||
if stop == Step::TempWritten {
|
||||
return Ok(());
|
||||
|
||||
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
|
||||
}
|
||||
if backup && path.is_file() {
|
||||
// Copy (not move) the old file, so the target exists at every moment.
|
||||
let bak_tmp = temp_beside(path, "bak");
|
||||
let r = fs::copy(path, &bak_tmp)
|
||||
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
|
||||
.and_then(|f| f.sync_all())
|
||||
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
|
||||
if let Err(e) = r {
|
||||
let _ = fs::remove_file(&bak_tmp);
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Flush the directory entry (the rename) to disk where the platform allows it.
|
||||
fn sync_dir(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
|
||||
&& let Ok(d) = fs::File::open(dir)
|
||||
{
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Steps of a save, so tests can stop one part way (as a crash would).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Step {
|
||||
TempWritten,
|
||||
BackupWritten,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
|
||||
let tmp = temp_beside(path, "new");
|
||||
if let Err(e) = write_synced(&tmp, bytes) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if stop == Step::BackupWritten {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(path);
|
||||
// Temporary files of saves that crashed part way (a minute old or more) go.
|
||||
for t in stale_temps(path) {
|
||||
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
|
||||
let _ = fs::remove_file(t);
|
||||
if stop == Step::TempWritten {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
|
||||
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
|
||||
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let prefix = format!(".{name}.");
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
|
||||
.take(10_000)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
|
||||
}
|
||||
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
// The file may grow between the size check and the read.
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_keeps_a_backup() {
|
||||
let d = dir("bak");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"one", true).unwrap();
|
||||
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
|
||||
write_atomic(&p, b"two", true).unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two");
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
write_atomic(&p, b"three", false).unwrap();
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
assert!(stale_temps(&p).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save stopped after any step leaves the old file whole (or the new one).
|
||||
#[test]
|
||||
fn a_save_stopped_part_way_keeps_the_old_file() {
|
||||
let d = dir("stop");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"old", true).unwrap();
|
||||
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
|
||||
write_steps(&p, b"new content", true, stop).unwrap();
|
||||
let now = fs::read(&p).unwrap();
|
||||
if stop < Step::Replaced {
|
||||
assert_eq!(now, b"old", "{stop:?}");
|
||||
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
|
||||
for t in stale_temps(&p) {
|
||||
fs::remove_file(t).unwrap();
|
||||
}
|
||||
} else {
|
||||
assert_eq!(now, b"new content");
|
||||
if backup && path.is_file() {
|
||||
// Copy (not move) the old file, so the target exists at every moment.
|
||||
let bak_tmp = temp_beside(path, "bak");
|
||||
let r = fs::copy(path, &bak_tmp)
|
||||
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
|
||||
.and_then(|f| f.sync_all())
|
||||
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
|
||||
if let Err(e) = r {
|
||||
let _ = fs::remove_file(&bak_tmp);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
write_atomic(&p, b"old", false).unwrap();
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
if stop == Step::BackupWritten {
|
||||
return Ok(());
|
||||
}
|
||||
if let Err(e) = fs::rename(&tmp, path) {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
sync_dir(path);
|
||||
// Temporary files of saves that crashed part way (a minute old or more) go.
|
||||
for t in stale_temps(path) {
|
||||
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
|
||||
let _ = fs::remove_file(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_target() {
|
||||
let d = dir("fail");
|
||||
let p = d.join("missing-dir").join("a.fieldcraft");
|
||||
assert!(write_atomic(&p, b"x", true).is_err());
|
||||
assert!(!p.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
|
||||
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_capped() {
|
||||
let d = dir("read");
|
||||
let p = d.join("a.bin");
|
||||
fs::write(&p, [7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
|
||||
assert!(read_capped(&d, 1 << 20).is_err());
|
||||
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
|
||||
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
|
||||
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let prefix = format!(".{name}.");
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
|
||||
.take(10_000)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a regular file of at most `max` bytes.
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
|
||||
if !meta.is_file() {
|
||||
return Err(Error::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
|
||||
}
|
||||
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
// The file may grow between the size check and the read.
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn is_file(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> Result<()> {
|
||||
fs::remove_file(path).map_err(|e| io_error(path, e))
|
||||
}
|
||||
|
||||
/// The files directly in `dir` (at most a million).
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.take(1_000_000)
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_dir_all(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir).map_err(|e| io_error(dir, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_keeps_a_backup() {
|
||||
let d = dir("bak");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"one", true).unwrap();
|
||||
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
|
||||
write_atomic(&p, b"two", true).unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"two");
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
write_atomic(&p, b"three", false).unwrap();
|
||||
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
|
||||
assert!(stale_temps(&p).is_empty());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save stopped after any step leaves the old file whole (or the new one).
|
||||
#[test]
|
||||
fn a_save_stopped_part_way_keeps_the_old_file() {
|
||||
let d = dir("stop");
|
||||
let p = d.join("a.fieldcraft");
|
||||
write_atomic(&p, b"old", true).unwrap();
|
||||
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
|
||||
write_steps(&p, b"new content", true, stop).unwrap();
|
||||
let now = fs::read(&p).unwrap();
|
||||
if stop < Step::Replaced {
|
||||
assert_eq!(now, b"old", "{stop:?}");
|
||||
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
|
||||
for t in stale_temps(&p) {
|
||||
fs::remove_file(t).unwrap();
|
||||
}
|
||||
} else {
|
||||
assert_eq!(now, b"new content");
|
||||
}
|
||||
write_atomic(&p, b"old", false).unwrap();
|
||||
}
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_target() {
|
||||
let d = dir("fail");
|
||||
let p = d.join("missing-dir").join("a.fieldcraft");
|
||||
assert!(write_atomic(&p, b"x", true).is_err());
|
||||
assert!(!p.exists());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_are_capped() {
|
||||
let d = dir("read");
|
||||
let p = d.join("a.bin");
|
||||
fs::write(&p, [7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
|
||||
assert!(read_capped(&d, 1 << 20).is_err());
|
||||
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,12 @@
|
||||
//! capped before anything is allocated, every part's checksum is verified, and the model is
|
||||
//! rebuilt through the same checked methods commands use, so a file cannot hold what commands
|
||||
//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`],
|
||||
//! [`recovery`] and [`roots`] touch the file system.
|
||||
//! [`recovery`] and [`roots`] touch the file system, which in the browser is [`memory`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod fs;
|
||||
pub mod memory;
|
||||
pub mod mesh;
|
||||
pub mod recovery;
|
||||
pub mod roots;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Files kept in memory: the file system of the browser build.
|
||||
//!
|
||||
//! A browser page has no file system it can call synchronously, so on `wasm32` [`crate::fs`]
|
||||
//! keeps files here. The web host puts files in before a command reads them (a file picked or
|
||||
//! dropped, an autosave kept in the browser's storage) and takes out what commands wrote
|
||||
//! ([`MemoryFs::take_writes`]) to hand it out as a download or keep it in the browser's storage.
|
||||
//! Natively the type is only used by its tests.
|
||||
//!
|
||||
//! Follows SolidCraft's `crates/io/src/vfs.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Most files kept, and most bytes in all: a page that is handed more refuses the write.
|
||||
pub const MAX_FILES: usize = 10_000;
|
||||
pub const MAX_TOTAL_BYTES: u64 = 2 << 30;
|
||||
|
||||
/// Files by path (shared, so handing one out copies nothing), their total size, and the paths
|
||||
/// written since the host last took them.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryFs {
|
||||
files: BTreeMap<PathBuf, Arc<[u8]>>,
|
||||
total: u64,
|
||||
written: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn missing(path: &Path) -> Error {
|
||||
Error::Io(format!("{}: no such file in this browser page", path.display()))
|
||||
}
|
||||
|
||||
impl MemoryFs {
|
||||
fn room_for(&self, path: &Path, len: usize) -> Result<()> {
|
||||
let replaced = self.files.get(path).map_or(0, |b| b.len() as u64);
|
||||
let total = self.total.saturating_sub(replaced).saturating_add(len as u64);
|
||||
if !self.files.contains_key(path) && self.files.len() >= MAX_FILES {
|
||||
return Err(Error::Limit(format!("{}: more than {MAX_FILES} files in this browser page", path.display())));
|
||||
}
|
||||
if total > MAX_TOTAL_BYTES {
|
||||
return Err(Error::Limit(format!("{}: files in this browser page would take more than {} MB", path.display(), MAX_TOTAL_BYTES >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&mut self, path: &Path, bytes: Arc<[u8]>) -> Result<()> {
|
||||
self.room_for(path, bytes.len())?;
|
||||
self.total = self.total.saturating_add(bytes.len() as u64);
|
||||
if let Some(old) = self.files.insert(path.to_path_buf(), bytes) {
|
||||
self.total = self.total.saturating_sub(old.len() as u64);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a file in from the host (not recorded as written).
|
||||
pub fn insert(&mut self, path: &Path, bytes: Vec<u8>) -> Result<()> {
|
||||
self.put(path, bytes.into())
|
||||
}
|
||||
|
||||
/// Write a file for a command: kept, and recorded for [`MemoryFs::take_writes`].
|
||||
pub fn write(&mut self, path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
self.put(path, bytes.into())?;
|
||||
self.written.retain(|p| p != path);
|
||||
self.written.push(path.to_path_buf());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file of at most `max` bytes.
|
||||
pub fn read(&self, path: &Path, max: u64) -> Result<Vec<u8>> {
|
||||
let bytes = self.files.get(path).ok_or_else(|| missing(path))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), bytes.len() >> 20, max >> 20)));
|
||||
}
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub fn is_file(&self, path: &Path) -> bool {
|
||||
self.files.contains_key(path)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &Path) -> Result<()> {
|
||||
let old = self.files.remove(path).ok_or_else(|| missing(path))?;
|
||||
self.total = self.total.saturating_sub(old.len() as u64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files directly in `dir`, in path order.
|
||||
pub fn list(&self, dir: &Path) -> Vec<PathBuf> {
|
||||
// Paths order by component, so everything under `dir` follows it.
|
||||
self.files
|
||||
.range(dir.to_path_buf()..)
|
||||
.map(|(p, _)| p)
|
||||
.take_while(|p| p.starts_with(dir))
|
||||
.filter(|p| p.parent() == Some(dir))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The files written since the last call, in the order last written, with their content
|
||||
/// (a file removed since is left out).
|
||||
pub fn take_writes(&mut self) -> Vec<(PathBuf, Arc<[u8]>)> {
|
||||
std::mem::take(&mut self.written).into_iter().filter_map(|p| self.files.get(&p).cloned().map(|b| (p, b))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_are_kept_listed_and_handed_out_once_written() {
|
||||
let mut m = MemoryFs::default();
|
||||
let (a, b) = (Path::new("/browser/a.fieldcraft"), Path::new("/browser/b.inp"));
|
||||
m.insert(a, b"picked".to_vec()).unwrap();
|
||||
assert_eq!(m.read(a, 100).unwrap(), b"picked");
|
||||
assert!(m.take_writes().is_empty(), "a file put in by the host is not a write");
|
||||
m.write(b, b"one").unwrap();
|
||||
m.write(a, b"saved").unwrap();
|
||||
m.write(b, b"two").unwrap();
|
||||
let taken: Vec<(PathBuf, Vec<u8>)> = m.take_writes().into_iter().map(|(p, b)| (p, b.to_vec())).collect();
|
||||
assert_eq!(taken, vec![(a.to_path_buf(), b"saved".to_vec()), (b.to_path_buf(), b"two".to_vec())]);
|
||||
assert!(m.take_writes().is_empty());
|
||||
assert_eq!(m.list(Path::new("/browser")), vec![a.to_path_buf(), b.to_path_buf()]);
|
||||
assert!(m.list(Path::new("/")).is_empty(), "only the folder's own files");
|
||||
m.insert(Path::new("/browser2/c"), vec![]).unwrap();
|
||||
m.insert(Path::new("/browser/sub/d"), vec![]).unwrap();
|
||||
assert_eq!(m.list(Path::new("/browser")).len(), 2, "not a sibling folder's files, nor a subfolder's");
|
||||
m.write(a, b"again").unwrap();
|
||||
m.remove(a).unwrap();
|
||||
assert!(m.take_writes().is_empty(), "a removed file is not handed out");
|
||||
assert!(!m.is_file(a) && m.remove(a).is_err() && m.read(a, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_sizes_are_capped() {
|
||||
let mut m = MemoryFs::default();
|
||||
let p = Path::new("/x");
|
||||
m.insert(p, vec![0; 100]).unwrap();
|
||||
assert!(matches!(m.read(p, 99), Err(Error::Limit(_))));
|
||||
assert_eq!(m.read(p, 100).map(|b| b.len()), Ok(100));
|
||||
for i in 1..MAX_FILES {
|
||||
m.insert(&PathBuf::from(format!("/f{i}")), vec![]).unwrap();
|
||||
}
|
||||
assert!(matches!(m.write(Path::new("/one-too-many"), b""), Err(Error::Limit(_))));
|
||||
assert!(m.write(p, b"replacing is fine").is_ok());
|
||||
assert_eq!(m.total, 17, "the total follows replacements");
|
||||
m.remove(p).unwrap();
|
||||
assert_eq!(m.total, 0);
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,29 @@
|
||||
//! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists
|
||||
//! it for the next launch to offer.
|
||||
//!
|
||||
//! In the browser the folder is [`BROWSER_DIR`] in the page's files ([`crate::memory`]), which
|
||||
//! the web host keeps in the browser's storage, and an entry is held while the page that wrote it
|
||||
//! is open (the page holds no file locks; another tab's entry counts as left behind).
|
||||
//!
|
||||
//! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md. The engine decides when to autosave; this module only stores.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fs::{read_capped, write_atomic};
|
||||
use crate::fs::{create_dir_all, is_file, list, read_capped, remove as remove_file, write_atomic};
|
||||
use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result};
|
||||
|
||||
/// The recovery folder of the browser build.
|
||||
pub const BROWSER_DIR: &str = "/recovery";
|
||||
|
||||
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `FieldCraft/recovery` (`fieldcraft/` on
|
||||
/// Linux) in the platform's per-user data folder.
|
||||
/// Linux) in the platform's per-user data folder; [`BROWSER_DIR`] in the browser.
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Some(PathBuf::from(BROWSER_DIR));
|
||||
}
|
||||
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
|
||||
if let Some(d) = env("FIELDCRAFT_RECOVERY_DIR") {
|
||||
return Some(d);
|
||||
@@ -47,12 +56,28 @@ pub struct Entry {
|
||||
pub elements: usize,
|
||||
}
|
||||
|
||||
/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm).
|
||||
fn unix_now() -> u64 {
|
||||
/// Milliseconds since 1970 from the host, where the platform has no clock of its own (the
|
||||
/// browser's plain wasm); see [`set_clock`].
|
||||
static CLOCK: std::sync::OnceLock<fn() -> u64> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the module a clock (milliseconds since 1970) on a platform without one: the web host
|
||||
/// passes the browser's. Entries then record when they were saved and get ids no other page
|
||||
/// takes. The first clock given stays.
|
||||
pub fn set_clock(now_ms: fn() -> u64) {
|
||||
let _ = CLOCK.set(now_ms);
|
||||
}
|
||||
|
||||
/// Milliseconds since 1970 (0 without a clock).
|
||||
fn unix_now_ms() -> u64 {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return 0;
|
||||
return CLOCK.get().map_or(0, |now| now());
|
||||
}
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis().try_into().unwrap_or(u64::MAX)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Seconds since 1970 (0 without a clock).
|
||||
fn unix_now() -> u64 {
|
||||
unix_now_ms() / 1000
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
@@ -63,11 +88,106 @@ fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
|
||||
dir.join(format!("{id}.{ext}"))
|
||||
}
|
||||
|
||||
/// Whether a running app holds entry `id`'s lock.
|
||||
fn held(dir: &Path, id: &str) -> bool {
|
||||
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
|
||||
/// Entry locks on the desktop: `<id>.lock`, locked while the app runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod lock {
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{MAX_LISTED, file};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A held entry lock.
|
||||
#[derive(Debug)]
|
||||
pub struct Lock(#[allow(dead_code)] File);
|
||||
|
||||
/// Whether a running app holds entry `id`'s lock.
|
||||
pub fn held(dir: &Path, id: &str) -> bool {
|
||||
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
|
||||
}
|
||||
|
||||
pub fn take(dir: &Path, id: &str) -> Result<Lock> {
|
||||
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
Ok(Lock(lock))
|
||||
}
|
||||
|
||||
/// Remove the locks of apps that ended with nothing to recover.
|
||||
pub fn sweep(dir: &Path) {
|
||||
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("lock")
|
||||
&& !p.with_extension("json").exists()
|
||||
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
|
||||
{
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A process-unique part of new entry ids.
|
||||
pub fn instance() -> String {
|
||||
std::process::id().to_string()
|
||||
}
|
||||
|
||||
/// Let go of the lock; its file goes unless an autosave stays for the next launch.
|
||||
pub fn release(lock: Lock, dir: &Path, id: &str, keep: bool) {
|
||||
drop(lock);
|
||||
if !keep {
|
||||
let _ = fs::remove_file(file(dir, id, "lock"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry "locks" in the browser: the entries this page holds, kept in memory (a page that is
|
||||
/// gone holds none).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod lock {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
thread_local! {
|
||||
static HELD: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
|
||||
}
|
||||
|
||||
/// Releases the entry when dropped (the slot is gone).
|
||||
#[derive(Debug)]
|
||||
pub struct Lock(String);
|
||||
|
||||
impl Drop for Lock {
|
||||
fn drop(&mut self) {
|
||||
HELD.with(|h| h.borrow_mut().remove(&self.0));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn held(_dir: &Path, id: &str) -> bool {
|
||||
HELD.with(|h| h.borrow().contains(id))
|
||||
}
|
||||
|
||||
pub fn take(_dir: &Path, id: &str) -> Result<Lock> {
|
||||
HELD.with(|h| h.borrow_mut().insert(id.to_string()));
|
||||
Ok(Lock(id.to_string()))
|
||||
}
|
||||
|
||||
pub fn sweep(_dir: &Path) {}
|
||||
|
||||
/// The page's part of new entry ids: the milliseconds of the clock (the id has its seconds),
|
||||
/// so pages started in the same second take different ids (unless in the same millisecond).
|
||||
pub fn instance() -> String {
|
||||
format!("w{}", super::unix_now_ms() % 1000)
|
||||
}
|
||||
|
||||
pub fn release(lock: Lock, _dir: &Path, _id: &str, _keep: bool) {
|
||||
drop(lock);
|
||||
}
|
||||
}
|
||||
|
||||
use lock::held;
|
||||
|
||||
fn missing(id: &str) -> Error {
|
||||
Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::<String>()))
|
||||
}
|
||||
@@ -84,17 +204,14 @@ fn read_entry(path: &Path) -> Option<Entry> {
|
||||
|
||||
/// Entries left by apps that are no longer running, newest first.
|
||||
pub fn orphans(dir: &Path) -> Vec<Entry> {
|
||||
let mut out: Vec<Entry> = fs::read_dir(dir)
|
||||
let mut out: Vec<Entry> = list(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
.filter_map(|p| {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("json") {
|
||||
return None;
|
||||
}
|
||||
let entry = read_entry(&p)?;
|
||||
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file()
|
||||
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !is_file(&file(dir, &entry.id, EXTENSION))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -126,7 +243,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
return Err(Error::Io(format!("recovered model `{id}` belongs to a running app")));
|
||||
}
|
||||
for ext in ["json", EXTENSION, "lock"] {
|
||||
let _ = fs::remove_file(file(dir, id, ext));
|
||||
let _ = remove_file(&file(dir, id, ext));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -136,32 +253,21 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
pub struct Slot {
|
||||
dir: PathBuf,
|
||||
id: String,
|
||||
lock: Option<File>,
|
||||
lock: Option<lock::Lock>,
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Take a new entry in `dir` (created if needed), holding its lock.
|
||||
pub fn new(dir: &Path) -> Result<Slot> {
|
||||
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
|
||||
fs::create_dir_all(dir).map_err(io)?;
|
||||
// Locks of apps that ended with nothing to recover.
|
||||
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) == Some("lock")
|
||||
&& !p.with_extension("json").exists()
|
||||
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
|
||||
{
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
create_dir_all(dir)?;
|
||||
lock::sweep(dir);
|
||||
// Unique within the process too: an app started again in the same second (tests that
|
||||
// "crash" and restart) must not take over the crashed app's entry.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
let id = format!("{}-{}-{seq}", unix_now(), lock::instance());
|
||||
let lock = lock::take(dir, &id)?;
|
||||
Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false })
|
||||
}
|
||||
|
||||
@@ -189,7 +295,7 @@ impl Slot {
|
||||
pub fn clear(&mut self) {
|
||||
if self.has_entry {
|
||||
for ext in ["json", EXTENSION] {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
||||
let _ = remove_file(&file(&self.dir, &self.id, ext));
|
||||
}
|
||||
self.has_entry = false;
|
||||
}
|
||||
@@ -198,15 +304,16 @@ impl Slot {
|
||||
/// The app is closing: the lock is released; with no autosave the lock file goes too, while
|
||||
/// an autosave stays for the next launch to offer.
|
||||
pub fn close(mut self) {
|
||||
self.lock = None;
|
||||
if !self.has_entry {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
|
||||
if let Some(lock) = self.lock.take() {
|
||||
lock::release(lock, &self.dir, &self.id, self.has_entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Where tool calls end up: an in-process headless session, or a running app's control channel
|
||||
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.camera`,
|
||||
//! `ui.render`, `ui.capture`).
|
||||
//! `ui.render`, `ui.capture`, `ui.select`, `ui.selection`).
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
@@ -43,6 +43,8 @@ pub trait Backend {
|
||||
fn commands(&mut self) -> Result<Value, String>;
|
||||
/// A PNG of the model, or of the app's window when one is attached.
|
||||
fn picture(&mut self, request: &PictureRequest) -> Result<Picture, String>;
|
||||
/// A request to the app's window (`ui.select`, `ui.selection`): an error without one.
|
||||
fn window(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// The folders file access is confined to (checked by the server for the files it writes
|
||||
/// itself).
|
||||
fn roots(&self) -> &Roots;
|
||||
@@ -124,6 +126,10 @@ impl Backend for Headless {
|
||||
Ok(Picture { png, info })
|
||||
}
|
||||
|
||||
fn window(&mut self, method: &str, _params: Value) -> Result<Value, String> {
|
||||
Err(format!("{method} needs the app's window: start the server with --connect to a running FieldCraft"))
|
||||
}
|
||||
|
||||
fn roots(&self) -> &Roots {
|
||||
&self.roots
|
||||
}
|
||||
@@ -267,6 +273,10 @@ impl Backend for Remote {
|
||||
read
|
||||
}
|
||||
|
||||
fn window(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.call(method, params)
|
||||
}
|
||||
|
||||
fn roots(&self) -> &Roots {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ fn initialize_lists_tools_and_resources() {
|
||||
"probe",
|
||||
"reactions",
|
||||
"screenshot",
|
||||
"select",
|
||||
"selection",
|
||||
"undo",
|
||||
"redo",
|
||||
] {
|
||||
@@ -181,6 +183,9 @@ fn hostile_tool_arguments_are_tool_errors() {
|
||||
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0}, "view": "top"})),
|
||||
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
|
||||
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
|
||||
("select", json!({"filter": "faces"})),
|
||||
("select", json!({})),
|
||||
("selection", json!({})),
|
||||
("undo", json!({})),
|
||||
("redo", json!({})),
|
||||
] {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub const MAX_STEPS: usize = 10_000;
|
||||
/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is
|
||||
/// left out.
|
||||
const MESHERS: &[(&str, &str, &str)] = &[
|
||||
("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"),
|
||||
("tet", "mesh.tet.generate", "constrained Delaunay tets of every body of the open design, one {body}, or an imported surface {surface}"),
|
||||
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
|
||||
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
|
||||
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
|
||||
@@ -290,6 +290,41 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
true,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"select",
|
||||
"Select in the viewport",
|
||||
"Change the app window's viewport selection (needs --connect; ui.select): `filter` (faces, edges, vertices, \
|
||||
bodies, nodes, element_faces, elements), `mode` (replace, add, remove, toggle), `clear`, items by name and \
|
||||
label as model.set.create takes them (`geometry`, `nodes`, `elements`, `faces`), a pick at a window point \
|
||||
`at` [x, y] or the visible items in a box `box` [x0, y0, x1, y1]. Returns the selection; its `params` are \
|
||||
a set's members.",
|
||||
obj(
|
||||
json!({
|
||||
"filter": {"type": "string", "enum": ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"]},
|
||||
"box_mode": {"type": "boolean", "description": "Box selection on or off in the window"},
|
||||
"mode": {"type": "string", "enum": ["replace", "add", "remove", "toggle"]},
|
||||
"clear": {"type": "boolean", "description": "Empty the selection first"},
|
||||
"geometry": {"type": "array", "items": {"type": "object"}, "description": "[{body, face|edge|vertex}]"},
|
||||
"nodes": {"type": "array", "items": {"type": "integer"}},
|
||||
"elements": {"type": "array", "items": {"type": "integer"}},
|
||||
"faces": {"type": "array", "items": {"type": "object"}, "description": "[{element, face}]"},
|
||||
"at": {"type": "array", "items": {"type": "number"}, "description": "Window point [x, y] to pick at"},
|
||||
"box": {"type": "array", "items": {"type": "number"}, "description": "Window box [x0, y0, x1, y1]"},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"selection",
|
||||
"Read the viewport selection",
|
||||
"The app window's viewport selection (needs --connect; ui.selection): filter, items, `params` (the \
|
||||
selection as a set's members, ready for model.set.create) and what is under the pointer.",
|
||||
obj(json!({}), &[]),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false),
|
||||
tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false),
|
||||
]
|
||||
@@ -440,6 +475,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
"show_results" => show_results(b, a),
|
||||
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
|
||||
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
|
||||
"select" => {
|
||||
let p = pick(a, &["filter", "box_mode", "mode", "clear", "geometry", "nodes", "elements", "faces", "at", "box"]);
|
||||
if p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
return Err("select: give `filter`, `mode`, `clear`, items, `at` or `box`".into());
|
||||
}
|
||||
b.window("ui.select", p)
|
||||
}
|
||||
"selection" => b.window("ui.selection", json!({})),
|
||||
"undo" => b.execute("edit.undo", json!({})),
|
||||
"redo" => b.execute("edit.redo", json!({})),
|
||||
other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::<String>())),
|
||||
|
||||
@@ -8,43 +8,12 @@
|
||||
//! the hull. The hull is the bounding box the recovery adds around the surface, so every Steiner
|
||||
//! point is strictly inside it and no ghost tetrahedra are needed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
pub(super) use crate::tet::complex::{IdMap, NONE, face_toward, sorted3};
|
||||
|
||||
use crate::delaunay::Triangulation;
|
||||
use crate::predicates::{GenericPoint, Sign, generic_insphere_sos, generic_lex, generic_orient3d};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A multiplicative hasher for small integer keys (vertex triples). The maps it serves are only
|
||||
/// looked up, never iterated, so its order cannot leak into the output.
|
||||
#[derive(Default)]
|
||||
pub(super) struct IdHasher(u64);
|
||||
|
||||
impl Hasher for IdHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.write_u64(u64::from(b));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_u32(&mut self, x: u32) {
|
||||
self.write_u64(u64::from(x));
|
||||
}
|
||||
|
||||
fn write_u64(&mut self, x: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash map keyed by vertex ids.
|
||||
pub(super) type IdMap<K, V> = HashMap<K, V, BuildHasherDefault<IdHasher>>;
|
||||
|
||||
/// No tetrahedron / no vertex.
|
||||
pub(super) const NONE: u32 = u32::MAX;
|
||||
/// Marks a recycled tetrahedron slot.
|
||||
const DEAD: u32 = u32::MAX - 1;
|
||||
|
||||
@@ -52,25 +21,6 @@ pub(super) fn corrupt(what: &str) -> Error {
|
||||
Error::Failed(format!("constrained tetrahedralization is inconsistent: {what}"))
|
||||
}
|
||||
|
||||
/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side
|
||||
/// ([`Complex::volume`] of the face plus that vertex is positive).
|
||||
pub(super) fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] {
|
||||
let [a, b, c, d] = vs;
|
||||
match k {
|
||||
0 => [b, d, c],
|
||||
1 => [a, c, d],
|
||||
2 => [a, d, b],
|
||||
_ => [a, b, c],
|
||||
}
|
||||
}
|
||||
|
||||
/// A triangle's vertices sorted: the key of an unoriented face.
|
||||
pub(super) fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] {
|
||||
let mut f = [a, b, c];
|
||||
f.sort_unstable();
|
||||
f
|
||||
}
|
||||
|
||||
/// The working tetrahedralization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct Complex {
|
||||
|
||||
@@ -34,9 +34,10 @@
|
||||
//! then the Steiner points, each with its segment and parameter, rounded to doubles; and for each
|
||||
//! boundary tetrahedron face the input triangle it lies on (so the B-rep face tag carries over).
|
||||
//! Rounding a Steiner point to the nearest doubles moves it off its segment by under an ulp and
|
||||
//! can, on a nearly flat tetrahedron, flip it (§5.1). [`round`] then tries the doubles a few ulps
|
||||
//! around such a point for one that keeps every incident tetrahedron positive; [`TetMesh::gate`]
|
||||
//! runs the validity gate on the rounded mesh and any failure left is reported, never hidden.
|
||||
//! can, on a nearly flat tetrahedron, flip it (§5.1); nearly cospherical input also gives
|
||||
//! tetrahedra that are positive yet flat to rounding. [`repair`] removes them by flips that keep
|
||||
//! the boundary (§5.1); [`TetMesh::gate`] runs the validity gate on the rounded mesh and any
|
||||
//! failure left is reported, never hidden.
|
||||
//!
|
||||
//! # Determinism and bounds
|
||||
//! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are
|
||||
@@ -46,11 +47,11 @@
|
||||
mod complex;
|
||||
mod facets;
|
||||
mod meet;
|
||||
mod round;
|
||||
mod repair;
|
||||
mod segments;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -122,9 +123,9 @@ pub struct CdtStats {
|
||||
pub gift_wrapped_tets: usize,
|
||||
/// Tetrahedra in the box at the end.
|
||||
pub box_tets: usize,
|
||||
/// Steiner points moved by a few ulps from their nearest doubles to keep every incident
|
||||
/// tetrahedron positive ([`round`]).
|
||||
pub rounding_adjusted: usize,
|
||||
/// Tetrahedra that were flat, inverted or inadmissible on the rounded coordinates and were
|
||||
/// removed by flips ([`repair`]).
|
||||
pub repaired: usize,
|
||||
}
|
||||
|
||||
/// The constrained Delaunay tetrahedralization of a closed surface (see the module docs).
|
||||
@@ -355,7 +356,7 @@ impl Recovery {
|
||||
}
|
||||
let mut stats = self.stats;
|
||||
stats.box_tets = self.cx.live_count();
|
||||
stats.rounding_adjusted = round::repair(&mut points, &tets, n)?;
|
||||
stats.repaired = repair::repair(&points, &mut tets, &mut boundary, self.options.max_tets)?;
|
||||
Ok(TetMesh { points, steiner, tets, boundary, stats })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Repairing the tetrahedra that rounding to doubles leaves flat, inverted or inadmissible.
|
||||
//!
|
||||
//! The exact tetrahedralization is valid, but its Steiner points are rounded to doubles, which
|
||||
//! can flip a nearly flat tetrahedron (Diazzi et al. 2023, §5.1), and nearly cospherical input
|
||||
//! (a tessellated sphere, a cylinder) gives tetrahedra that are positive yet flat to rounding:
|
||||
//! their normalized Jacobian is below FieldCraft's validity criterion
|
||||
//! ([`fieldcraft_model::admissible_jacobian`]), so a solver would refuse them. Following §5.1,
|
||||
//! such tetrahedra are removed by flips that never touch the boundary, on the rounded
|
||||
//! coordinates and decided by exact `orient3d`: edge removal (which includes the 3-2 and 4-4
|
||||
//! flips) around each of their edges and face removal (the 2-3 flip and multi-face removal)
|
||||
//! across each of their faces, each applied only when it improves the worst tetrahedron it
|
||||
//! changes ([`crate::tet::ops`]). Every boundary edge is held fixed, so every input triangle
|
||||
//! stays a union of tetrahedron faces. The passes are capped; whatever is left is reported by the
|
||||
//! validity gate, never hidden.
|
||||
|
||||
use fieldcraft_model::{normalized_jacobian, tet4_jacobian};
|
||||
|
||||
use super::BoundaryFace;
|
||||
use crate::tet::complex::{EDGES, NONE, TetComplex, VertexKind};
|
||||
use crate::tet::ops::{remove_edge_at, remove_face};
|
||||
use crate::tet::refine::feature_curves;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A tetrahedron is repaired when its normalized Jacobian is at most this (a margin of a
|
||||
/// thousand above the validity criterion, so rounding differences cannot bring it back).
|
||||
const REPAIR_BELOW: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN;
|
||||
/// Passes over the tetrahedra still to repair.
|
||||
const MAX_PASSES: usize = 8;
|
||||
|
||||
fn needs_repair(points: &[[f64; 3]], t: &[u32; 4]) -> bool {
|
||||
let p = |v: u32| points.get(v as usize).copied().unwrap_or([f64::NAN; 3]);
|
||||
!(normalized_jacobian(&tet4_jacobian(&[p(t[0]), p(t[1]), p(t[2]), p(t[3])])) > REPAIR_BELOW)
|
||||
}
|
||||
|
||||
/// Repair the rounded mesh in place; the number of tetrahedra that needed it.
|
||||
pub(super) fn repair(points: &[[f64; 3]], tets: &mut Vec<[u32; 4]>, boundary: &mut Vec<BoundaryFace>, max_tets: usize) -> Result<usize> {
|
||||
let bad = tets.iter().filter(|t| needs_repair(points, t)).count();
|
||||
if bad == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let tags: Vec<(u32, usize, u32)> = boundary.iter().map(|b| (b.tet, usize::from(b.opposite), b.triangle)).collect();
|
||||
let kinds = vec![VertexKind::Corner; points.len()];
|
||||
let mut cx = TetComplex::with_invalid(points, kinds, tets, &tags, &[], points.len(), max_tets.max(tets.len() * 2))?;
|
||||
// The segments: boundary edges between two different input triangles (never by angle).
|
||||
// Edges inside one input triangle (between its pieces) may be flipped; the triangle stays
|
||||
// covered.
|
||||
let (_, segments) = feature_curves(&cx, -2.0)?;
|
||||
for ((a, b), c) in segments {
|
||||
cx.set_curve(a, b, Some(c));
|
||||
}
|
||||
for _ in 0..MAX_PASSES {
|
||||
let targets: Vec<(u32, [u32; 4])> = cx.live_tets().filter(|(_, vs)| needs_repair(points, vs)).collect();
|
||||
if targets.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut changed = false;
|
||||
for (t, vs) in targets {
|
||||
if !cx.is_live(t) || cx.tet(t)? != vs {
|
||||
continue;
|
||||
}
|
||||
for &(i, j) in &EDGES {
|
||||
if cx.is_live(t) && cx.tet(t)? == vs && remove_edge_at(&mut cx, t, vs[i], vs[j])? {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A flat tetrahedron lying on one input triangle (two of its faces are pieces of
|
||||
// it): delete it; its two other faces cover the same piece of the triangle.
|
||||
if cx.is_live(t) && cx.tet(t)? == vs && cx.delete_boundary_tet(t)? {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if cx.is_live(t) && cx.tet(t)? == vs {
|
||||
for k in 0..4 {
|
||||
if remove_face(&mut cx, t, k)? {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let c = cx.compact()?;
|
||||
if c.source.len() != points.len() || c.source.iter().enumerate().any(|(i, &s)| s as usize != i) {
|
||||
return Err(Error::Failed("repair removed a vertex".into()));
|
||||
}
|
||||
*tets = c.tets;
|
||||
*boundary = c.boundary.into_iter().map(|(tet, k, tag)| BoundaryFace { tet, opposite: k as u8, triangle: tag }).collect();
|
||||
if boundary.iter().any(|b| b.triangle == NONE) {
|
||||
return Err(Error::Failed("repair lost a boundary face's triangle".into()));
|
||||
}
|
||||
Ok(bad)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Rounding the Steiner points to doubles without inverting tetrahedra.
|
||||
//!
|
||||
//! The exact tetrahedralization is valid, but a Steiner point rounded to its nearest doubles is
|
||||
//! off its segment by up to half an ulp per coordinate, which flips a tetrahedron whose volume is
|
||||
//! below that scale (Diazzi et al. 2023, §5.1, §6.2: the nearly flat ones, typically four Steiner
|
||||
//! points on nearly parallel boundary edges). For each tetrahedron that is not positive after
|
||||
//! rounding, each of its Steiner points tries the doubles within [`REACH`] ulps of its rounded
|
||||
//! coordinates, nearest first, and takes the first with which every tetrahedron around it is
|
||||
//! positive (exact `orient3d` on the doubles). Surface vertices never move. The moves are a few
|
||||
//! ulps, so boundary faces stay on their triangles to rounding precision. The number of passes is
|
||||
//! capped; what is left is for the validity gate to report.
|
||||
//!
|
||||
//! This is the interim answer. The paper's own (§5.1) improves the exact mesh by flips that never
|
||||
//! cross a facet before rounding, which removes most nearly flat tetrahedra instead of nudging
|
||||
//! their vertices; it belongs with the flips of the quality stage.
|
||||
|
||||
use crate::predicates::{Point3, Sign, tet_volume_sign};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// How far, in ulps per coordinate, a rounded Steiner point may move.
|
||||
const REACH: i32 = 2;
|
||||
/// Passes over the tetrahedra that are not positive.
|
||||
const MAX_PASSES: usize = 4;
|
||||
|
||||
fn positive(points: &[[f64; 3]], t: &[u32; 4]) -> Result<bool> {
|
||||
let p = |v: u32| -> Result<Point3> {
|
||||
let c = points.get(v as usize).copied().ok_or_else(|| Error::Failed("rounding: vertex id out of range".into()))?;
|
||||
Point3::try_from(c)
|
||||
};
|
||||
Ok(tet_volume_sign(&p(t[0])?, &p(t[1])?, &p(t[2])?, &p(t[3])?) == Sign::Positive)
|
||||
}
|
||||
|
||||
/// `x` moved by `k` ulps.
|
||||
fn step(mut x: f64, k: i32) -> f64 {
|
||||
for _ in 0..k.unsigned_abs() {
|
||||
x = if k > 0 { x.next_up() } else { x.next_down() };
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Offsets within [`REACH`], nearest (smallest total) first, in a fixed order.
|
||||
fn offsets() -> Vec<[i32; 3]> {
|
||||
let r = -REACH..=REACH;
|
||||
let mut o: Vec<[i32; 3]> = r.clone().flat_map(|a| r.clone().flat_map(move |b| (-REACH..=REACH).map(move |c| [a, b, c]))).collect();
|
||||
o.retain(|d| d != &[0, 0, 0]);
|
||||
o.sort_by_key(|d| (d.iter().map(|k| k.abs()).sum::<i32>(), *d));
|
||||
o
|
||||
}
|
||||
|
||||
/// Move Steiner points (ids `>= surface`) so every tetrahedron is positive where a few-ulp move
|
||||
/// does it. The number of points moved.
|
||||
pub(super) fn repair(points: &mut [[f64; 3]], tets: &[[u32; 4]], surface: usize) -> Result<usize> {
|
||||
let mut bad = Vec::new();
|
||||
for (i, t) in tets.iter().enumerate() {
|
||||
if !positive(points, t)? {
|
||||
bad.push(i);
|
||||
}
|
||||
}
|
||||
if bad.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut around: Vec<Vec<u32>> = vec![Vec::new(); points.len()];
|
||||
for (i, t) in tets.iter().enumerate() {
|
||||
for &v in t {
|
||||
if (v as usize) >= surface
|
||||
&& let Some(a) = around.get_mut(v as usize)
|
||||
{
|
||||
a.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let offsets = offsets();
|
||||
let mut moved = vec![false; points.len()];
|
||||
for _ in 0..MAX_PASSES {
|
||||
let mut still = Vec::new();
|
||||
for &i in &bad {
|
||||
let t = *tets.get(i).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
|
||||
if positive(points, &t)? {
|
||||
continue;
|
||||
}
|
||||
let mut fixed = false;
|
||||
for v in t.into_iter().filter(|&v| (v as usize) >= surface) {
|
||||
let Some(origin) = points.get(v as usize).copied() else { continue };
|
||||
let incident = around.get(v as usize).cloned().unwrap_or_default();
|
||||
for d in &offsets {
|
||||
let candidate: [f64; 3] = std::array::from_fn(|k| step(origin[k], d[k]));
|
||||
if candidate.iter().any(|x| !crate::predicates::is_admissible(*x)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = points.get_mut(v as usize) {
|
||||
*p = candidate;
|
||||
}
|
||||
let mut ok = true;
|
||||
for &j in &incident {
|
||||
let u = tets.get(j as usize).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
|
||||
if !positive(points, u)? {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
fixed = true;
|
||||
if let Some(m) = moved.get_mut(v as usize) {
|
||||
*m = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Some(p) = points.get_mut(v as usize) {
|
||||
*p = origin;
|
||||
}
|
||||
}
|
||||
if fixed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !fixed {
|
||||
still.push(i);
|
||||
}
|
||||
}
|
||||
if still.is_empty() || still.len() == bad.len() {
|
||||
break;
|
||||
}
|
||||
bad = still;
|
||||
}
|
||||
Ok(moved.iter().filter(|m| **m).count())
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::vec3::{cross, dot, norm, sub};
|
||||
|
||||
/// A closed surface from points and triangles, each triangle oriented outward from `centre`
|
||||
/// (the shapes here are star-shaped about it).
|
||||
fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
|
||||
pub(crate) fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
|
||||
let oriented: Vec<[u32; 3]> = triangles
|
||||
.iter()
|
||||
.map(|&[a, b, c]| {
|
||||
@@ -47,7 +47,7 @@ fn check(s: &BoundarySurface, m: &TetMesh) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
||||
pub(crate) fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
||||
let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, (i >> 1 & 1) as f64, (i >> 2 & 1) as f64]).collect();
|
||||
let quads = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]];
|
||||
let t = quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect();
|
||||
@@ -98,7 +98,7 @@ fn schonhardt_prism_gets_steiner_points_and_conforms() {
|
||||
}
|
||||
|
||||
/// A UV sphere: many cospherical vertices.
|
||||
fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
||||
pub(crate) fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
||||
let mut p = vec![[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]];
|
||||
for r in 1..rings {
|
||||
let th = std::f64::consts::PI * r as f64 / rings as f64;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
mod arrangement;
|
||||
mod brep;
|
||||
mod cells;
|
||||
mod geom;
|
||||
pub(crate) mod geom;
|
||||
mod interval;
|
||||
mod layered;
|
||||
mod mapped;
|
||||
@@ -583,7 +583,7 @@ fn finish(cells: &Cells, raw: Raw, method: HexMethod) -> Result<HexMesh> {
|
||||
|
||||
/// `∫ x n_x dA` over a quad4 or quad8 face (outward normal): its contribution to the enclosed
|
||||
/// volume. The 3 × 3 Gauss rule integrates it exactly (degree at most 5 per direction).
|
||||
fn face_flux(face: &[P]) -> f64 {
|
||||
pub(crate) fn face_flux(face: &[P]) -> f64 {
|
||||
const G: [(f64, f64); 3] = [(-0.774_596_669_241_483_4, 5.0 / 9.0), (0.0, 8.0 / 9.0), (0.774_596_669_241_483_4, 5.0 / 9.0)];
|
||||
let mut sum = 0.0;
|
||||
for (r, wr) in G {
|
||||
|
||||
@@ -12,11 +12,13 @@ pub mod cdt;
|
||||
pub mod delaunay;
|
||||
pub mod hex;
|
||||
pub mod join;
|
||||
pub mod octree;
|
||||
pub mod planar;
|
||||
pub mod quad;
|
||||
pub mod quality;
|
||||
pub mod size;
|
||||
pub mod surface;
|
||||
pub mod tet;
|
||||
mod vec3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
//! The boundary as the hex mesher sees it: a triangle tree for nearest-point, ray and box
|
||||
//! queries, the exact inside test, and the feature graph (corners, curves, patches).
|
||||
//!
|
||||
//! # Feature graph
|
||||
//! Every feature edge of the [`BoundarySurface`] (between two B-rep faces, or a crease inside a
|
||||
//! face) whose dihedral angle reaches the sharp angle is kept; smooth face boundaries (a fillet
|
||||
//! meeting its faces tangentially) are not features, the faces on either side share a patch.
|
||||
//! Neither are blades and slits, edges whose normals turn by more than the blade angle (a 3°
|
||||
//! wedge, a sphere touching a plane): no valid hexahedra can follow them, so the mesh rounds
|
||||
//! them within the boundary tolerance. *Corners* are vertices where the number of feature edges is not two, or where a
|
||||
//! curve turns by more than [`CORNER_TURN`]; *curves* are the chains of feature edges between
|
||||
//! corners, or closed loops; *patches* are the connected sets of triangles between feature edges,
|
||||
//! so every patch lies in one B-rep face and is bounded by curves. This is the partition the hex
|
||||
//! mesh's boundary must reproduce (Gao, Shen, Panozzo 2019, §3.3).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) use crate::hex::geom::closest_on_triangle;
|
||||
use crate::predicates::{Axis, Point3, Sign, orient2d_projected, orient3d, snap_to_domain};
|
||||
use crate::surface::BoundarySurface;
|
||||
use crate::vec3::{P, cross, dot, norm, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A degree-2 feature vertex whose curve turns by more than this (degrees) is a corner.
|
||||
pub(crate) const CORNER_TURN: f64 = 30.0;
|
||||
/// Triangles per tree leaf.
|
||||
const LEAF: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Aabb {
|
||||
pub lo: P,
|
||||
pub hi: P,
|
||||
}
|
||||
|
||||
impl Aabb {
|
||||
pub const EMPTY: Aabb = Aabb { lo: [f64::INFINITY; 3], hi: [f64::NEG_INFINITY; 3] };
|
||||
pub fn add(&mut self, p: P) {
|
||||
for ((lo, hi), x) in self.lo.iter_mut().zip(&mut self.hi).zip(p) {
|
||||
*lo = lo.min(x);
|
||||
*hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
fn join(&mut self, o: &Aabb) {
|
||||
self.add(o.lo);
|
||||
self.add(o.hi);
|
||||
}
|
||||
pub fn overlaps(&self, o: &Aabb) -> bool {
|
||||
(0..3).all(|k| self.lo[k] <= o.hi[k] && o.lo[k] <= self.hi[k])
|
||||
}
|
||||
/// Squared distance from a point to the box (0 inside).
|
||||
fn dist2(&self, p: P) -> f64 {
|
||||
(0..3).map(|k| (self.lo[k] - p[k]).max(0.0).max(p[k] - self.hi[k])).map(|d| d * d).sum()
|
||||
}
|
||||
/// Whether the segment `a + t (b − a)`, `t ∈ [0, 1]`, may meet the box (slab test, widened by
|
||||
/// a relative margin so rounding never drops a hit).
|
||||
fn meets_segment(&self, a: P, b: P) -> bool {
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
for k in 0..3 {
|
||||
let pad = 1e-9 * (self.hi[k] - self.lo[k]).abs().max(1e-300) + 1e-12 * (a[k].abs() + b[k].abs());
|
||||
let (lo, hi) = (self.lo[k] - pad, self.hi[k] + pad);
|
||||
let d = b[k] - a[k];
|
||||
if d == 0.0 {
|
||||
if a[k] < lo || a[k] > hi {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (mut u, mut v) = ((lo - a[k]) / d, (hi - a[k]) / d);
|
||||
if u > v {
|
||||
std::mem::swap(&mut u, &mut v);
|
||||
}
|
||||
t0 = t0.max(u);
|
||||
t1 = t1.min(v);
|
||||
if t0 > t1 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Node {
|
||||
bounds: Aabb,
|
||||
children: Option<usize>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// A median-split tree over triangle boxes.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Tree {
|
||||
nodes: Vec<Node>,
|
||||
order: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
fn build(boxes: &[Aabb]) -> Tree {
|
||||
let mut order: Vec<u32> = (0..boxes.len() as u32).collect();
|
||||
let empty = Node { bounds: Aabb::EMPTY, children: None, start: 0, end: 0 };
|
||||
let mut nodes = vec![empty];
|
||||
let mut work = vec![(0usize, 0usize, boxes.len())];
|
||||
while let Some((ni, start, end)) = work.pop() {
|
||||
let Some(slice) = order.get_mut(start..end) else { continue };
|
||||
let (mut bounds, mut centres) = (Aabb::EMPTY, Aabb::EMPTY);
|
||||
for &t in slice.iter() {
|
||||
if let Some(b) = boxes.get(t as usize) {
|
||||
bounds.join(b);
|
||||
centres.add([0, 1, 2].map(|k| b.lo[k] + b.hi[k]));
|
||||
}
|
||||
}
|
||||
let mut node = Node { bounds, children: None, start, end };
|
||||
if end - start > LEAF {
|
||||
let ext = [0, 1, 2].map(|k| centres.hi[k] - centres.lo[k]);
|
||||
let axis = if ext[0] >= ext[1] && ext[0] >= ext[2] {
|
||||
0
|
||||
} else if ext[1] >= ext[2] {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let key = |t: &u32| boxes.get(*t as usize).map_or(0.0, |b| b.lo[axis] + b.hi[axis]);
|
||||
let mid = (end - start) / 2;
|
||||
slice.select_nth_unstable_by(mid, |a, b| key(a).total_cmp(&key(b)).then(a.cmp(b)));
|
||||
let l = nodes.len();
|
||||
nodes.push(empty);
|
||||
nodes.push(empty);
|
||||
node.children = Some(l);
|
||||
work.push((l, start, start + mid));
|
||||
work.push((l + 1, start + mid, end));
|
||||
}
|
||||
if let Some(n) = nodes.get_mut(ni) {
|
||||
*n = node;
|
||||
}
|
||||
}
|
||||
Tree { nodes, order }
|
||||
}
|
||||
|
||||
fn leaf(&self, n: &Node) -> &[u32] {
|
||||
self.order.get(n.start..n.end).unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lerp(a: P, b: P, t: f64) -> P {
|
||||
[0, 1, 2].map(|k| a[k] + (b[k] - a[k]) * t)
|
||||
}
|
||||
|
||||
/// The closest point of segment `ab` to `p` and its parameter.
|
||||
pub(crate) fn closest_on_segment(p: P, a: P, b: P) -> (P, f64) {
|
||||
let ab = sub(b, a);
|
||||
let l2 = dot(ab, ab);
|
||||
let t = if l2 > 0.0 { (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(lerp(a, b, t), t)
|
||||
}
|
||||
|
||||
pub(crate) fn dist2(a: P, b: P) -> f64 {
|
||||
let d = sub(a, b);
|
||||
dot(d, d)
|
||||
}
|
||||
|
||||
/// A chain of feature edges.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Curve {
|
||||
/// Surface vertices in order; a closed loop repeats its first vertex at the end.
|
||||
pub vertices: Vec<u32>,
|
||||
pub closed: bool,
|
||||
/// The patches on either side (sorted, deduplicated: one entry for a crease seam).
|
||||
pub patches: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct Patch {
|
||||
pub triangles: Vec<u32>,
|
||||
/// The B-rep faces of its triangles (sorted, deduplicated; one for a well-formed boundary).
|
||||
pub faces: Vec<u32>,
|
||||
pub area: f64,
|
||||
/// Twice the area over the length of the bounding curves: the width of a strip, the
|
||||
/// diameter of a disc, `∞` for a closed surface without curves.
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// The boundary with its queries and feature graph.
|
||||
pub(crate) struct Geometry<'a> {
|
||||
pub surface: &'a BoundarySurface,
|
||||
tree: Tree,
|
||||
pub boxes: Vec<Aabb>,
|
||||
pub normals: Vec<P>,
|
||||
pub tri_patch: Vec<u32>,
|
||||
pub patches: Vec<Patch>,
|
||||
pub curves: Vec<Curve>,
|
||||
/// Corner vertices, ascending.
|
||||
pub corners: Vec<u32>,
|
||||
pub bounds: Aabb,
|
||||
/// Far enough to leave the boundary from any point of its box.
|
||||
far: f64,
|
||||
}
|
||||
|
||||
impl<'a> Geometry<'a> {
|
||||
pub fn new(surface: &'a BoundarySurface, sharp_angle: f64, blade_angle: f64) -> Result<Geometry<'a>> {
|
||||
let pts = surface.points();
|
||||
let tris = surface.triangles();
|
||||
let xyz = |v: u32| pts.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
let mut bounds = Aabb::EMPTY;
|
||||
let mut boxes = Vec::with_capacity(tris.len());
|
||||
let mut normals = Vec::with_capacity(tris.len());
|
||||
for t in tris {
|
||||
let mut b = Aabb::EMPTY;
|
||||
for &v in t {
|
||||
b.add(xyz(v));
|
||||
}
|
||||
bounds.join(&b);
|
||||
boxes.push(b);
|
||||
let n = cross(sub(xyz(t[1]), xyz(t[0])), sub(xyz(t[2]), xyz(t[0])));
|
||||
let l = norm(n);
|
||||
normals.push(if l > 0.0 { n.map(|c| c / l) } else { [0.0; 3] });
|
||||
}
|
||||
if tris.is_empty() || !(0..3).all(|k| bounds.hi[k] >= bounds.lo[k]) {
|
||||
return Err(Error::Invalid("the boundary has no triangles".into()));
|
||||
}
|
||||
let tree = Tree::build(&boxes);
|
||||
let diag = norm(sub(bounds.hi, bounds.lo));
|
||||
let far = 4.0 * diag.max(1e-9) + (0..3).map(|k| bounds.lo[k].abs().max(bounds.hi[k].abs())).fold(0.0, f64::max);
|
||||
|
||||
// Feature edges and corners.
|
||||
// A boundary between two B-rep faces is kept or dropped as a whole, by the median angle
|
||||
// along it, so tessellation noise never breaks a curve into pieces; creases inside a
|
||||
// face go edge by edge.
|
||||
let face_of = |t: u32| surface.tri_face().get(t as usize).copied().unwrap_or(0);
|
||||
let mut pair_angles: BTreeMap<(u32, u32), Vec<f64>> = BTreeMap::new();
|
||||
for e in surface.feature_edges() {
|
||||
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
|
||||
if a != b {
|
||||
pair_angles.entry((a.min(b), a.max(b))).or_default().push(e.angle);
|
||||
}
|
||||
}
|
||||
let pair_kept: BTreeMap<(u32, u32), bool> = pair_angles
|
||||
.into_iter()
|
||||
.map(|(k, mut v)| {
|
||||
v.sort_by(f64::total_cmp);
|
||||
let m = v.get(v.len() / 2).copied().unwrap_or(0.0);
|
||||
(k, m >= sharp_angle && m <= blade_angle)
|
||||
})
|
||||
.collect();
|
||||
let kept = |e: &&crate::surface::FeatureEdge| {
|
||||
let (a, b) = (face_of(e.triangles[0]), face_of(e.triangles[1]));
|
||||
if a != b { pair_kept.get(&(a.min(b), a.max(b))).copied().unwrap_or(false) } else { e.angle >= sharp_angle && e.angle <= blade_angle }
|
||||
};
|
||||
let mut edge_set: BTreeMap<(u32, u32), [u32; 2]> = BTreeMap::new();
|
||||
for e in surface.feature_edges().iter().filter(kept) {
|
||||
edge_set.insert((e.vertices[0], e.vertices[1]), e.triangles);
|
||||
}
|
||||
let mut adj: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||||
for &(a, b) in edge_set.keys() {
|
||||
adj.entry(a).or_default().push(b);
|
||||
adj.entry(b).or_default().push(a);
|
||||
}
|
||||
let mut corners: Vec<u32> = adj
|
||||
.iter()
|
||||
.filter(|(v, n)| {
|
||||
if n.len() != 2 {
|
||||
return true;
|
||||
}
|
||||
let (p, a, b) = (xyz(**v), xyz(n[0]), xyz(n[1]));
|
||||
let (u, w) = (sub(p, a), sub(b, p));
|
||||
crate::vec3::angle_deg(u, w) > CORNER_TURN
|
||||
})
|
||||
.map(|(v, _)| *v)
|
||||
.collect();
|
||||
corners.sort_unstable();
|
||||
|
||||
// Patches: flood fill across non-feature edges.
|
||||
let neighbors = surface.neighbors();
|
||||
let mut tri_patch = vec![u32::MAX; tris.len()];
|
||||
let mut patches: Vec<Patch> = Vec::new();
|
||||
for seed in 0..tris.len() {
|
||||
if tri_patch[seed] != u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = patches.len() as u32;
|
||||
let mut patch = Patch { triangles: Vec::new(), faces: Vec::new(), area: 0.0, width: f64::INFINITY };
|
||||
tri_patch[seed] = id;
|
||||
let mut stack = vec![seed as u32];
|
||||
while let Some(t) = stack.pop() {
|
||||
patch.triangles.push(t);
|
||||
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
|
||||
let n = neighbors.get(t as usize).copied().unwrap_or([u32::MAX; 3]);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (tri[k], tri[(k + 1) % 3]);
|
||||
if edge_set.contains_key(&(a.min(b), a.max(b))) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = tri_patch.get_mut(n[k] as usize)
|
||||
&& *slot == u32::MAX
|
||||
{
|
||||
*slot = id;
|
||||
stack.push(n[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.triangles.sort_unstable();
|
||||
for &t in &patch.triangles {
|
||||
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
|
||||
patch.area += 0.5 * norm(cross(sub(xyz(tri[1]), xyz(tri[0])), sub(xyz(tri[2]), xyz(tri[0]))));
|
||||
patch.faces.push(surface.tri_face().get(t as usize).copied().unwrap_or(0));
|
||||
}
|
||||
patch.faces.sort_unstable();
|
||||
patch.faces.dedup();
|
||||
let mut perimeter = 0.0;
|
||||
for &t in &patch.triangles {
|
||||
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (tri[k], tri[(k + 1) % 3]);
|
||||
if edge_set.contains_key(&(a.min(b), a.max(b))) {
|
||||
perimeter += norm(sub(xyz(b), xyz(a)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if perimeter > 0.0 {
|
||||
patch.width = 2.0 * patch.area / perimeter;
|
||||
}
|
||||
patches.push(patch);
|
||||
}
|
||||
|
||||
// Curves: chains from corners, then closed loops.
|
||||
let mut used: BTreeMap<(u32, u32), bool> = edge_set.keys().map(|k| (*k, false)).collect();
|
||||
let is_corner = |v: u32| corners.binary_search(&v).is_ok();
|
||||
let mut curves: Vec<Curve> = Vec::new();
|
||||
let trace = |start: u32, next: u32, used: &mut BTreeMap<(u32, u32), bool>, curves: &mut Vec<Curve>| {
|
||||
let mut verts = vec![start];
|
||||
let (mut prev, mut cur) = (start, next);
|
||||
loop {
|
||||
let key = (prev.min(cur), prev.max(cur));
|
||||
if let Some(u) = used.get_mut(&key) {
|
||||
*u = true;
|
||||
}
|
||||
verts.push(cur);
|
||||
if cur == start || is_corner(cur) || verts.len() > edge_set.len() + 2 {
|
||||
break;
|
||||
}
|
||||
let nxt = adj.get(&cur).and_then(|n| n.iter().copied().find(|&w| w != prev && used.get(&(cur.min(w), cur.max(w))) == Some(&false)));
|
||||
match nxt {
|
||||
Some(w) => {
|
||||
prev = cur;
|
||||
cur = w;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let closed = verts.first() == verts.last() && verts.len() > 2;
|
||||
let mut ps: Vec<u32> = Vec::new();
|
||||
for w in verts.windows(2) {
|
||||
if let Some(t) = edge_set.get(&(w[0].min(w[1]), w[0].max(w[1]))) {
|
||||
ps.extend(t.iter().filter_map(|&t| tri_patch.get(t as usize).copied()));
|
||||
}
|
||||
}
|
||||
ps.sort_unstable();
|
||||
ps.dedup();
|
||||
curves.push(Curve { vertices: verts, closed, patches: ps });
|
||||
};
|
||||
for &c in &corners {
|
||||
let ns = adj.get(&c).cloned().unwrap_or_default();
|
||||
for n in ns {
|
||||
if used.get(&(c.min(n), c.max(n))) == Some(&false) {
|
||||
trace(c, n, &mut used, &mut curves);
|
||||
}
|
||||
}
|
||||
}
|
||||
let keys: Vec<(u32, u32)> = used.keys().copied().collect();
|
||||
for k in keys {
|
||||
if used.get(&k) == Some(&false) {
|
||||
trace(k.0, k.1, &mut used, &mut curves);
|
||||
}
|
||||
}
|
||||
Ok(Geometry { surface, tree, boxes, normals, tri_patch, patches, curves, corners, bounds, far })
|
||||
}
|
||||
|
||||
pub fn xyz(&self, v: u32) -> P {
|
||||
self.surface.points().get(v as usize).copied().unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
/// The centroid of triangle `t`.
|
||||
pub fn triangle_centre(&self, t: u32) -> P {
|
||||
let [a, b, c] = self.triangle(t);
|
||||
[0, 1, 2].map(|k| (a[k] + b[k] + c[k]) / 3.0)
|
||||
}
|
||||
|
||||
pub fn triangle(&self, t: u32) -> [P; 3] {
|
||||
let tri = self.surface.triangles().get(t as usize).copied().unwrap_or([0; 3]);
|
||||
tri.map(|v| self.xyz(v))
|
||||
}
|
||||
|
||||
/// The closest boundary point to `p` among triangles that pass `keep`: (triangle, point,
|
||||
/// squared distance).
|
||||
pub fn nearest(&self, p: P, keep: impl Fn(u32) -> bool) -> Option<(u32, P, f64)> {
|
||||
let mut best: Option<(u32, P, f64)> = None;
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
let Some(node) = self.tree.nodes.get(n) else { continue };
|
||||
let bd = node.bounds.dist2(p);
|
||||
if best.is_some_and(|b| bd >= b.2) {
|
||||
continue;
|
||||
}
|
||||
match node.children {
|
||||
Some(l) => {
|
||||
let (dl, dr) = (
|
||||
self.tree.nodes.get(l).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
|
||||
self.tree.nodes.get(l + 1).map_or(f64::INFINITY, |x| x.bounds.dist2(p)),
|
||||
);
|
||||
if dl <= dr {
|
||||
stack.extend([l + 1, l]);
|
||||
} else {
|
||||
stack.extend([l, l + 1]);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for &t in self.tree.leaf(node) {
|
||||
if !keep(t) {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c] = self.triangle(t);
|
||||
let q = closest_on_triangle(p, a, b, c);
|
||||
let d = dist2(p, q);
|
||||
if best.is_none_or(|x| d < x.2 || (d == x.2 && t < x.0)) {
|
||||
best = Some((t, q, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The triangles whose boxes overlap `b`, ascending.
|
||||
pub fn overlapping(&self, b: &Aabb) -> Vec<u32> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
let Some(node) = self.tree.nodes.get(n) else { continue };
|
||||
if !node.bounds.overlaps(b) {
|
||||
continue;
|
||||
}
|
||||
match node.children {
|
||||
Some(l) => stack.extend([l, l + 1]),
|
||||
None => out.extend(self.tree.leaf(node).iter().copied().filter(|&t| self.boxes.get(t as usize).is_some_and(|x| x.overlaps(b)))),
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The first triangle hit by the ray from `p` along `d` (unit) within `max`, with its
|
||||
/// distance (floating point; used to measure, never to decide).
|
||||
pub fn ray(&self, p: P, d: P, max: f64, skip: u32) -> Option<(u32, f64)> {
|
||||
let q = [0, 1, 2].map(|k| p[k] + d[k] * max);
|
||||
let mut best: Option<(u32, f64)> = None;
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
let Some(node) = self.tree.nodes.get(n) else { continue };
|
||||
if !node.bounds.meets_segment(p, q) {
|
||||
continue;
|
||||
}
|
||||
match node.children {
|
||||
Some(l) => stack.extend([l, l + 1]),
|
||||
None => {
|
||||
for &t in self.tree.leaf(node) {
|
||||
if t == skip {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c] = self.triangle(t);
|
||||
if let Some(s) = ray_triangle(p, d, a, b, c)
|
||||
&& s > 0.0
|
||||
&& s <= max
|
||||
&& best.is_none_or(|x| s < x.1)
|
||||
{
|
||||
best = Some((t, s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Whether `p` is strictly inside the solid, decided exactly: the parity of the boundary
|
||||
/// crossings of a segment from `p` to a far point, tried along fixed directions until one
|
||||
/// meets no edge or vertex and does not start on the boundary. `None` when every direction is
|
||||
/// degenerate (p lies on the boundary) or `p` is not admissible.
|
||||
pub fn inside(&self, p: P) -> Option<bool> {
|
||||
const DIRS: [P; 7] = [
|
||||
[0.5773, 0.6123, 0.5401],
|
||||
[-0.6301, 0.4372, 0.6417],
|
||||
[0.3989, -0.7068, 0.5839],
|
||||
[0.7211, 0.4109, -0.5577],
|
||||
[-0.4402, -0.6005, -0.6675],
|
||||
[0.1351, 0.9013, -0.4116],
|
||||
[-0.8803, 0.1237, 0.4583],
|
||||
];
|
||||
let pp = Point3::new(snap_to_domain(p[0]), snap_to_domain(p[1]), snap_to_domain(p[2])).ok()?;
|
||||
'dirs: for d in DIRS {
|
||||
let q = [0, 1, 2].map(|k| snap_to_domain(p[k] + d[k] * self.far));
|
||||
let Ok(qq) = Point3::new(q[0], q[1], q[2]) else { continue };
|
||||
let mut parity = false;
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
let Some(node) = self.tree.nodes.get(n) else { continue };
|
||||
if !node.bounds.meets_segment(p, q) {
|
||||
continue;
|
||||
}
|
||||
match node.children {
|
||||
Some(l) => stack.extend([l, l + 1]),
|
||||
None => {
|
||||
for &t in self.tree.leaf(node) {
|
||||
match self.crossing(&pp, &qq, t) {
|
||||
Some(true) => parity = !parity,
|
||||
Some(false) => {}
|
||||
None => continue 'dirs,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(parity);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether segment `pq` crosses triangle `t` properly (`Some(true)`), misses it
|
||||
/// (`Some(false)`), or touches it degenerately (`None`): exact.
|
||||
fn crossing(&self, p: &Point3, q: &Point3, t: u32) -> Option<bool> {
|
||||
let tri = self.surface.triangles().get(t as usize)?;
|
||||
let a = self.surface.point3(tri[0])?;
|
||||
let b = self.surface.point3(tri[1])?;
|
||||
let c = self.surface.point3(tri[2])?;
|
||||
let (sp, sq) = (orient3d(&a, &b, &c, p), orient3d(&a, &b, &c, q));
|
||||
if sq.is_zero() {
|
||||
// The far end is outside the boundary's box, so never on a triangle; a segment in
|
||||
// the plane is ambiguous.
|
||||
return None;
|
||||
}
|
||||
if sp.is_zero() {
|
||||
// p in the triangle's plane: the segment leaves the plane at p, so it touches the
|
||||
// triangle only if p lies in it.
|
||||
let axis = [Axis::Z, Axis::X, Axis::Y].into_iter().find(|&ax| !orient2d_projected(&a, &b, &c, ax).is_zero())?;
|
||||
let o = orient2d_projected(&a, &b, &c, axis);
|
||||
let inside = [(&a, &b), (&b, &c), (&c, &a)].into_iter().all(|(u, v)| orient2d_projected(u, v, p, axis) != -o);
|
||||
return if inside { None } else { Some(false) };
|
||||
}
|
||||
if sp == sq {
|
||||
return Some(false);
|
||||
}
|
||||
let s = [orient3d(p, q, &a, &b), orient3d(p, q, &b, &c), orient3d(p, q, &c, &a)];
|
||||
if s.iter().any(|x| x.is_zero()) {
|
||||
let nz: Vec<Sign> = s.iter().copied().filter(|x| !x.is_zero()).collect();
|
||||
return if nz.windows(2).all(|w| w[0] == w[1]) { None } else { Some(false) };
|
||||
}
|
||||
Some(s[0] == s[1] && s[1] == s[2])
|
||||
}
|
||||
|
||||
/// The closest point of curve `c` to `p`: (point, segment index, parameter on it).
|
||||
pub fn nearest_on_curve(&self, c: u32, p: P) -> Option<(P, usize, f64)> {
|
||||
let curve = self.curves.get(c as usize)?;
|
||||
let mut best: Option<(P, usize, f64, f64)> = None;
|
||||
for (i, w) in curve.vertices.windows(2).enumerate() {
|
||||
let (q, t) = closest_on_segment(p, self.xyz(w[0]), self.xyz(w[1]));
|
||||
let d = dist2(p, q);
|
||||
if best.is_none_or(|b| d < b.3) {
|
||||
best = Some((q, i, t, d));
|
||||
}
|
||||
}
|
||||
best.map(|(q, i, t, _)| (q, i, t))
|
||||
}
|
||||
}
|
||||
|
||||
/// Möller–Trumbore ray–triangle distance (floating point).
|
||||
fn ray_triangle(o: P, d: P, a: P, b: P, c: P) -> Option<f64> {
|
||||
let (e1, e2) = (sub(b, a), sub(c, a));
|
||||
let h = cross(d, e2);
|
||||
let det = dot(e1, h);
|
||||
if det.abs() < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
let inv = 1.0 / det;
|
||||
let s = sub(o, a);
|
||||
let u = inv * dot(s, h);
|
||||
if !(-1e-12..=1.0 + 1e-12).contains(&u) {
|
||||
return None;
|
||||
}
|
||||
let q = cross(s, e1);
|
||||
let v = inv * dot(d, q);
|
||||
if v < -1e-12 || u + v > 1.0 + 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some(inv * dot(e2, q))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::surface::SurfaceOptions;
|
||||
|
||||
/// A closed axis-aligned box surface, one B-rep face per side.
|
||||
pub(crate) fn box_surface(lo: P, hi: P) -> BoundarySurface {
|
||||
let c = |i: usize| [if i & 1 == 0 { lo[0] } else { hi[0] }, if i & 2 == 0 { lo[1] } else { hi[1] }, if i & 4 == 0 { lo[2] } else { hi[2] }];
|
||||
let pts: Vec<P> = (0..8).map(c).collect();
|
||||
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
|
||||
let mut tris = Vec::new();
|
||||
let mut face = Vec::new();
|
||||
for (f, q) in quads.iter().enumerate() {
|
||||
tris.push([q[0], q[1], q[2]]);
|
||||
tris.push([q[0], q[2], q[3]]);
|
||||
face.extend([f as u32; 2]);
|
||||
}
|
||||
BoundarySurface::new(&pts, &tris, &face, &SurfaceOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_feature_graph_inside_and_nearest() {
|
||||
let s = box_surface([0.0; 3], [2.0, 3.0, 4.0]);
|
||||
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
|
||||
assert_eq!(g.corners.len(), 8);
|
||||
assert_eq!(g.curves.len(), 12);
|
||||
assert_eq!(g.patches.len(), 6);
|
||||
assert!(g.curves.iter().all(|c| c.vertices.len() == 2 && c.patches.len() == 2 && !c.closed));
|
||||
assert_eq!(g.inside([1.0, 1.5, 2.0]), Some(true));
|
||||
assert_eq!(g.inside([3.0, 1.5, 2.0]), Some(false));
|
||||
assert_eq!(g.inside([1.0, 1.5, 0.0]), None, "on the boundary");
|
||||
assert_eq!(g.inside([0.0, 0.0, 0.0]), None, "a corner");
|
||||
let (_, q, d) = g.nearest([1.0, 1.0, -1.0], |_| true).unwrap();
|
||||
assert!((d - 1.0).abs() < 1e-12 && q[2] == 0.0);
|
||||
let top = g.tri_patch[2];
|
||||
let (_, q, _) = g.nearest([1.0, 1.0, -1.0], |t| g.tri_patch[t as usize] == top).unwrap();
|
||||
assert_eq!(q[2], 4.0, "restricted to the top patch");
|
||||
let (t, s) = g.ray([1.0, 1.5, 2.0], [0.0, 0.0, 1.0], 10.0, u32::MAX).unwrap();
|
||||
assert!((s - 2.0).abs() < 1e-12 && g.tri_patch[t as usize] == top);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! The adaptive grid: a box of root cubes, each refined as a 27-tree (a leaf splits into
|
||||
//! 3 × 3 × 3), strongly balanced and closed under the template patterns, then cut into a
|
||||
//! conforming hexahedral mesh by [`super::templates`].
|
||||
//!
|
||||
//! # Balance and closure
|
||||
//! *Strong balance*: leaves that share a vertex differ by at most one level, so a leaf's finer
|
||||
//! neighbours are exactly one level finer and a leaf next to a coarser one never has a finer one
|
||||
//! on that side. A vertex of a leaf at level `L` is *marked* when a finer leaf touches it, that
|
||||
//! is, when one of the eight level-`L` cells around the vertex is split. *Closure*: a leaf whose
|
||||
//! marks fit no template (two diagonal corners of a face, three of a face, …) is split too.
|
||||
//! Balancing and closing repeat until neither splits anything; both only split, so this ends,
|
||||
//! and every round runs over the leaves in sorted order, so the result is the same on every run.
|
||||
//!
|
||||
//! # Coordinates
|
||||
//! Everything is integer. A cell at level `L` has index `i` (per axis, in units of its own edge)
|
||||
//! and spans `6 · 3^(D − L)` lattice units, `D` the deepest level allowed; template vertices are
|
||||
//! in sixths of a leaf, so every vertex is a lattice point and vertices shared by neighbours are
|
||||
//! found by exact lookup.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::templates::{self, Pattern, Sym};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A cell: its level and index (in units of its edge at that level).
|
||||
pub(crate) type Key = (u8, [i64; 3]);
|
||||
|
||||
/// The deepest level a tree may have (`3^12` lattice steps per root cell edge, × 6).
|
||||
pub(crate) const MAX_LEVEL: u8 = 12;
|
||||
|
||||
fn pow3(n: u8) -> i64 {
|
||||
3i64.pow(u32::from(n))
|
||||
}
|
||||
|
||||
/// What covers a cell-sized region at some level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Cover {
|
||||
/// A leaf (at that level or coarser).
|
||||
Leaf(Key),
|
||||
/// The region is split at that level (finer leaves inside).
|
||||
Split,
|
||||
/// Outside the root box.
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// The tree. `nodes` maps every cell that exists to whether it is a leaf.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Tree {
|
||||
/// Root cubes per axis.
|
||||
pub roots: [i64; 3],
|
||||
/// The deepest level allowed.
|
||||
pub depth: u8,
|
||||
nodes: HashMap<Key, bool>,
|
||||
leaves: usize,
|
||||
max_leaves: usize,
|
||||
}
|
||||
|
||||
/// The sort order of [`Tree::sorted_leaves`]: level, then z, y, x.
|
||||
fn order(k: &Key) -> (u8, i64, i64, i64) {
|
||||
(k.0, k.1[2], k.1[1], k.1[0])
|
||||
}
|
||||
|
||||
/// The 26 neighbour offsets, in a fixed order.
|
||||
fn offsets() -> impl Iterator<Item = [i64; 3]> {
|
||||
(0..27).map(|k| [k % 3 - 1, (k / 3) % 3 - 1, k / 9 - 1]).filter(|d| *d != [0, 0, 0])
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
/// One leaf per root cube.
|
||||
pub fn new(roots: [i64; 3], depth: u8, max_leaves: usize) -> Result<Tree> {
|
||||
if roots.iter().any(|&n| n < 1) || depth > MAX_LEVEL {
|
||||
return Err(Error::Invalid(format!("grid of {roots:?} root cells to depth {depth}")));
|
||||
}
|
||||
let count = roots.iter().try_fold(1usize, |a, &n| usize::try_from(n).ok().and_then(|n| a.checked_mul(n)));
|
||||
let count = count.filter(|&c| c <= max_leaves).ok_or_else(|| Error::Limit(format!("{roots:?} root cells exceed {max_leaves} cells")))?;
|
||||
let mut nodes = HashMap::with_capacity(count);
|
||||
for z in 0..roots[2] {
|
||||
for y in 0..roots[1] {
|
||||
for x in 0..roots[0] {
|
||||
nodes.insert((0, [x, y, z]), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Tree { roots, depth, nodes, leaves: count, max_leaves })
|
||||
}
|
||||
|
||||
pub fn leaf_count(&self) -> usize {
|
||||
self.leaves
|
||||
}
|
||||
|
||||
/// Whether `key` is a leaf.
|
||||
pub fn is_leaf(&self, key: &Key) -> bool {
|
||||
self.nodes.get(key).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn inside(&self, (level, i): Key) -> bool {
|
||||
let n = pow3(level);
|
||||
(0..3).all(|k| i[k] >= 0 && i[k] < self.roots[k] * n)
|
||||
}
|
||||
|
||||
/// What covers the level-`L` cell `key`.
|
||||
fn cover(&self, key: Key) -> Cover {
|
||||
if !self.inside(key) {
|
||||
return Cover::Outside;
|
||||
}
|
||||
let (level, i) = key;
|
||||
for l in (0..=level).rev() {
|
||||
let s = pow3(level - l);
|
||||
let k = (l, [i[0].div_euclid(s), i[1].div_euclid(s), i[2].div_euclid(s)]);
|
||||
match self.nodes.get(&k) {
|
||||
Some(true) => return Cover::Leaf(k),
|
||||
Some(false) => return Cover::Split,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Cover::Outside
|
||||
}
|
||||
|
||||
/// Split a leaf into its 27 children.
|
||||
pub fn split(&mut self, key: Key) -> Result<()> {
|
||||
let level = key.0;
|
||||
if !self.is_leaf(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
if level >= self.depth {
|
||||
return Err(Error::Limit(format!("refinement needs more than {} levels", self.depth)));
|
||||
}
|
||||
if self.leaves + 26 > self.max_leaves {
|
||||
return Err(Error::Limit(format!("the grid needs more than {} cells", self.max_leaves)));
|
||||
}
|
||||
self.nodes.insert(key, false);
|
||||
for child in Self::children(key) {
|
||||
self.nodes.insert(child, true);
|
||||
}
|
||||
self.leaves += 26;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The leaves in sorted order (level, then z, y, x).
|
||||
pub fn sorted_leaves(&self) -> Vec<Key> {
|
||||
let mut v: Vec<Key> = self.nodes.iter().filter(|(_, leaf)| **leaf).map(|(k, _)| *k).collect();
|
||||
v.sort_by_key(order);
|
||||
v
|
||||
}
|
||||
|
||||
/// The children of a split cell.
|
||||
pub fn children(key: Key) -> impl Iterator<Item = Key> {
|
||||
let (level, i) = key;
|
||||
(0..27).map(move |k| (level + 1, [3 * i[0] + k % 3, 3 * i[1] + (k / 3) % 3, 3 * i[2] + k / 9]))
|
||||
}
|
||||
|
||||
/// Split until leaves sharing a vertex differ by at most one level, checking only the leaves
|
||||
/// `work` (in sorted order) and the children of the cells it splits: a split only makes
|
||||
/// neighbours finer, so only new leaves can be too fine for a neighbour, and the result is
|
||||
/// the unique coarsest balanced refinement whatever the order. Every split cell goes to `log`.
|
||||
fn balance_from(&mut self, mut work: Vec<Key>, log: &mut Vec<Key>) -> Result<()> {
|
||||
work.reverse();
|
||||
while let Some(key) = work.pop() {
|
||||
if !self.is_leaf(&key) || key.0 < 2 {
|
||||
continue;
|
||||
}
|
||||
let (level, i) = key;
|
||||
for d in offsets() {
|
||||
if let Cover::Leaf(k) = self.cover((level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]]))
|
||||
&& k.0 + 1 < level
|
||||
{
|
||||
self.split(k)?;
|
||||
log.push(k);
|
||||
let mut kids: Vec<Key> = Self::children(k).collect();
|
||||
kids.reverse();
|
||||
work.extend(kids);
|
||||
work.push(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The leaves whose marks a split of `key` may change: its children and the leaves beside it
|
||||
/// on its level.
|
||||
fn touched_by(&self, key: Key, out: &mut Vec<Key>) {
|
||||
out.extend(Self::children(key));
|
||||
let (level, i) = key;
|
||||
out.extend(offsets().map(|d| (level, [i[0] + d[0], i[1] + d[1], i[2] + d[2]])).filter(|k| self.is_leaf(k)));
|
||||
}
|
||||
|
||||
/// The marked corners of a leaf (bit `c` for corner `c`: bit 0 x, bit 1 y, bit 2 z).
|
||||
pub fn marks(&self, (level, i): Key) -> u8 {
|
||||
let mut mask = 0u8;
|
||||
for c in 0..8usize {
|
||||
let o = [(c & 1) as i64, ((c >> 1) & 1) as i64, ((c >> 2) & 1) as i64];
|
||||
let split = (0..8usize).any(|e| {
|
||||
let n = [i[0] + o[0] - 1 + (e & 1) as i64, i[1] + o[1] - 1 + ((e >> 1) & 1) as i64, i[2] + o[2] - 1 + ((e >> 2) & 1) as i64];
|
||||
self.nodes.get(&(level, n)) == Some(&false)
|
||||
});
|
||||
if split {
|
||||
mask |= 1 << c;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Balance, then split every leaf whose marks fit no template (and every leaf marked at all
|
||||
/// eight corners, whose template is the split), until nothing changes. `max_rounds` bounds the
|
||||
/// rounds. Each round re-checks only the leaves the previous splits can have changed, so a
|
||||
/// round costs what it splits, not the size of the tree.
|
||||
pub fn close(&mut self, max_rounds: usize, skip: &dyn Fn(Key) -> bool) -> Result<()> {
|
||||
let mut fresh = self.sorted_leaves();
|
||||
let mut touched = fresh.clone();
|
||||
for _ in 0..max_rounds {
|
||||
let mut log = Vec::new();
|
||||
self.balance_from(std::mem::take(&mut fresh), &mut log)?;
|
||||
for &k in &log {
|
||||
self.touched_by(k, &mut touched);
|
||||
}
|
||||
touched.retain(|k| self.is_leaf(k));
|
||||
touched.sort_by_key(order);
|
||||
touched.dedup();
|
||||
let bad: Vec<Key> = touched
|
||||
.drain(..)
|
||||
.filter(|&k| !skip(k))
|
||||
.filter(|&k| !matches!(templates::classify(self.marks(k)), Some(Pattern::None | Pattern::Corner | Pattern::Edge | Pattern::Face)))
|
||||
.collect();
|
||||
if bad.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for &k in &bad {
|
||||
self.split(k)?;
|
||||
}
|
||||
for &k in &bad {
|
||||
fresh.extend(Self::children(k));
|
||||
self.touched_by(k, &mut touched);
|
||||
}
|
||||
fresh.sort_by_key(order);
|
||||
}
|
||||
Err(Error::Limit(format!("grid closure did not settle in {max_rounds} rounds")))
|
||||
}
|
||||
|
||||
/// Lattice units per edge of a level-`L` cell.
|
||||
pub fn span(&self, level: u8) -> i64 {
|
||||
6 * pow3(self.depth - level)
|
||||
}
|
||||
|
||||
/// Cut every leaf by its template into a conforming mesh on the integer lattice. Fails if a
|
||||
/// leaf's marks fit no template (call [`Tree::close`] first).
|
||||
pub fn mesh(&self, skip: &dyn Fn(Key) -> bool) -> Result<LatticeMesh> {
|
||||
let syms: Vec<Sym> = templates::symmetries();
|
||||
let canon: Vec<(Pattern, Vec<templates::Hex>)> =
|
||||
[Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face].into_iter().map(|p| (p, templates::template(p))).collect();
|
||||
let mut out = LatticeMesh::default();
|
||||
let mut index: HashMap<[i64; 3], u32> = HashMap::new();
|
||||
for key in self.sorted_leaves() {
|
||||
if skip(key) {
|
||||
continue;
|
||||
}
|
||||
let mask = self.marks(key);
|
||||
let pattern = templates::classify(mask).filter(|p| *p != Pattern::Full);
|
||||
let (pattern, sym) = pattern
|
||||
.and_then(|p| templates::orientation(&syms, p, mask).map(|s| (p, s)))
|
||||
.ok_or_else(|| Error::Failed(format!("grid cell {key:?} has marks {mask:#010b} that fit no template")))?;
|
||||
let hexes = canon.iter().find(|(p, _)| *p == pattern).map(|(_, h)| h.as_slice()).unwrap_or(&[]);
|
||||
let (level, i) = key;
|
||||
let unit = pow3(self.depth - level);
|
||||
for h in hexes {
|
||||
let mut g = h.map(|p| {
|
||||
let q = sym.apply(p);
|
||||
[0, 1, 2].map(|k| (6 * i[k] + q[k]) * unit)
|
||||
});
|
||||
if templates::corner0_orientation(&g) < 0 {
|
||||
g = templates::reversed(g);
|
||||
}
|
||||
let mut ids = [0u32; 8];
|
||||
for (id, p) in ids.iter_mut().zip(g) {
|
||||
*id = match index.get(&p) {
|
||||
Some(&v) => v,
|
||||
None => {
|
||||
let v = u32::try_from(out.points.len()).map_err(|_| Error::Limit("too many grid points".into()))?;
|
||||
index.insert(p, v);
|
||||
out.points.push(p);
|
||||
v
|
||||
}
|
||||
};
|
||||
}
|
||||
out.hexes.push(ids);
|
||||
out.level.push(level);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A conforming hexahedral mesh on the integer lattice.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct LatticeMesh {
|
||||
pub points: Vec<[i64; 3]>,
|
||||
pub hexes: Vec<[u32; 8]>,
|
||||
/// The tree level of the leaf each hexahedron came from.
|
||||
pub level: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
use crate::quality::{Validity, element_validity};
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
/// A tiny deterministic generator for test trees.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
}
|
||||
|
||||
fn check_conforming(tree: &Tree) {
|
||||
let m = tree.mesh(&|_| false).unwrap();
|
||||
let mut faces: HashMap<[u32; 4], usize> = HashMap::new();
|
||||
let mut volume = 0.0;
|
||||
for h in &m.hexes {
|
||||
let xyz: Vec<[f64; 3]> = h.iter().map(|&v| m.points[v as usize].map(|c| c as f64)).collect();
|
||||
let c = element_validity(ElementKind::Hex8, &xyz).unwrap();
|
||||
assert_eq!(c.validity, Validity::Valid);
|
||||
volume += c.volume;
|
||||
for f in ElementKind::Hex8.faces() {
|
||||
let mut q = [h[f[0]], h[f[1]], h[f[2]], h[f[3]]];
|
||||
q.sort();
|
||||
*faces.entry(q).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let side = tree.span(0) as f64;
|
||||
let expected = side.powi(3) * (tree.roots[0] * tree.roots[1] * tree.roots[2]) as f64;
|
||||
assert!((volume - expected).abs() < 1e-6 * expected, "volume {volume} vs {expected}");
|
||||
let hi = tree.roots.map(|n| n * tree.span(0));
|
||||
for (q, n) in faces {
|
||||
let on_box = (0..3).any(|k| q.iter().all(|&v| m.points[v as usize][k] == 0) || q.iter().all(|&v| m.points[v as usize][k] == hi[k]));
|
||||
assert_eq!(n, if on_box { 1 } else { 2 }, "face {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_trees_close_into_conforming_valid_meshes() {
|
||||
let mut rng = Lcg(7);
|
||||
for case in 0..12 {
|
||||
let roots = [1 + (rng.next() % 2) as i64, 1 + (rng.next() % 2) as i64, 1];
|
||||
let mut tree = Tree::new(roots, 3, 1_000_000).unwrap();
|
||||
for _ in 0..(1 + rng.next() % 6) {
|
||||
let leaves = tree.sorted_leaves();
|
||||
let k = leaves[(rng.next() as usize) % leaves.len()];
|
||||
if k.0 < 3 {
|
||||
tree.split(k).unwrap();
|
||||
}
|
||||
}
|
||||
tree.close(100, &|_| false).unwrap();
|
||||
for k in tree.sorted_leaves() {
|
||||
assert!(templates::classify(tree.marks(k)).is_some(), "case {case}");
|
||||
}
|
||||
check_conforming(&tree);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_are_errors() {
|
||||
assert!(Tree::new([0, 1, 1], 2, 10).is_err());
|
||||
assert!(Tree::new([10, 10, 10], 2, 999).is_err());
|
||||
let mut t = Tree::new([1, 1, 1], 1, 100).unwrap();
|
||||
t.split((0, [0, 0, 0])).unwrap();
|
||||
assert!(t.split((1, [0, 0, 0])).is_err(), "deeper than the depth");
|
||||
let mut t = Tree::new([1, 1, 1], 3, 30).unwrap();
|
||||
t.split((0, [0, 0, 0])).unwrap();
|
||||
assert!(t.split((1, [0, 0, 0])).is_err(), "more cells than allowed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! The topological map from the boundary's feature graph (corners, curves, patches) onto the
|
||||
//! core's boundary quads, after X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
|
||||
//! hexahedral meshing*, CGF 38(5), 2019, §3.3 (paper only; the authors' code has no licence and
|
||||
//! is not read). Gao et al. trace the curves first and read the patches off them; here the
|
||||
//! patches come first and the curves are their borders, which keeps every region on the side of
|
||||
//! the part it faces:
|
||||
//!
|
||||
//! 1. **Label.** Each quad takes the patch its outward normal meets first (within two quad
|
||||
//! sizes), or else the patch nearest its centre.
|
||||
//! 2. **Clean.** A quad whose label differs from all its edge neighbours takes their majority
|
||||
//! label; then every patch keeps only its largest connected group of quads, the other
|
||||
//! groups joining the label they border most. Both repeat a bounded number of times.
|
||||
//! 3. **Check.** The map must be a bijection: every patch has a region, and two regions border
|
||||
//! each other exactly where the part has a curve between their patches.
|
||||
//! 4. **Features.** Region borders are the curves (each border edge takes the curve between its
|
||||
//! two patches nearest to it); a vertex where three or more regions meet is a corner and takes
|
||||
//! the nearest corner of the part around those patches. Corners that are not region junctions
|
||||
//! (where one curve turns sharply) go to the nearest vertex on their curve's border.
|
||||
//!
|
||||
//! Any failure names where the grid is too coarse for the features; the caller refines there and
|
||||
//! tries again (§3.3, "Bijective map").
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::geom::{Geometry, closest_on_segment, dist2};
|
||||
use super::smooth::Constraint;
|
||||
use super::topo::quad_edges;
|
||||
use crate::vec3::{P, cross, norm, sub};
|
||||
|
||||
/// Rounds of label cleaning.
|
||||
const CLEAN_ROUNDS: usize = 16;
|
||||
|
||||
/// Where and why the map failed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MapFailure {
|
||||
pub at: Vec<P>,
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
/// The map: a constraint per quad vertex and a patch per quad.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct FeatureMap {
|
||||
pub role: BTreeMap<u32, Constraint>,
|
||||
pub quad_patch: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Map the feature graph onto `quads` (the core's boundary, outward) over `xyz`.
|
||||
pub(crate) fn map_features(g: &Geometry, quads: &[[u32; 4]], xyz: &dyn Fn(u32) -> P) -> Result<FeatureMap, MapFailure> {
|
||||
let n = quads.len();
|
||||
let centre = |q: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
for &v in &quads[q] {
|
||||
let p = xyz(v);
|
||||
for k in 0..3 {
|
||||
c[k] += 0.25 * p[k];
|
||||
}
|
||||
}
|
||||
c
|
||||
};
|
||||
let edges = quad_edges(quads);
|
||||
let mut nbr: Vec<Vec<u32>> = vec![Vec::new(); n];
|
||||
for qs in edges.values() {
|
||||
if let [a, b] = qs.as_slice() {
|
||||
nbr[*a as usize].push(*b);
|
||||
nbr[*b as usize].push(*a);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Label: the patch the quad faces (a ray along its outward normal), else the nearest.
|
||||
let mut label: Vec<u32> = (0..n)
|
||||
.map(|q| {
|
||||
let c = centre(q);
|
||||
let p = quads[q].map(xyz);
|
||||
let nrm = cross(sub(p[2], p[0]), sub(p[3], p[1]));
|
||||
let l = norm(nrm);
|
||||
let reach = 2.0 * (norm(sub(p[2], p[0])) + norm(sub(p[3], p[1])));
|
||||
let hit = if l > 0.0 { g.ray(c, nrm.map(|x| x / l), reach, u32::MAX).map(|(t, _)| t) } else { None };
|
||||
hit.or_else(|| g.nearest(c, |_| true).map(|(t, _, _)| t)).and_then(|t| g.tri_patch.get(t as usize).copied()).unwrap_or(0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// A patch no quad faces (a narrow face the core's boundary passes by) takes the quad
|
||||
// nearest to it, which the cleaning keeps.
|
||||
let mut seeded: BTreeSet<usize> = BTreeSet::new();
|
||||
let have: BTreeSet<u32> = label.iter().copied().collect();
|
||||
for p in 0..g.patches.len() as u32 {
|
||||
if have.contains(&p) {
|
||||
continue;
|
||||
}
|
||||
let best = (0..n)
|
||||
.filter(|q| !seeded.contains(q))
|
||||
.filter_map(|q| g.nearest(centre(q), |t| g.tri_patch.get(t as usize) == Some(&p)).map(|x| (q, x.2)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
if let Some((q, _)) = best {
|
||||
label[q] = p;
|
||||
seeded.insert(q);
|
||||
}
|
||||
}
|
||||
// 2. Clean.
|
||||
for _ in 0..CLEAN_ROUNDS {
|
||||
let mut changed = false;
|
||||
for q in 0..n {
|
||||
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for &m in &nbr[q] {
|
||||
*count.entry(label[m as usize]).or_default() += 1;
|
||||
}
|
||||
let same = count.get(&label[q]).copied().unwrap_or(0);
|
||||
if same == 0
|
||||
&& !nbr[q].is_empty()
|
||||
&& !seeded.contains(&q)
|
||||
&& let Some((&best, _)) = count.iter().filter(|(l, _)| **l != label[q]).max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
|
||||
{
|
||||
label[q] = best;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let mut comp = vec![usize::MAX; n];
|
||||
let mut comps: Vec<(u32, Vec<usize>)> = Vec::new();
|
||||
for s in 0..n {
|
||||
if comp[s] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let id = comps.len();
|
||||
let mut members = vec![s];
|
||||
comp[s] = id;
|
||||
let mut i = 0;
|
||||
while i < members.len() {
|
||||
let q = members[i];
|
||||
i += 1;
|
||||
for &m in &nbr[q] {
|
||||
let m = m as usize;
|
||||
if comp[m] == usize::MAX && label[m] == label[s] {
|
||||
comp[m] = id;
|
||||
members.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
comps.push((label[s], members));
|
||||
}
|
||||
let mut largest: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for (i, (l, m)) in comps.iter().enumerate() {
|
||||
let e = largest.entry(*l).or_insert(i);
|
||||
if comps[*e].1.len() < m.len() {
|
||||
*e = i;
|
||||
}
|
||||
}
|
||||
for (i, (l, members)) in comps.iter().enumerate() {
|
||||
if largest.get(l) == Some(&i) {
|
||||
continue;
|
||||
}
|
||||
let mut count: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for &q in members {
|
||||
for &m in &nbr[q] {
|
||||
if comp[m as usize] != i {
|
||||
*count.entry(label[m as usize]).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((&best, _)) = count.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0))) {
|
||||
for &q in members {
|
||||
label[q] = best;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check: every patch has a region; borders match curves.
|
||||
let present: BTreeSet<u32> = label.iter().copied().collect();
|
||||
for (p, patch) in g.patches.iter().enumerate() {
|
||||
if !present.contains(&(p as u32)) {
|
||||
let at: Vec<P> = patch.triangles.iter().take(64).map(|&t| g.triangle_centre(t)).collect();
|
||||
return Err(MapFailure { at, why: format!("patch {p} (B-rep faces {:?}) has no region", patch.faces) });
|
||||
}
|
||||
}
|
||||
let mut curves_between: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
|
||||
for (i, c) in g.curves.iter().enumerate() {
|
||||
match c.patches.as_slice() {
|
||||
[a, b] => curves_between.entry((*a, *b)).or_default().push(i as u32),
|
||||
[a] => curves_between.entry((*a, *a)).or_default().push(i as u32),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut role: BTreeMap<u32, Constraint> = BTreeMap::new();
|
||||
let mut border_pairs: BTreeSet<(u32, u32)> = BTreeSet::new();
|
||||
let mut vertex_labels: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
|
||||
for (q, quad) in quads.iter().enumerate() {
|
||||
for &v in quad {
|
||||
vertex_labels.entry(v).or_default().insert(label[q]);
|
||||
}
|
||||
}
|
||||
for ((a, b), qs) in &edges {
|
||||
let (q0, q1) = match qs.as_slice() {
|
||||
[x, y] => (*x as usize, *y as usize),
|
||||
_ => continue,
|
||||
};
|
||||
let (la, lb) = (label[q0].min(label[q1]), label[q0].max(label[q1]));
|
||||
if la == lb {
|
||||
continue;
|
||||
}
|
||||
let Some(cs) = curves_between.get(&(la, lb)) else {
|
||||
return Err(MapFailure { at: vec![xyz(*a), xyz(*b)], why: format!("regions of patches {la} and {lb} meet where the part has no curve") });
|
||||
};
|
||||
border_pairs.insert((la, lb));
|
||||
let mid = [0, 1, 2].map(|k| 0.5 * (xyz(*a)[k] + xyz(*b)[k]));
|
||||
let c = nearest_curve(g, cs, mid);
|
||||
for v in [*a, *b] {
|
||||
role.entry(v).or_insert(Constraint::Curve(c));
|
||||
}
|
||||
}
|
||||
for (pair, cs) in &curves_between {
|
||||
if pair.0 != pair.1 && !border_pairs.contains(pair) {
|
||||
let at: Vec<P> = cs.iter().flat_map(|&c| g.curves[c as usize].vertices.iter().map(|&v| g.xyz(v)).collect::<Vec<_>>()).collect();
|
||||
return Err(MapFailure { at, why: format!("the regions of patches {} and {} do not meet along their curve", pair.0, pair.1) });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Corners.
|
||||
let mut taken: BTreeSet<u32> = BTreeSet::new();
|
||||
let junctions: Vec<u32> = vertex_labels.iter().filter(|(_, l)| l.len() >= 3).map(|(v, _)| *v).collect();
|
||||
for &v in &junctions {
|
||||
let p = xyz(v);
|
||||
let labels = &vertex_labels[&v];
|
||||
let best = g
|
||||
.corners
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| !taken.contains(c) && corner_patches(g, *c).is_superset(labels))
|
||||
.min_by(|&a, &b| dist2(g.xyz(a), p).total_cmp(&dist2(g.xyz(b), p)).then(a.cmp(&b)));
|
||||
match best {
|
||||
Some(c) => {
|
||||
taken.insert(c);
|
||||
role.insert(v, Constraint::Corner(c));
|
||||
}
|
||||
None => return Err(MapFailure { at: vec![p], why: format!("regions {labels:?} meet where the part has no corner") }),
|
||||
}
|
||||
}
|
||||
for &c in &g.corners {
|
||||
if taken.contains(&c) {
|
||||
continue;
|
||||
}
|
||||
let p = g.xyz(c);
|
||||
let cp = corner_patches(g, c);
|
||||
let best = role
|
||||
.iter()
|
||||
.filter(|(_, r)| matches!(r, Constraint::Curve(_)))
|
||||
.map(|(v, _)| *v)
|
||||
.filter(|v| vertex_labels.get(v).is_some_and(|l| l.is_subset(&cp)))
|
||||
.min_by(|&a, &b| dist2(xyz(a), p).total_cmp(&dist2(xyz(b), p)).then(a.cmp(&b)));
|
||||
match best {
|
||||
Some(v) => {
|
||||
role.insert(v, Constraint::Corner(c));
|
||||
}
|
||||
None => return Err(MapFailure { at: vec![p], why: format!("the feature corner at {p:?} has no vertex") }),
|
||||
}
|
||||
}
|
||||
for (q, quad) in quads.iter().enumerate() {
|
||||
for &v in quad {
|
||||
role.entry(v).or_insert(Constraint::Patch(label[q]));
|
||||
}
|
||||
}
|
||||
Ok(FeatureMap { role, quad_patch: label })
|
||||
}
|
||||
|
||||
/// The patches around a corner of the part (on either side of the curves that end there).
|
||||
fn corner_patches(g: &Geometry, c: u32) -> BTreeSet<u32> {
|
||||
g.curves.iter().filter(|cv| cv.vertices.first() == Some(&c) || cv.vertices.last() == Some(&c)).flat_map(|cv| cv.patches.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// The curve of `cs` nearest to `p`.
|
||||
fn nearest_curve(g: &Geometry, cs: &[u32], p: P) -> u32 {
|
||||
cs.iter().copied().min_by(|&a, &b| curve_distance(g, a, p).total_cmp(&curve_distance(g, b, p)).then(a.cmp(&b))).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn curve_distance(g: &Geometry, c: u32, p: P) -> f64 {
|
||||
g.curves.get(c as usize).map_or(f64::INFINITY, |cv| {
|
||||
cv.vertices.windows(2).map(|w| dist2(p, closest_on_segment(p, g.xyz(w[0]), g.xyz(w[1])).0)).fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
//! Unstructured all-hexahedral meshing of any closed boundary: an adaptive grid cut into
|
||||
//! conforming hexahedra, a core of grid elements inside the part, two boundary layers that carry
|
||||
//! the part's faces, sharp curves and corners, and an optimisation that fits them to the
|
||||
//! geometry while keeping every element valid.
|
||||
//!
|
||||
//! # Pipeline
|
||||
//! 1. **Frame.** The grid is aligned with the part: the normals of its two largest planar
|
||||
//! patches when they are orthogonal and cover more area than the coordinate axes do, the
|
||||
//! coordinate axes otherwise (octree methods are orientation dependent; N. Pietroni et al.,
|
||||
//! *Hex-mesh generation and processing: a survey*, ACM TOG 42(2), 2022, §4.6).
|
||||
//! 2. **Grid** ([`grid`]). Root cubes of the target size over the part's box, refined as a
|
||||
//! 27-tree where a B-rep face curves (its normals turn by more than
|
||||
//! [`OctreeOptions::curvature_angle`] inside a cell), where the part is thin (fewer than
|
||||
//! [`OctreeOptions::thickness_cells`] cells across, measured along the inward normal of each
|
||||
//! boundary triangle) and where a patch is narrow, then strongly balanced and closed under the
|
||||
//! transition templates.
|
||||
//! 3. **Conforming hexahedra** ([`templates`]): the 3-refinement node-marking templates
|
||||
//! (R. Schneiders, 1996; Y. Ito, A. M. Shih, B. K. Soni, IJNME 77(13), 2009), so every grid
|
||||
//! leaf becomes 1, 4, 11, 22 or 27 hexahedra and the grid is conforming by construction.
|
||||
//! 4. **Core.** Elements whose eight corners are inside the part (exact ray parity on the
|
||||
//! boundary triangles) and at least [`OctreeOptions::core_offset`] of their cell size from it.
|
||||
//! Elements are dropped until the core's boundary is a 2-manifold and every boundary vertex can
|
||||
//! move outward (its quads' normals lie in one half-space: the buffer rule of H. Tong,
|
||||
//! E. Halilaj, Y. J. Zhang, *HybridOctree_Hex*, arXiv 2401.05984, 2024); its components and
|
||||
//! genus must match the part's shells.
|
||||
//! 5. **Feature map** ([`map`]): the part's patches, sharp curves and corners mapped onto the
|
||||
//! core's boundary quads (X. Gao, H. Shen, D. Panozzo, *Feature preserving octree-based
|
||||
//! hexahedral meshing*, CGF 38(5), 2019, §3.3), patches first.
|
||||
//! 6. **Padding and pillowing** ([`topo`]): one layer of hexahedra on the core's boundary (so no
|
||||
//! element keeps two faces on the boundary), then each patch's layer is pillowed, which inserts
|
||||
//! a ring of quads along every sharp curve on both sides: a curve then runs between quads that
|
||||
//! each have one edge on it, so straight and curved features are captured without degenerate
|
||||
//! quads (Gao et al. 2019, §3.4, Fig. 9).
|
||||
//! 7. **Fit** ([`smooth`]): the foldover-free energy of V. Garanzha et al., *Foldover-free maps
|
||||
//! in 50 lines of code*, ACM TOG 40(4), 2021, plus a fitting term that draws every boundary
|
||||
//! vertex onto its corner, curve or patch with a growing weight (Gao et al. 2019, §3.5); the
|
||||
//! barrier keeps every element valid while the boundary moves. Each boundary vertex then goes
|
||||
//! exactly onto its feature where its elements stay valid; where they would not (a blade, a
|
||||
//! near-tangent contact) the mesh stays off by [`OctreeReport::boundary_distance`].
|
||||
//! 8. **Gate.** The worst elements are improved by compass search, then every element is proven
|
||||
//! valid by the certified Jacobian check (Bernstein bounds, [`crate::quality`]; corner
|
||||
//! Jacobians alone do not prove a trilinear hexahedron valid, J. Marschner et al., CGF 39(5),
|
||||
//! 2020); elements that are not are repaired locally first. A failure in 4–8 names where the
|
||||
//! grid is too coarse; the leaves there are split once more and the mesh is rebuilt, at most
|
||||
//! [`OctreeOptions::attempts`] times, and a rebuild may not grow the grid more than
|
||||
//! [`RETRY_GROWTH`] times (Gao et al. 2019, §3.3).
|
||||
//!
|
||||
//! # Why 3-refinement templates
|
||||
//! The dual schemes of M. Livesu, L. Pitzalis, G. Cherchi, *Optimal dual schemes for adaptive
|
||||
//! grid based hexmeshing* (ACM TOG 41(2), 2022) give a simpler singular structure, but their
|
||||
//! installation depends on a paired grid whose transition vertices the reference pipeline
|
||||
//! (L. Pitzalis et al., *Generalized adaptive refinement for grid-based hexahedral meshing*, ACM
|
||||
//! TOG 40(6), 2021) selects with an integer linear program, and on heuristics that are retried
|
||||
//! with other parameters when they fail. The node-marking templates need only the balance and
|
||||
//! closure rules and are proven conforming by an exhaustive test. Their limit is the one
|
||||
//! Schneiders states: they close convex refined regions only, so a leaf next to a concave corner
|
||||
//! of a finer region is split, and the split spreads to the region's orthogonal hull (H. Tong,
|
||||
//! Y. J. Zhang, *MCHex*, arXiv 2511.02064, 2025, meet the same limit with the same templates).
|
||||
//! Curvature therefore refines only [`OctreeOptions::curvature_levels`] levels; deeper levels
|
||||
//! follow thickness and narrow faces, whose refined regions are mostly convex. Schneiders'
|
||||
//! modified edge rule (every edge with a marked end split in three) or the dual schemes are the
|
||||
//! upgrade path.
|
||||
//!
|
||||
//! # Determinism and bounds
|
||||
//! The same boundary and options give the same mesh: grid cells are processed in sorted order,
|
||||
//! maps are only looked up, the optimiser sweeps vertices in index order. Grid cells, elements,
|
||||
//! refinement depth, closure rounds, optimiser sweeps and attempts are capped.
|
||||
|
||||
mod geom;
|
||||
mod grid;
|
||||
mod map;
|
||||
mod smooth;
|
||||
mod templates;
|
||||
mod topo;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
pub use crate::hex::BoundaryFace;
|
||||
use crate::hex::face_flux;
|
||||
use crate::quality::{GateOptions, Metric, Validity, element_quality, validity_gate_checks};
|
||||
use crate::size::{SizeField, checked_size};
|
||||
use crate::surface::BoundarySurface;
|
||||
use crate::vec3::{P, cross, dot, norm, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
use geom::{Aabb, Geometry};
|
||||
use grid::Tree;
|
||||
use smooth::{Constraint, Optimizer};
|
||||
|
||||
/// Options and caps of [`octree_hex_mesh`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OctreeOptions {
|
||||
/// Refinement levels below the root cells (each one a third of the size), at most 8.
|
||||
pub max_depth: u8,
|
||||
/// The most grid cells and the most output elements.
|
||||
pub max_elements: usize,
|
||||
/// A cell is split where the normals of one B-rep face inside it turn by more than this
|
||||
/// (degrees).
|
||||
pub curvature_angle: f64,
|
||||
/// Levels below the root that curvature may refine (see the module docs).
|
||||
pub curvature_levels: u8,
|
||||
/// A cell is split where the part is less than this many cells thick.
|
||||
pub thickness_cells: f64,
|
||||
/// Core elements keep this fraction of their cell size from the boundary.
|
||||
pub core_offset: f64,
|
||||
/// Optimiser sweeps over all vertices.
|
||||
pub max_sweeps: usize,
|
||||
/// Builds, counting the rebuilds after refining where a stage failed (1 = no retry).
|
||||
pub attempts: usize,
|
||||
/// Boundary edges whose faces meet at more than this angle (degrees between normals) are
|
||||
/// sharp features the mesh captures with its own edges; smoother face boundaries are not.
|
||||
pub sharp_angle: f64,
|
||||
/// Edges whose normals turn by more than this (degrees: thin blades, near-tangent contacts)
|
||||
/// are not captured; the mesh rounds them.
|
||||
pub blade_angle: f64,
|
||||
}
|
||||
|
||||
impl Default for OctreeOptions {
|
||||
fn default() -> Self {
|
||||
OctreeOptions {
|
||||
max_depth: 2,
|
||||
max_elements: 5_000_000,
|
||||
curvature_angle: 25.0,
|
||||
curvature_levels: 1,
|
||||
thickness_cells: 3.0,
|
||||
core_offset: 0.3,
|
||||
max_sweeps: 300,
|
||||
attempts: 4,
|
||||
sharp_angle: 30.0,
|
||||
blade_angle: 160.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`octree_hex_mesh`] did.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OctreeReport {
|
||||
/// Builds run (1 + rebuilds after a failure).
|
||||
pub attempts: usize,
|
||||
/// Grid leaves of the last build.
|
||||
pub grid_cells: usize,
|
||||
/// Core elements (from the grid) and boundary-layer elements.
|
||||
pub core_elements: usize,
|
||||
pub layer_elements: usize,
|
||||
/// The grid's axes.
|
||||
pub frame: [[f64; 3]; 3],
|
||||
/// Sharp corners, curves and patches of the boundary.
|
||||
pub corners: usize,
|
||||
pub curves: usize,
|
||||
pub patches: usize,
|
||||
/// Optimiser sweeps of the last build.
|
||||
pub sweeps: usize,
|
||||
/// Smallest and mean corner scaled Jacobian.
|
||||
pub min_scaled_jacobian: f64,
|
||||
pub mean_scaled_jacobian: f64,
|
||||
/// Exact volume of the elements and the boundary's enclosed volume.
|
||||
pub volume: f64,
|
||||
pub surface_volume: f64,
|
||||
/// The largest distance of a boundary vertex from its corner, curve or patch: zero when
|
||||
/// every boundary vertex lies on the geometry; where the geometry is sharper than valid
|
||||
/// elements can follow (blades, slits), the mesh stays this far off.
|
||||
pub boundary_distance: f64,
|
||||
/// Why earlier builds were refined.
|
||||
pub retries: Vec<String>,
|
||||
}
|
||||
|
||||
/// An all-hex8 mesh.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OctreeHexMesh {
|
||||
pub nodes: Vec<[f64; 3]>,
|
||||
/// Model node order; every element is proven valid.
|
||||
pub hexes: Vec<[u32; 8]>,
|
||||
pub boundary: Vec<BoundaryFace>,
|
||||
pub report: OctreeReport,
|
||||
}
|
||||
|
||||
/// A rebuild after a failure may have at most this many times the grid cells of the build
|
||||
/// before…
|
||||
pub const RETRY_GROWTH: usize = 4;
|
||||
/// … or this many cells, whichever is more.
|
||||
const RETRY_MIN_CELLS: usize = 250_000;
|
||||
/// The fitting weights of the successive optimisation stages.
|
||||
const FIT_STAGES: &[f64] = &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0];
|
||||
/// A boundary vertex goes exactly onto its feature only if its elements keep at least this
|
||||
/// corner scaled Jacobian.
|
||||
const MIN_SNAP_QUALITY: f64 = 0.05;
|
||||
/// Elements below this corner scaled Jacobian are improved by compass search.
|
||||
const IMPROVE_BELOW: f64 = 0.3;
|
||||
/// Closure rounds of the grid (each splits at least one leaf; the cell cap bounds them too).
|
||||
const CLOSURE_ROUNDS: usize = 1000;
|
||||
/// Rounds of the core clean-up (each drops at least one element).
|
||||
const CORE_ROUNDS: usize = 1000;
|
||||
|
||||
/// A failure that refining at `at` may cure.
|
||||
struct Retry {
|
||||
at: Vec<P>,
|
||||
why: String,
|
||||
}
|
||||
|
||||
enum Attempt {
|
||||
Done(Box<OctreeHexMesh>),
|
||||
Retry(Retry),
|
||||
}
|
||||
|
||||
/// Mesh the solid bounded by `surface` with hexahedra of about `field`'s size (the root cell
|
||||
/// size is the field at the centre of the part's box; finer cells follow the part's curvature,
|
||||
/// thickness and narrow faces). See the module docs.
|
||||
pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options: &OctreeOptions) -> Result<OctreeHexMesh> {
|
||||
check_options(options)?;
|
||||
let g = Geometry::new(surface, options.sharp_angle, options.blade_angle)?;
|
||||
let frame = choose_frame(&g);
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for &p in surface.points() {
|
||||
let u = to_frame(&frame, p);
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(u[k]);
|
||||
hi[k] = hi[k].max(u[k]);
|
||||
}
|
||||
}
|
||||
let centre_u = [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k]));
|
||||
let h = checked_size(field, from_frame(&frame, centre_u))?;
|
||||
let mut roots = [0i64; 3];
|
||||
for k in 0..3 {
|
||||
let n = ((hi[k] - lo[k]) / h + 1.0).ceil();
|
||||
if !(n.is_finite() && n >= 1.0 && n <= options.max_elements as f64) {
|
||||
return Err(Error::Limit(format!("a size of {h} needs {n} root cells along an axis")));
|
||||
}
|
||||
roots[k] = n as i64;
|
||||
}
|
||||
let origin = [0, 1, 2].map(|k| centre_u[k] - 0.5 * roots[k] as f64 * h);
|
||||
let ctx = Context {
|
||||
g: &g,
|
||||
field,
|
||||
options,
|
||||
frame,
|
||||
origin,
|
||||
h,
|
||||
roots,
|
||||
thickness: triangle_thickness(&g),
|
||||
outside_memo: RefCell::new(HashMap::new()),
|
||||
last_leaves: Cell::new(0),
|
||||
too_thin: Cell::new(f64::INFINITY),
|
||||
};
|
||||
let mut hints: Vec<P> = Vec::new();
|
||||
let mut retries: Vec<String> = Vec::new();
|
||||
for attempt in 0..options.attempts {
|
||||
let cap = if attempt == 0 {
|
||||
options.max_elements
|
||||
} else {
|
||||
options.max_elements.min(RETRY_GROWTH.saturating_mul(ctx.last_leaves.get()).max(RETRY_MIN_CELLS))
|
||||
};
|
||||
let built = match ctx.build(&hints, cap) {
|
||||
Err(Error::Limit(why)) if attempt > 0 => {
|
||||
retries.push(format!("refining for another build needs more than {cap} grid cells ({why})"));
|
||||
break;
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
match built {
|
||||
Attempt::Done(mut m) => {
|
||||
m.report.attempts = attempt + 1;
|
||||
m.report.retries = retries;
|
||||
return Ok(*m);
|
||||
}
|
||||
Attempt::Retry(r) => {
|
||||
retries.push(r.why);
|
||||
hints.extend(r.at);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::Failed(format!("octree hex meshing failed after {} builds: {}", retries.len(), retries.join("; "))))
|
||||
}
|
||||
|
||||
fn check_options(o: &OctreeOptions) -> Result<()> {
|
||||
let ok = o.max_depth <= 8
|
||||
&& o.curvature_levels <= o.max_depth
|
||||
&& o.max_elements >= 1
|
||||
&& o.curvature_angle.is_finite()
|
||||
&& o.curvature_angle > 0.0
|
||||
&& o.thickness_cells.is_finite()
|
||||
&& o.thickness_cells >= 0.0
|
||||
&& o.core_offset.is_finite()
|
||||
&& (0.0..1.0).contains(&o.core_offset)
|
||||
&& (1..=16).contains(&o.attempts)
|
||||
&& o.sharp_angle.is_finite()
|
||||
&& o.sharp_angle > 0.0
|
||||
&& o.blade_angle.is_finite()
|
||||
&& o.blade_angle > o.sharp_angle
|
||||
&& o.blade_angle <= 180.0
|
||||
&& o.max_sweeps <= 100_000;
|
||||
if ok { Ok(()) } else { Err(Error::Invalid(format!("octree options out of range: {o:?}"))) }
|
||||
}
|
||||
|
||||
fn from_frame(f: &[[f64; 3]; 3], u: P) -> P {
|
||||
[0, 1, 2].map(|k| u[0] * f[0][k] + u[1] * f[1][k] + u[2] * f[2][k])
|
||||
}
|
||||
|
||||
fn to_frame(f: &[[f64; 3]; 3], p: P) -> P {
|
||||
[dot(p, f[0]), dot(p, f[1]), dot(p, f[2])]
|
||||
}
|
||||
|
||||
/// The grid axes (rows): the coordinate axes, or the normals of the two largest orthogonal planar
|
||||
/// patches when more boundary area is aligned with them.
|
||||
fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
|
||||
let tol = 1f64.to_radians().sin();
|
||||
let normal = |t: u32| g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
let mut planar: Vec<(f64, usize, P)> = Vec::new();
|
||||
for (i, p) in g.patches.iter().enumerate() {
|
||||
let Some(&t0) = p.triangles.first() else { continue };
|
||||
let n0 = normal(t0);
|
||||
if p.triangles.iter().all(|&t| norm(cross(normal(t), n0)) < tol) {
|
||||
planar.push((p.area, i, n0));
|
||||
}
|
||||
}
|
||||
planar.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let Some(&(_, _, a0)) = planar.first() else { return identity };
|
||||
let Some(&(_, _, a1)) = planar.iter().find(|x| dot(x.2, a0).abs() < tol) else { return identity };
|
||||
let a1 = {
|
||||
let v = sub(a1, a0.map(|x| x * dot(a1, a0)));
|
||||
let l = norm(v);
|
||||
v.map(|x| x / l)
|
||||
};
|
||||
let candidate = [a0, a1, cross(a0, a1)];
|
||||
let score = |f: &[[f64; 3]; 3]| -> f64 {
|
||||
(0..g.surface.triangles().len() as u32)
|
||||
.filter(|&t| f.iter().any(|a| norm(cross(normal(t), *a)) < tol))
|
||||
.map(|t| {
|
||||
let [a, b, c] = g.triangle(t);
|
||||
0.5 * norm(cross(sub(b, a), sub(c, a)))
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
if score(&candidate) > score(&identity) * (1.0 + 1e-9) { candidate } else { identity }
|
||||
}
|
||||
|
||||
/// The distance through the part from each boundary triangle: a ray from its centre along the
|
||||
/// inward normal to the next triangle (`∞` when none).
|
||||
fn triangle_thickness(g: &Geometry) -> Vec<f64> {
|
||||
let far = 2.0 * norm(sub(g.bounds.hi, g.bounds.lo)) + 1.0;
|
||||
(0..g.surface.triangles().len() as u32)
|
||||
.map(|t| {
|
||||
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
g.ray(g.triangle_centre(t), n.map(|x| -x), far, t).map_or(f64::INFINITY, |(_, s)| s)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn centroid(points: &[P], hx: &[u32; 8]) -> P {
|
||||
[0, 1, 2].map(|k| hx.iter().map(|&v| points.get(v as usize).map_or(0.0, |p| p[k])).sum::<f64>() / 8.0)
|
||||
}
|
||||
|
||||
fn edge_mean(points: &[P], hx: &[u32; 8]) -> f64 {
|
||||
let x = hx.map(|v| points.get(v as usize).copied().unwrap_or([0.0; 3]));
|
||||
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
|
||||
let m = edges.iter().map(|&(a, b)| norm(sub(x[a], x[b]))).sum::<f64>() / 12.0;
|
||||
if m > 0.0 { m } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The part being meshed and the state carried from one build to the next.
|
||||
struct Context<'a> {
|
||||
g: &'a Geometry<'a>,
|
||||
field: &'a dyn SizeField,
|
||||
options: &'a OctreeOptions,
|
||||
frame: [[f64; 3]; 3],
|
||||
origin: P,
|
||||
h: f64,
|
||||
roots: [i64; 3],
|
||||
thickness: Vec<f64>,
|
||||
outside_memo: RefCell<HashMap<grid::Key, bool>>,
|
||||
/// Grid leaves of the last build.
|
||||
last_leaves: Cell<usize>,
|
||||
/// The thinnest wall the deepest cells cannot cross in `thickness_cells` (∞ when none).
|
||||
too_thin: Cell<f64>,
|
||||
}
|
||||
|
||||
/// The mesh while its boundary layers are built: points, elements, their target sizes and how
|
||||
/// each point may move.
|
||||
struct Work {
|
||||
points: Vec<P>,
|
||||
hexes: Vec<[u32; 8]>,
|
||||
size: Vec<f64>,
|
||||
role: Vec<Constraint>,
|
||||
core_count: usize,
|
||||
}
|
||||
|
||||
impl Context<'_> {
|
||||
fn edge(&self, level: u8) -> f64 {
|
||||
self.h / 3f64.powi(i32::from(level))
|
||||
}
|
||||
|
||||
/// The world box (inflated by `pad` cell edges) and the centre of a cell.
|
||||
fn cell_box(&self, (level, i): grid::Key, pad: f64) -> (Aabb, P) {
|
||||
let e = self.edge(level);
|
||||
let mut b = Aabb::EMPTY;
|
||||
for c in 0..8 {
|
||||
b.add(from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + ((c >> k) & 1) as f64) * e)));
|
||||
}
|
||||
let centre = from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + (i[k] as f64 + 0.5) * e));
|
||||
let p = pad * e;
|
||||
(Aabb { lo: b.lo.map(|x| x - p), hi: b.hi.map(|x| x + p) }, centre)
|
||||
}
|
||||
|
||||
fn needs_split(&self, key: grid::Key) -> Result<bool> {
|
||||
let e = self.edge(key.0);
|
||||
let (bx, centre) = self.cell_box(key, 0.05);
|
||||
if e > checked_size(self.field, centre)? * (1.0 + 1e-9) {
|
||||
return Ok(true);
|
||||
}
|
||||
let reach = 0.5 * 3f64.sqrt() * e * 1.05;
|
||||
let cos = self.options.curvature_angle.to_radians().cos();
|
||||
let mut first: BTreeMap<u32, P> = BTreeMap::new();
|
||||
for t in self.g.overlapping(&bx) {
|
||||
let [a, b, c] = self.g.triangle(t);
|
||||
if geom::dist2(centre, geom::closest_on_triangle(centre, a, b, c)) > reach * reach {
|
||||
continue;
|
||||
}
|
||||
let n = self.g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
let face = self.g.surface.tri_face().get(t as usize).copied().unwrap_or(0);
|
||||
let f = *first.entry(face).or_insert(n);
|
||||
if key.0 < self.options.curvature_levels && dot(f, n) < cos {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(&th) = self.thickness.get(t as usize)
|
||||
&& th < self.options.thickness_cells * e
|
||||
{
|
||||
if key.0 + 1 >= self.options.max_depth && th < self.options.thickness_cells * e / 3.0 {
|
||||
self.too_thin.set(self.too_thin.get().min(th));
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Narrow patches (a hole's wall, a thin rim) need cells across them.
|
||||
if self.g.tri_patch.get(t as usize).and_then(|&p| self.g.patches.get(p as usize)).is_some_and(|pt| pt.width < 2.0 * e) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Whether a cell lies wholly outside the part (its centre outside and farther from the
|
||||
/// boundary than its half diagonal): no core element can come from it, so its transition
|
||||
/// pattern does not matter and it is not meshed. Memoised.
|
||||
fn outside(&self, key: grid::Key) -> bool {
|
||||
if let Some(&v) = self.outside_memo.borrow().get(&key) {
|
||||
return v;
|
||||
}
|
||||
let (_, centre) = self.cell_box(key, 0.0);
|
||||
let half = 0.5 * 3f64.sqrt() * self.edge(key.0);
|
||||
let far = self.g.nearest(centre, |_| true).is_some_and(|x| x.2 > half * half * (1.0 + 1e-9));
|
||||
let v = far && self.g.inside(centre) == Some(false);
|
||||
self.outside_memo.borrow_mut().insert(key, v);
|
||||
v
|
||||
}
|
||||
|
||||
/// The level-`level` cells within one cell of a hint point.
|
||||
fn hint_cells(&self, hints: &[P], level: u8) -> HashSet<grid::Key> {
|
||||
let e = self.edge(level);
|
||||
let mut out = HashSet::new();
|
||||
for &p in hints {
|
||||
let u = to_frame(&self.frame, p);
|
||||
let i = [0, 1, 2].map(|k| ((u[k] - self.origin[k]) / e).floor());
|
||||
if !i.iter().all(|x| x.is_finite() && x.abs() < 1e15) {
|
||||
continue;
|
||||
}
|
||||
for d in 0..27i64 {
|
||||
out.insert((level, [i[0] as i64 + d % 3 - 1, i[1] as i64 + (d / 3) % 3 - 1, i[2] as i64 + d / 9 - 1]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The refined, balanced and closed grid; leaves near `hints` (where an earlier build
|
||||
/// failed) are split once more.
|
||||
fn tree(&self, hints: &[P], max_leaves: usize) -> Result<Tree> {
|
||||
let mut tree = Tree::new(self.roots, self.options.max_depth, max_leaves)?;
|
||||
for level in 0..self.options.max_depth {
|
||||
for key in tree.sorted_leaves() {
|
||||
if key.0 == level && self.needs_split(key)? {
|
||||
tree.split(key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hints.is_empty() {
|
||||
let near: Vec<HashSet<grid::Key>> = (0..self.options.max_depth).map(|l| self.hint_cells(hints, l)).collect();
|
||||
let split: Vec<grid::Key> =
|
||||
tree.sorted_leaves().into_iter().filter(|k| near.get(usize::from(k.0)).is_some_and(|s| s.contains(k))).collect();
|
||||
for key in split {
|
||||
tree.split(key)?;
|
||||
}
|
||||
}
|
||||
tree.close(CLOSURE_ROUNDS, &|k| self.outside(k))?;
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
fn build(&self, hints: &[P], max_leaves: usize) -> Result<Attempt> {
|
||||
let g = self.g;
|
||||
let tree = self.tree(hints, max_leaves)?;
|
||||
self.last_leaves.set(tree.leaf_count());
|
||||
let lattice = tree.mesh(&|k| self.outside(k))?;
|
||||
if lattice.hexes.len() > self.options.max_elements {
|
||||
return Err(Error::Limit(format!("the grid has {} elements; the limit is {}", lattice.hexes.len(), self.options.max_elements)));
|
||||
}
|
||||
let unit = self.h / tree.span(0) as f64;
|
||||
let points: Vec<P> = lattice.points.iter().map(|q| from_frame(&self.frame, [0, 1, 2].map(|k| self.origin[k] + q[k] as f64 * unit))).collect();
|
||||
|
||||
// Core.
|
||||
let dist: Vec<f64> = points.iter().map(|&p| g.nearest(p, |_| true).map_or(0.0, |x| x.2.sqrt())).collect();
|
||||
let keep = self.core(&lattice, &points, &dist);
|
||||
let core: Vec<usize> = (0..lattice.hexes.len()).filter(|&i| keep[i]).collect();
|
||||
if core.is_empty() {
|
||||
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
|
||||
return Ok(Attempt::Retry(Retry { at, why: "no grid element lies inside the part".into() }));
|
||||
}
|
||||
let mut w = Work {
|
||||
hexes: core.iter().map(|&i| lattice.hexes[i]).collect(),
|
||||
size: core.iter().map(|&i| self.edge(lattice.level[i])).collect(),
|
||||
role: Vec::new(),
|
||||
points,
|
||||
core_count: core.len(),
|
||||
};
|
||||
let qb = topo::boundary(&w.hexes);
|
||||
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
|
||||
if let Some(r) = check_topology(g, &q) {
|
||||
let thin = self.too_thin.get();
|
||||
if thin.is_finite() {
|
||||
return Err(Error::Failed(format!(
|
||||
"{}: the part has walls {thin:.3} thick, thinner than {} cells of the deepest level ({:.3}); use a smaller size, more levels, or a structured (swept) hex mesh",
|
||||
r.why,
|
||||
self.options.thickness_cells,
|
||||
self.edge(self.options.max_depth)
|
||||
)));
|
||||
}
|
||||
return Ok(Attempt::Retry(r));
|
||||
}
|
||||
|
||||
// Feature map, padding and pillows.
|
||||
let fmap = {
|
||||
let pts = &w.points;
|
||||
match map::map_features(g, &q, &|v| pts.get(v as usize).copied().unwrap_or([0.0; 3])) {
|
||||
Ok(m) => m,
|
||||
Err(f) => return Ok(Attempt::Retry(Retry { at: f.at, why: f.why })),
|
||||
}
|
||||
};
|
||||
self.layers(&mut w, &qb, &fmap, &dist)?;
|
||||
if w.hexes.len() > self.options.max_elements {
|
||||
return Err(Error::Limit(format!("the mesh needs {} elements; the limit is {}", w.hexes.len(), self.options.max_elements)));
|
||||
}
|
||||
|
||||
// Fit, then improve and repair.
|
||||
let mut opt = Optimizer::new(g, std::mem::take(&mut w.points), &w.hexes, &w.size, w.role.clone());
|
||||
let mut ran = 0;
|
||||
let sweeps = (self.options.max_sweeps / FIT_STAGES.len()).max(1);
|
||||
for &weight in FIT_STAGES {
|
||||
opt.fit = weight;
|
||||
ran = opt.run(sweeps, 1e-4);
|
||||
}
|
||||
let (_, boundary_distance) = opt.snap(MIN_SNAP_QUALITY);
|
||||
let inverted: Vec<P> = w
|
||||
.hexes
|
||||
.iter()
|
||||
.filter(|hx| smooth::min_scaled_jacobian(&hx.map(|v| opt.points[v as usize])) <= 0.0)
|
||||
.map(|hx| centroid(&opt.points, hx))
|
||||
.collect();
|
||||
if !inverted.is_empty() {
|
||||
let why = format!("{} elements stay inverted after untangling", inverted.len());
|
||||
return Ok(Attempt::Retry(Retry { at: inverted, why }));
|
||||
}
|
||||
opt.improve_worst(IMPROVE_BELOW, 4);
|
||||
let uncertified = opt.repair_certified(6);
|
||||
if !uncertified.is_empty() {
|
||||
let at: Vec<P> = uncertified.iter().filter_map(|&e| w.hexes.get(e as usize)).map(|hx| centroid(&opt.points, hx)).collect();
|
||||
let why = format!("{} elements fail the certified validity check", at.len());
|
||||
return Ok(Attempt::Retry(Retry { at, why }));
|
||||
}
|
||||
w.points = opt.points;
|
||||
self.finish(&w, &tree, ran, boundary_distance)
|
||||
}
|
||||
|
||||
/// The core: elements wholly inside the part, cleaned to a 2-manifold boundary whose
|
||||
/// vertices can all move outward.
|
||||
fn core(&self, lattice: &grid::LatticeMesh, points: &[P], dist: &[f64]) -> Vec<bool> {
|
||||
let inside: Vec<bool> = points.iter().map(|&p| self.g.inside(p) == Some(true)).collect();
|
||||
let mut keep: Vec<bool> = lattice
|
||||
.hexes
|
||||
.iter()
|
||||
.zip(&lattice.level)
|
||||
.map(|(hx, &l)| {
|
||||
let floor = self.options.core_offset * self.edge(l);
|
||||
hx.iter().all(|&v| inside.get(v as usize) == Some(&true) && dist.get(v as usize).is_some_and(|&d| d >= floor))
|
||||
})
|
||||
.collect();
|
||||
for _ in 0..CORE_ROUNDS {
|
||||
let mut drop = topo::nonmanifold(&lattice.hexes, &keep);
|
||||
if drop.is_empty() {
|
||||
drop = topo::hidden_vertices(&lattice.hexes, &keep, &|v| points.get(v as usize).copied().unwrap_or([0.0; 3]), 0.1);
|
||||
}
|
||||
if drop.is_empty() {
|
||||
break;
|
||||
}
|
||||
for d in drop {
|
||||
if let Some(k) = keep.get_mut(d as usize) {
|
||||
*k = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
/// The boundary layers: padding on the core's boundary quads `qb`, whose outer copies take the
|
||||
/// map's roles and start halfway to the boundary along the core's normals, then a pillow
|
||||
/// under each patch, whose copies start a quarter of the way into their elements. Every
|
||||
/// element starts valid or nearly so; the fit untangles the rest.
|
||||
fn layers(&self, w: &mut Work, qb: &[(topo::FaceRef, [u32; 4])], fmap: &map::FeatureMap, dist: &[f64]) -> Result<()> {
|
||||
let q: Vec<[u32; 4]> = qb.iter().map(|x| x.1).collect();
|
||||
let core_count = w.core_count;
|
||||
let copy = topo::pad(&mut w.points, &mut w.hexes, &q)?;
|
||||
w.role = vec![Constraint::Free; w.points.len()];
|
||||
let mut normal: BTreeMap<u32, P> = BTreeMap::new();
|
||||
for f in &q {
|
||||
let p = f.map(|v| w.points[v as usize]);
|
||||
let n = cross(sub(p[2], p[0]), sub(p[3], p[1]));
|
||||
let l = norm(n);
|
||||
if l > 0.0 {
|
||||
for &v in f {
|
||||
let e = normal.entry(v).or_insert([0.0; 3]);
|
||||
for k in 0..3 {
|
||||
e[k] += n[k] / l;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (&v, &c) in © {
|
||||
if let Some(r) = fmap.role.get(&v) {
|
||||
w.role[c as usize] = *r;
|
||||
}
|
||||
let n = normal.get(&v).copied().unwrap_or([0.0; 3]);
|
||||
let l = norm(n);
|
||||
let d = 0.5 * dist.get(v as usize).copied().unwrap_or(0.0);
|
||||
if l > 0.0 {
|
||||
w.points[c as usize] = [0, 1, 2].map(|k| w.points[v as usize][k] + d * n[k] / l);
|
||||
}
|
||||
}
|
||||
for ((owner, _), _) in qb {
|
||||
w.size.push(0.5 * w.size.get(*owner as usize).copied().unwrap_or(self.h));
|
||||
}
|
||||
let patches: BTreeSet<u32> = fmap.quad_patch.iter().copied().collect();
|
||||
for p in patches {
|
||||
let inside: Vec<bool> =
|
||||
(0..w.hexes.len()).map(|i| i >= core_count && i < core_count + q.len() && fmap.quad_patch.get(i - core_count) == Some(&p)).collect();
|
||||
let before = w.hexes.len();
|
||||
let centroids: Vec<P> = w.hexes.iter().map(|hx| centroid(&w.points, hx)).collect();
|
||||
let copies = topo::pillow(&mut w.points, &mut w.hexes, &inside)?;
|
||||
w.role.resize(w.points.len(), Constraint::Free);
|
||||
for pc in &copies {
|
||||
let orig = w.points[pc.original as usize];
|
||||
let n = pc.elements.len().max(1) as f64;
|
||||
let mut c = [0.0; 3];
|
||||
for &e in &pc.elements {
|
||||
let ce = centroids.get(e as usize).copied().unwrap_or(orig);
|
||||
for k in 0..3 {
|
||||
c[k] += ce[k] / n;
|
||||
}
|
||||
}
|
||||
w.points[pc.copy as usize] = [0, 1, 2].map(|k| orig[k] + 0.25 * (c[k] - orig[k]));
|
||||
w.role[pc.copy as usize] = match w.role.get(pc.original as usize).copied().unwrap_or(Constraint::Free) {
|
||||
Constraint::Free => Constraint::Free,
|
||||
_ => Constraint::Patch(p),
|
||||
};
|
||||
}
|
||||
for hx in &w.hexes[before..] {
|
||||
w.size.push(edge_mean(&w.points, hx));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The gate and the output: every element proven valid, nodes renumbered, boundary faces with
|
||||
/// their B-rep faces.
|
||||
fn finish(&self, w: &Work, tree: &Tree, sweeps: usize, boundary_distance: f64) -> Result<Attempt> {
|
||||
let g = self.g;
|
||||
// One certified pass: every element's quality (the same metric as mesh.quality and
|
||||
// mesh.hex.generate report) and its Jacobian check for the gate.
|
||||
let mut bad: Vec<P> = Vec::new();
|
||||
let mut checks = Vec::with_capacity(w.hexes.len());
|
||||
let (mut sj_min, mut sj_sum) = (f64::INFINITY, 0.0);
|
||||
for (e, hx) in w.hexes.iter().enumerate() {
|
||||
let q = element_quality(ElementKind::Hex8, &hx.map(|v| w.points[v as usize]))?;
|
||||
let check = q.jacobian.ok_or_else(|| Error::Failed("an element was not certified".into()))?;
|
||||
if check.validity != Validity::Valid {
|
||||
bad.push(centroid(&w.points, hx));
|
||||
}
|
||||
let sj = q.get(Metric::ScaledJacobian).unwrap_or(-1.0);
|
||||
sj_min = sj_min.min(sj);
|
||||
sj_sum += sj;
|
||||
checks.push((e as u32 + 1, check));
|
||||
}
|
||||
if !bad.is_empty() {
|
||||
let why = format!("{} elements fail the certified validity check", bad.len());
|
||||
return Ok(Attempt::Retry(Retry { at: bad, why }));
|
||||
}
|
||||
// The volume the mesh's own boundary encloses equals the elements' total exactly when they
|
||||
// tile it without overlapping.
|
||||
let faces = topo::boundary(&w.hexes);
|
||||
let enclosed: f64 = faces.iter().map(|(_, quad)| face_flux(&quad.map(|v| w.points[v as usize]))).sum();
|
||||
let gate =
|
||||
validity_gate_checks(checks.iter().map(|(l, c)| (*l, c)), &GateOptions { expected_volume: Some(enclosed), volume_tolerance: 1e-9 })?
|
||||
.into_result()?;
|
||||
let volume = gate.volume;
|
||||
let mut new_id = vec![u32::MAX; w.points.len()];
|
||||
let mut nodes = Vec::new();
|
||||
let mut hexes = Vec::with_capacity(w.hexes.len());
|
||||
for hx in &w.hexes {
|
||||
let mut o = [0u32; 8];
|
||||
for (k, &v) in hx.iter().enumerate() {
|
||||
if new_id[v as usize] == u32::MAX {
|
||||
new_id[v as usize] = u32::try_from(nodes.len()).map_err(|_| Error::Limit("too many nodes".into()))?;
|
||||
nodes.push(w.points[v as usize]);
|
||||
}
|
||||
o[k] = new_id[v as usize];
|
||||
}
|
||||
hexes.push(o);
|
||||
}
|
||||
let mut boundary = Vec::new();
|
||||
for ((e, f), quad) in faces {
|
||||
let patch = quad.iter().find_map(|&v| match w.role.get(v as usize) {
|
||||
Some(Constraint::Patch(p)) => Some(*p),
|
||||
_ => None,
|
||||
});
|
||||
let c = [0, 1, 2].map(|k| quad.iter().map(|&v| w.points[v as usize][k]).sum::<f64>() / 4.0);
|
||||
let brep_face = match patch.and_then(|p| g.patches.get(p as usize)) {
|
||||
Some(pt) if pt.faces.len() == 1 => pt.faces[0],
|
||||
_ => {
|
||||
let keep = |t: u32| patch.is_none_or(|p| g.tri_patch.get(t as usize) == Some(&p));
|
||||
g.nearest(c, keep).and_then(|(t, _, _)| g.surface.tri_face().get(t as usize).copied()).unwrap_or(0)
|
||||
}
|
||||
};
|
||||
boundary.push(BoundaryFace { element: e, face: f + 1, brep_face });
|
||||
}
|
||||
let report = OctreeReport {
|
||||
attempts: 0,
|
||||
grid_cells: tree.leaf_count(),
|
||||
core_elements: w.core_count,
|
||||
layer_elements: w.hexes.len() - w.core_count,
|
||||
frame: self.frame,
|
||||
corners: g.corners.len(),
|
||||
curves: g.curves.len(),
|
||||
patches: g.patches.len(),
|
||||
sweeps,
|
||||
min_scaled_jacobian: sj_min,
|
||||
mean_scaled_jacobian: sj_sum / w.hexes.len().max(1) as f64,
|
||||
volume,
|
||||
surface_volume: g.surface.volume(),
|
||||
boundary_distance,
|
||||
retries: Vec::new(),
|
||||
};
|
||||
Ok(Attempt::Done(Box::new(OctreeHexMesh { nodes, hexes, boundary, report })))
|
||||
}
|
||||
}
|
||||
|
||||
/// The core boundary's components and genus against the part's shells; on a mismatch, a retry
|
||||
/// that refines near the whole boundary.
|
||||
fn check_topology(g: &Geometry, quads: &[[u32; 4]]) -> Option<Retry> {
|
||||
let edges = topo::quad_edges(quads);
|
||||
let mut comp: Vec<usize> = (0..quads.len()).collect();
|
||||
for qs in edges.values() {
|
||||
for w in qs.windows(2) {
|
||||
let (a, b) = (topo::find(&mut comp, w[0] as usize), topo::find(&mut comp, w[1] as usize));
|
||||
comp[a] = b;
|
||||
}
|
||||
}
|
||||
// Per component: vertices, edges, faces.
|
||||
let mut stats: BTreeMap<usize, (BTreeSet<u32>, i64, i64)> = BTreeMap::new();
|
||||
for (i, q) in quads.iter().enumerate() {
|
||||
let s = stats.entry(topo::find(&mut comp, i)).or_default();
|
||||
s.2 += 1;
|
||||
s.0.extend(q.iter().copied());
|
||||
}
|
||||
for qs in edges.values() {
|
||||
if let Some(&q0) = qs.first()
|
||||
&& let Some(s) = stats.get_mut(&topo::find(&mut comp, q0 as usize))
|
||||
{
|
||||
s.1 += 1;
|
||||
}
|
||||
}
|
||||
let mut got: Vec<i64> = stats.values().map(|s| (2 - (s.0.len() as i64 - s.1 + s.2)) / 2).collect();
|
||||
let mut want: Vec<i64> = g.surface.shells().iter().map(|s| i64::from(s.genus)).collect();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
if got == want {
|
||||
return None;
|
||||
}
|
||||
let at = (0..g.surface.triangles().len() as u32).map(|t| g.triangle_centre(t)).collect();
|
||||
Some(Retry { at, why: format!("the core's boundary has components of genus {got:?}, the part {want:?}") })
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
//! Untangling, fitting and shape optimisation of the hexahedral mesh.
|
||||
//!
|
||||
//! # Energy
|
||||
//! The foldover-free mapping energy of V. Garanzha, I. Kaporin, L. Kudryavtseva, F. Protais,
|
||||
//! N. Ray, D. Sokolov, *Foldover-free maps in 50 lines of code*, ACM TOG 40(4):102, 2021 (paper
|
||||
//! only; the authors' code is AGPL and is not read). Each hexahedron is measured at nine points,
|
||||
//! its eight corners (the trapezoidal rule the paper uses for quads) and its centre, where a
|
||||
//! twisted element folds first: `J` is the sample's three edge directions against the ideal cube
|
||||
//! of the element's target edge `s`, `D = det J`, and the sample costs
|
||||
//!
|
||||
//! `f_ε + λ g_ε`, `f_ε = |J|³_F / χ(D, ε)`, `g_ε = (D² + 1) / χ(D, ε)`,
|
||||
//! `χ(D, ε) = (D + √(ε² + D²)) / 2`,
|
||||
//!
|
||||
//! weighted by `s³`. `f` is the shape (conformal) term, `g` the volume term. The paper's shape
|
||||
//! term is `|J|²/D^{2/3}`; its 3/2 power `|J|³/D`, the classical polyconvex distortion in three
|
||||
//! dimensions (J. M. Ball, 1977), is minimal at the same ideal shapes and needs only square
|
||||
//! roots, so the optimiser gives the same bits on every platform (no `pow` of the system maths
|
||||
//! library). `χ` keeps both finite for inverted samples, and `ε` follows the paper's rule (7),
|
||||
//! `ε = √(10⁻¹² + 0.04 min(0, min D)²)`, so it vanishes once every sample is positive and the
|
||||
//! energy becomes a barrier against inversion.
|
||||
//!
|
||||
//! A vertex on a corner, curve or patch adds `fit · 8 s̄ · dist²` (`s̄` the mean target edge of its
|
||||
//! elements) to its distance from that feature: with `fit` raised stage by stage the boundary is
|
||||
//! drawn onto the geometry while the barrier keeps every element valid (Gao, Shen, Panozzo 2019,
|
||||
//! §3.5). With `fit = 0` constrained vertices are held on their features instead (each step is
|
||||
//! restricted to the tangent and projected back).
|
||||
//!
|
||||
//! # Minimisation
|
||||
//! Nonlinear Gauss–Seidel: each vertex in turn takes a Newton step on its own energy with the
|
||||
//! paper's positive approximation of the Hessian (the second derivatives of `χ` and `det` are
|
||||
//! dropped, §2.3, App. A), accepted only if the energy decreased (halving line search). The paper
|
||||
//! minimises the same energy globally with L-BFGS; the vertex sweep is the local form of the same
|
||||
//! descent. Then a compass search raises the worst corner scaled Jacobians, and another repairs
|
||||
//! elements whose trilinear map the certified check does not prove positive. Sweeps, line-search
|
||||
//! steps, compass steps and rounds are capped.
|
||||
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
use crate::quality::{Validity, element_validity};
|
||||
use crate::vec3::{P, cross, dot, norm, sub};
|
||||
|
||||
use super::geom::dist2;
|
||||
|
||||
use super::geom::Geometry;
|
||||
|
||||
/// How a vertex may move.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Constraint {
|
||||
Free,
|
||||
/// On a corner of the part (the boundary surface's vertex).
|
||||
Corner(u32),
|
||||
/// On a feature curve.
|
||||
Curve(u32),
|
||||
/// On a patch of the boundary.
|
||||
Patch(u32),
|
||||
}
|
||||
|
||||
/// Model-order corner `c` of a hex8: its three neighbours along x, y, z and the signs that make
|
||||
/// the reference corner frame right-handed.
|
||||
const CORNER_FRAMES: [([usize; 3], [f64; 3]); 8] = [
|
||||
([1, 3, 4], [1.0, 1.0, 1.0]),
|
||||
([0, 2, 5], [-1.0, 1.0, 1.0]),
|
||||
([3, 1, 6], [-1.0, -1.0, 1.0]),
|
||||
([2, 0, 7], [1.0, -1.0, 1.0]),
|
||||
([5, 7, 0], [1.0, 1.0, -1.0]),
|
||||
([4, 6, 1], [-1.0, 1.0, -1.0]),
|
||||
([7, 5, 2], [-1.0, -1.0, -1.0]),
|
||||
([6, 4, 3], [1.0, -1.0, -1.0]),
|
||||
];
|
||||
|
||||
/// Weight of the volume term.
|
||||
const LAMBDA: f64 = 0.1;
|
||||
|
||||
fn chi(d: f64, eps: f64) -> f64 {
|
||||
0.5 * (d + (eps * eps + d * d).sqrt())
|
||||
}
|
||||
|
||||
fn chi_d(d: f64, eps: f64) -> f64 {
|
||||
0.5 * (1.0 + d / (eps * eps + d * d).sqrt())
|
||||
}
|
||||
|
||||
/// The corner Jacobian (columns) of corner `c` of `x` against an ideal cube of edge `s`.
|
||||
fn corner_jacobian(x: &[P; 8], c: usize, s: f64) -> [P; 3] {
|
||||
let (n, sg) = CORNER_FRAMES[c];
|
||||
[0, 1, 2].map(|a| sub(x[n[a]], x[c]).map(|v| v * sg[a] / s))
|
||||
}
|
||||
|
||||
/// The sample points of the energy: the eight corners and the centre. Column `a` of sample `s`'s
|
||||
/// Jacobian is `Σ_k W[s][a][k] x_k` (before dividing by the target edge). The centre keeps
|
||||
/// twisted elements (positive corners, folded inside) out of reach, as the nine-point measure of
|
||||
/// ParaView and of Zhang et al.'s meshers does.
|
||||
const SAMPLES: usize = 9;
|
||||
|
||||
fn sample_weights(s: usize) -> [[f64; 8]; 3] {
|
||||
let mut w = [[0.0; 8]; 3];
|
||||
if s < 8 {
|
||||
let (n, sg) = CORNER_FRAMES[s];
|
||||
for a in 0..3 {
|
||||
w[a][n[a]] += sg[a];
|
||||
w[a][s] -= sg[a];
|
||||
}
|
||||
} else {
|
||||
w = [
|
||||
[-0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25, -0.25],
|
||||
[-0.25, -0.25, 0.25, 0.25, -0.25, -0.25, 0.25, 0.25],
|
||||
[-0.25, -0.25, -0.25, -0.25, 0.25, 0.25, 0.25, 0.25],
|
||||
];
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// The Jacobian (columns) of sample `smp` of `x` against an ideal cube of edge `s`.
|
||||
fn sample_jacobian(x: &[P; 8], smp: usize, s: f64) -> [P; 3] {
|
||||
let w = sample_weights(smp);
|
||||
[0, 1, 2].map(|a| {
|
||||
let mut c = [0.0; 3];
|
||||
for (k, p) in x.iter().enumerate() {
|
||||
for r in 0..3 {
|
||||
c[r] += w[a][k] * p[r];
|
||||
}
|
||||
}
|
||||
c.map(|v| v / s)
|
||||
})
|
||||
}
|
||||
|
||||
fn det3(j: &[P; 3]) -> f64 {
|
||||
dot(j[0], cross(j[1], j[2]))
|
||||
}
|
||||
|
||||
/// The cofactor columns: `∂ det / ∂ column a`.
|
||||
fn cofactors(j: &[P; 3]) -> [P; 3] {
|
||||
[cross(j[1], j[2]), cross(j[2], j[0]), cross(j[0], j[1])]
|
||||
}
|
||||
|
||||
/// The energy of one corner.
|
||||
fn corner_energy(j: &[P; 3], eps: f64) -> f64 {
|
||||
let d = det3(j);
|
||||
let c = chi(d, eps);
|
||||
let fro: f64 = j.iter().map(|v| dot(*v, *v)).sum();
|
||||
fro * fro.sqrt() / c + LAMBDA * (d * d + 1.0) / c
|
||||
}
|
||||
|
||||
/// The scaled Jacobian of each corner, smallest (`min over corners of det / product of edge
|
||||
/// lengths`), the usual hex quality measure.
|
||||
pub(crate) fn min_scaled_jacobian(x: &[P; 8]) -> f64 {
|
||||
(0..8)
|
||||
.map(|c| {
|
||||
let j = corner_jacobian(x, c, 1.0);
|
||||
let l = norm(j[0]) * norm(j[1]) * norm(j[2]);
|
||||
if l > 0.0 { det3(&j) / l } else { -1.0 }
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// The optimiser's state.
|
||||
pub(crate) struct Optimizer<'a> {
|
||||
pub geom: &'a Geometry<'a>,
|
||||
pub points: Vec<P>,
|
||||
pub hexes: &'a [[u32; 8]],
|
||||
/// Target edge per element.
|
||||
pub size: &'a [f64],
|
||||
pub constraint: Vec<Constraint>,
|
||||
/// Elements around each vertex with the vertex's local position.
|
||||
around: Vec<Vec<(u32, u8)>>,
|
||||
pub eps: f64,
|
||||
/// Weight of the fitting term; 0 holds boundary vertices on their features exactly.
|
||||
pub fit: f64,
|
||||
}
|
||||
|
||||
impl<'a> Optimizer<'a> {
|
||||
pub fn new(geom: &'a Geometry<'a>, points: Vec<P>, hexes: &'a [[u32; 8]], size: &'a [f64], constraint: Vec<Constraint>) -> Optimizer<'a> {
|
||||
let mut around: Vec<Vec<(u32, u8)>> = vec![Vec::new(); points.len()];
|
||||
for (e, h) in hexes.iter().enumerate() {
|
||||
for (k, &v) in h.iter().enumerate() {
|
||||
if let Some(a) = around.get_mut(v as usize) {
|
||||
a.push((e as u32, k as u8));
|
||||
}
|
||||
}
|
||||
}
|
||||
Optimizer { geom, points, hexes, size, constraint, around, eps: 1.0, fit: 0.0 }
|
||||
}
|
||||
|
||||
fn element(&self, e: u32) -> ([P; 8], f64) {
|
||||
let h = self.hexes.get(e as usize).copied().unwrap_or([0; 8]);
|
||||
(h.map(|v| self.points.get(v as usize).copied().unwrap_or([0.0; 3])), self.size.get(e as usize).copied().unwrap_or(1.0))
|
||||
}
|
||||
|
||||
/// The smallest normalised corner determinant over the mesh, and the total energy.
|
||||
pub fn measure(&self) -> (f64, f64) {
|
||||
let (mut m, mut total) = (f64::INFINITY, 0.0);
|
||||
for e in 0..self.hexes.len() as u32 {
|
||||
let (x, s) = self.element(e);
|
||||
for c in 0..SAMPLES {
|
||||
let j = sample_jacobian(&x, c, s);
|
||||
m = m.min(det3(&j));
|
||||
total += s * s * s * corner_energy(&j, self.eps);
|
||||
}
|
||||
}
|
||||
(m, total)
|
||||
}
|
||||
|
||||
/// Where vertex `v`'s feature is closest to `p` (`None` for a free vertex).
|
||||
pub fn target(&self, v: u32, p: P) -> Option<P> {
|
||||
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
|
||||
Constraint::Free => None,
|
||||
Constraint::Corner(c) => Some(self.geom.xyz(c)),
|
||||
Constraint::Curve(c) => self.geom.nearest_on_curve(c, p).map(|x| x.0),
|
||||
Constraint::Patch(pt) => self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)).map(|x| x.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fitting weight of vertex `v`: the fit factor times its elements' size (so a distance
|
||||
/// of one element weighs like the shape of its corners), ten times more for a corner.
|
||||
fn fit_weight(&self, v: u32) -> f64 {
|
||||
let a = self.around.get(v as usize).map_or(&[][..], |a| a.as_slice());
|
||||
if a.is_empty() || self.fit == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let s = a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(1.0)).sum::<f64>() / a.len() as f64;
|
||||
let corner = if matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))) { 10.0 } else { 1.0 };
|
||||
8.0 * self.fit * corner * s
|
||||
}
|
||||
|
||||
/// The energy of the corners that involve vertex `v`, with its fitting term.
|
||||
fn local_energy(&self, v: u32) -> f64 {
|
||||
let mut total = 0.0;
|
||||
if self.fit > 0.0 {
|
||||
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
if let Some(t) = self.target(v, p) {
|
||||
total += self.fit_weight(v) * dist2(p, t);
|
||||
}
|
||||
}
|
||||
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
|
||||
let (x, s) = self.element(e);
|
||||
let (n, _) = CORNER_FRAMES[k as usize];
|
||||
for c in [k as usize, n[0], n[1], n[2], 8] {
|
||||
total += s * s * s * corner_energy(&sample_jacobian(&x, c, s), self.eps);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The gradient and approximate Hessian of the local energy at vertex `v`.
|
||||
fn derivatives(&self, v: u32) -> (P, [[f64; 3]; 3]) {
|
||||
let mut g = [0.0; 3];
|
||||
let mut h = [[0.0; 3]; 3];
|
||||
for &(e, k) in self.around.get(v as usize).map_or(&[][..], |a| a.as_slice()) {
|
||||
let (x, s) = self.element(e);
|
||||
let k = k as usize;
|
||||
let w = s * s * s;
|
||||
let (nk, _) = CORNER_FRAMES[k];
|
||||
for c in [k, nk[0], nk[1], nk[2], 8] {
|
||||
// ∂ column a / ∂ x_v = coef[a] I.
|
||||
let wt = sample_weights(c);
|
||||
let coef: [f64; 3] = [0, 1, 2].map(|a| wt[a][k] / s);
|
||||
let j = sample_jacobian(&x, c, s);
|
||||
let d = det3(&j);
|
||||
let ch = chi(d, self.eps);
|
||||
let chd = chi_d(d, self.eps);
|
||||
let fro: f64 = j.iter().map(|q| dot(*q, *q)).sum();
|
||||
let cof = cofactors(&j);
|
||||
let root = fro.sqrt();
|
||||
// dE/dJ = A J + B cof(J).
|
||||
let a_coef = 3.0 * root / ch;
|
||||
let f_d = -fro * root * chd / (ch * ch);
|
||||
let g_d = 2.0 * d / ch - (d * d + 1.0) * chd / (ch * ch);
|
||||
let b_coef = f_d + LAMBDA * g_d;
|
||||
let mut dd = [0.0; 3];
|
||||
let mut sum_c2 = 0.0;
|
||||
for a in 0..3 {
|
||||
if coef[a] == 0.0 {
|
||||
continue;
|
||||
}
|
||||
sum_c2 += coef[a] * coef[a];
|
||||
for r in 0..3 {
|
||||
g[r] += w * coef[a] * (a_coef * j[a][r] + b_coef * cof[a][r]);
|
||||
dd[r] += coef[a] * cof[a][r];
|
||||
}
|
||||
}
|
||||
// Positive part of the second derivative along D, χ'' and det'' dropped.
|
||||
let f_dd = 2.0 * fro * root * chd * chd / (ch * ch * ch);
|
||||
let g_dd = 2.0 / ch - 4.0 * d * chd / (ch * ch) + 2.0 * (d * d + 1.0) * chd * chd / (ch * ch * ch);
|
||||
let k_dd = (f_dd + LAMBDA * g_dd).max(0.0);
|
||||
for r in 0..3 {
|
||||
h[r][r] += w * a_coef * sum_c2;
|
||||
for q in 0..3 {
|
||||
h[r][q] += w * k_dd * dd[r] * dd[q];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.fit > 0.0 {
|
||||
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
if let Some(t) = self.target(v, p) {
|
||||
let w = self.fit_weight(v);
|
||||
for r in 0..3 {
|
||||
g[r] += 2.0 * w * (p[r] - t[r]);
|
||||
h[r][r] += 2.0 * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
(g, h)
|
||||
}
|
||||
|
||||
/// Project a point onto vertex `v`'s constraint.
|
||||
fn project(&self, v: u32, p: P) -> P {
|
||||
self.target(v, p).unwrap_or(p)
|
||||
}
|
||||
|
||||
/// The direction a constrained vertex may move along: the step restricted to its tangent.
|
||||
fn restrict(&self, v: u32, d: P) -> P {
|
||||
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
match self.constraint.get(v as usize).copied().unwrap_or(Constraint::Free) {
|
||||
Constraint::Free => d,
|
||||
Constraint::Corner(_) => [0.0; 3],
|
||||
Constraint::Curve(c) => {
|
||||
let Some((_, i, _)) = self.geom.nearest_on_curve(c, p) else { return [0.0; 3] };
|
||||
let Some(cv) = self.geom.curves.get(c as usize) else { return [0.0; 3] };
|
||||
let (a, b) = (cv.vertices.get(i).copied().unwrap_or(0), cv.vertices.get(i + 1).copied().unwrap_or(0));
|
||||
let t = sub(self.geom.xyz(b), self.geom.xyz(a));
|
||||
let l2 = dot(t, t);
|
||||
if l2 > 0.0 { t.map(|x| x * dot(d, t) / l2) } else { [0.0; 3] }
|
||||
}
|
||||
Constraint::Patch(pt) => {
|
||||
let Some((t, _, _)) = self.geom.nearest(p, |t| self.geom.tri_patch.get(t as usize) == Some(&pt)) else { return [0.0; 3] };
|
||||
let n = self.geom.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
let dn = dot(d, n);
|
||||
[0, 1, 2].map(|k| d[k] - dn * n[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Newton step of vertex `v`; whether it moved.
|
||||
fn relax(&mut self, v: u32) -> bool {
|
||||
let soft = self.fit > 0.0;
|
||||
if (!soft && matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_))))
|
||||
|| self.around.get(v as usize).is_none_or(|a| a.is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (g, h) = self.derivatives(v);
|
||||
let Some(step) = solve3(h, g.map(|x| -x)) else { return false };
|
||||
let step = if soft { step } else { self.restrict(v, step) };
|
||||
if !step.iter().all(|x| x.is_finite()) || norm(step) == 0.0 {
|
||||
return false;
|
||||
}
|
||||
let old = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
let e0 = self.local_energy(v);
|
||||
let mut tau = 1.0;
|
||||
for _ in 0..12 {
|
||||
let moved = [0, 1, 2].map(|k| old[k] + tau * step[k]);
|
||||
let trial = if soft { moved } else { self.project(v, moved) };
|
||||
if let Some(p) = self.points.get_mut(v as usize) {
|
||||
*p = trial;
|
||||
}
|
||||
let e1 = self.local_energy(v);
|
||||
if e1 < e0 && e1.is_finite() {
|
||||
return true;
|
||||
}
|
||||
tau *= 0.5;
|
||||
}
|
||||
if let Some(p) = self.points.get_mut(v as usize) {
|
||||
*p = old;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Set the regularisation from the smallest corner determinant (Garanzha et al. 2021: ε
|
||||
/// shrinks as the mesh untangles); that determinant and the energy at the new ε.
|
||||
fn update_eps(&mut self) -> (f64, f64) {
|
||||
let (min_d, energy) = self.measure();
|
||||
let old = self.eps;
|
||||
self.eps = (1e-12 + 0.04 * min_d.min(0.0).powi(2)).sqrt();
|
||||
(min_d, if self.eps == old { energy } else { self.measure().1 })
|
||||
}
|
||||
|
||||
/// Sweep all vertices until every corner is positive and the energy settles, or `max_sweeps`;
|
||||
/// the sweeps run.
|
||||
pub fn run(&mut self, max_sweeps: usize, tolerance: f64) -> usize {
|
||||
let n = self.points.len() as u32;
|
||||
let (_, mut energy) = self.update_eps();
|
||||
let mut sweeps = 0;
|
||||
for sweep in 0..max_sweeps {
|
||||
let mut moved = 0usize;
|
||||
for v in 0..n {
|
||||
if self.relax(v) {
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
let old_eps = self.eps;
|
||||
let (min_d, e) = self.update_eps();
|
||||
sweeps = sweep + 1;
|
||||
let settled = old_eps == self.eps && (energy - e).abs() <= tolerance * energy.abs();
|
||||
energy = e;
|
||||
if moved == 0 || (min_d > 0.0 && settled) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sweeps
|
||||
}
|
||||
}
|
||||
|
||||
impl Optimizer<'_> {
|
||||
/// The smallest corner scaled Jacobian of the elements around vertex `v`.
|
||||
fn local_quality(&self, v: u32) -> f64 {
|
||||
self.around
|
||||
.get(v as usize)
|
||||
.map_or(f64::INFINITY, |a| a.iter().map(|&(e, _)| min_scaled_jacobian(&self.element(e).0)).fold(f64::INFINITY, f64::min))
|
||||
}
|
||||
|
||||
/// Move every constrained vertex exactly onto its feature where its elements keep a corner
|
||||
/// scaled Jacobian of at least `floor`; the others stay where the fit left them. Returns
|
||||
/// how many stayed off and the largest distance of a constrained vertex from its feature.
|
||||
pub fn snap(&mut self, floor: f64) -> (usize, f64) {
|
||||
let (mut off, mut worst) = (0usize, 0.0f64);
|
||||
for v in 0..self.points.len() as u32 {
|
||||
let p = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
let Some(t) = self.target(v, p) else { continue };
|
||||
if p == t {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = self.points.get_mut(v as usize) {
|
||||
*x = t;
|
||||
}
|
||||
if self.local_quality(v) < floor {
|
||||
if let Some(x) = self.points.get_mut(v as usize) {
|
||||
*x = p;
|
||||
}
|
||||
off += 1;
|
||||
worst = worst.max(dist2(p, t).sqrt());
|
||||
}
|
||||
}
|
||||
(off, worst)
|
||||
}
|
||||
|
||||
/// Raise the worst elements: every vertex of an element whose corner scaled Jacobian is
|
||||
/// below `threshold` moves, within its constraint, by a compass search that maximises the
|
||||
/// smallest scaled Jacobian around it (steps shrink by halves; at most `passes` rounds over
|
||||
/// the worst elements, 12 step sizes per vertex).
|
||||
pub fn improve_worst(&mut self, threshold: f64, passes: usize) {
|
||||
for _ in 0..passes {
|
||||
let mut verts: Vec<u32> = Vec::new();
|
||||
for (e, h) in self.hexes.iter().enumerate() {
|
||||
if min_scaled_jacobian(&self.element(e as u32).0) < threshold {
|
||||
verts.extend(h.iter().copied());
|
||||
}
|
||||
}
|
||||
verts.sort_unstable();
|
||||
verts.dedup();
|
||||
if verts.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut moved = false;
|
||||
for &v in &verts {
|
||||
if matches!(self.constraint.get(v as usize), Some(Constraint::Free)) {
|
||||
moved |= self.compass(v, &|o: &Self, v| o.local_quality(v));
|
||||
}
|
||||
}
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compass search on vertex `v` (within its constraint) for the largest `quality`; steps
|
||||
/// start at 0.15 of its largest element size and halve when no direction improves, 12 times
|
||||
/// at most. Whether it moved.
|
||||
fn compass(&mut self, v: u32, quality: &dyn Fn(&Self, u32) -> f64) -> bool {
|
||||
let p0 = self.points.get(v as usize).copied().unwrap_or([0.0; 3]);
|
||||
let reach = self
|
||||
.around
|
||||
.get(v as usize)
|
||||
.map_or(0.0, |a| a.iter().map(|&(e, _)| self.size.get(e as usize).copied().unwrap_or(0.0)).fold(0.0, f64::max));
|
||||
let mut step = 0.15 * reach;
|
||||
let mut best = quality(self, v);
|
||||
let mut cur = p0;
|
||||
let mut moved = false;
|
||||
for _ in 0..12 {
|
||||
if step <= 1e-9 * reach.max(1e-300) {
|
||||
break;
|
||||
}
|
||||
let mut improved = false;
|
||||
for d in [[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]] {
|
||||
let dir = self.restrict(v, d.map(|x| x * step));
|
||||
if norm(dir) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let trial = self.project(v, [0, 1, 2].map(|k| cur[k] + dir[k]));
|
||||
if let Some(p) = self.points.get_mut(v as usize) {
|
||||
*p = trial;
|
||||
}
|
||||
let q = quality(self, v);
|
||||
if q > best + 1e-12 {
|
||||
best = q;
|
||||
cur = trial;
|
||||
improved = true;
|
||||
moved = true;
|
||||
} else if let Some(p) = self.points.get_mut(v as usize) {
|
||||
*p = cur;
|
||||
}
|
||||
}
|
||||
if !improved {
|
||||
step *= 0.5;
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.points.get_mut(v as usize) {
|
||||
*p = cur;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// The smallest certified Jacobian ratio of the elements around `v` (negative where the
|
||||
/// trilinear map folds somewhere, even with positive corners).
|
||||
fn certified_quality(&self, v: u32) -> f64 {
|
||||
self.around.get(v as usize).map_or(f64::INFINITY, |a| {
|
||||
a.iter()
|
||||
.map(|&(e, _)| {
|
||||
let x = self.element(e).0;
|
||||
if min_scaled_jacobian(&x) <= 0.0 {
|
||||
return -1e9;
|
||||
}
|
||||
element_validity(ElementKind::Hex8, &x).map_or(-1.0, |c| match c.validity {
|
||||
Validity::Valid => c.ratio.max(1e-300),
|
||||
_ => c.ratio.min(0.0) - 1e-12,
|
||||
})
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
}
|
||||
|
||||
/// Repair elements whose trilinear map is not proven positive although their corners are:
|
||||
/// the vertices of each such element (free ones, and boundary ones along their features)
|
||||
/// move by compass search to raise the smallest certified bound around them; `rounds` rounds
|
||||
/// at most. Returns the elements still not proven valid.
|
||||
pub fn repair_certified(&mut self, rounds: usize) -> Vec<u32> {
|
||||
let check = |o: &Self, e: u32| element_validity(ElementKind::Hex8, &o.element(e).0).is_ok_and(|c| c.validity == Validity::Valid);
|
||||
let mut bad: Vec<u32> = (0..self.hexes.len() as u32).filter(|&e| !check(self, e)).collect();
|
||||
for _ in 0..rounds {
|
||||
if bad.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut verts: Vec<u32> = bad.iter().flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8])).collect();
|
||||
verts.sort_unstable();
|
||||
verts.dedup();
|
||||
for v in verts {
|
||||
if !matches!(self.constraint.get(v as usize), Some(Constraint::Corner(_)) | None) {
|
||||
self.compass(v, &|o: &Self, v| o.certified_quality(v));
|
||||
}
|
||||
}
|
||||
let mut around: Vec<u32> = bad
|
||||
.iter()
|
||||
.flat_map(|&e| self.hexes.get(e as usize).copied().unwrap_or([0; 8]))
|
||||
.flat_map(|v| self.around.get(v as usize).map_or(Vec::new(), |a| a.iter().map(|x| x.0).collect()))
|
||||
.collect();
|
||||
around.sort_unstable();
|
||||
around.dedup();
|
||||
bad = around.into_iter().filter(|&e| !check(self, e)).collect();
|
||||
}
|
||||
bad
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the symmetric 3 × 3 system `h x = b` (Cramer), `None` when singular.
|
||||
fn solve3(h: [[f64; 3]; 3], b: P) -> Option<P> {
|
||||
let d = dot(h[0], cross(h[1], h[2]));
|
||||
if !(d.abs() > 1e-300) || !d.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let col = |k: usize| [h[0][k], h[1][k], h[2][k]];
|
||||
let (c0, c1, c2) = (col(0), col(1), col(2));
|
||||
Some([dot(b, cross(c1, c2)) / d, dot(c0, cross(b, c2)) / d, dot(c0, cross(c1, b)) / d])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::octree::geom::tests::box_surface;
|
||||
|
||||
#[test]
|
||||
fn corner_frames_are_right_handed_on_the_unit_cube() {
|
||||
let cube: [P; 8] = [[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 c in 0..8 {
|
||||
let j = corner_jacobian(&cube, c, 1.0);
|
||||
assert!((det3(&j) - 1.0).abs() < 1e-15, "corner {c}");
|
||||
}
|
||||
assert!((min_scaled_jacobian(&cube) - 1.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tangled_interior_vertex_is_untangled() {
|
||||
// A 2 × 2 × 2 block of cubes with the centre vertex pushed through a face; the boundary
|
||||
// fixed.
|
||||
let s = box_surface([0.0; 3], [2.0; 3]);
|
||||
let g = Geometry::new(&s, 30.0, 160.0).unwrap();
|
||||
let id = |x: u32, y: u32, z: u32| x + 3 * (y + 3 * z);
|
||||
let mut p = Vec::new();
|
||||
for z in 0..3 {
|
||||
for y in 0..3 {
|
||||
for x in 0..3 {
|
||||
p.push([x as f64, y as f64, z as f64]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut h = Vec::new();
|
||||
for z in 0..2 {
|
||||
for y in 0..2 {
|
||||
for x in 0..2 {
|
||||
h.push([
|
||||
id(x, y, z),
|
||||
id(x + 1, y, z),
|
||||
id(x + 1, y + 1, z),
|
||||
id(x, y + 1, z),
|
||||
id(x, y, z + 1),
|
||||
id(x + 1, y, z + 1),
|
||||
id(x + 1, y + 1, z + 1),
|
||||
id(x, y + 1, z + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let centre = id(1, 1, 1) as usize;
|
||||
p[centre] = [1.7, 1.9, 0.2];
|
||||
let size = vec![1.0; h.len()];
|
||||
// Boundary vertices slide on the box's faces (hard constraints, patch of the nearest
|
||||
// face), the centre is free.
|
||||
let cons: Vec<Constraint> = (0..p.len())
|
||||
.map(|i| {
|
||||
if i == centre {
|
||||
return Constraint::Free;
|
||||
}
|
||||
let t = g.nearest(p[i], |_| true).unwrap().0;
|
||||
Constraint::Patch(g.tri_patch[t as usize])
|
||||
})
|
||||
.collect();
|
||||
let mut o = Optimizer::new(&g, p, &h, &size, cons);
|
||||
assert!(o.measure().0 < 0.0);
|
||||
let st = o.run(200, 1e-9);
|
||||
assert!(o.measure().0 > 0.9, "{st:?}");
|
||||
assert!(sub(o.points[centre], [1.0; 3]).iter().all(|c| c.abs() < 1e-3), "{:?}", o.points[centre]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! The 3-refinement transition templates: how a leaf of the balanced tree is cut into
|
||||
//! hexahedra so that it conforms to finer neighbours.
|
||||
//!
|
||||
//! Every vertex of a leaf is *marked* when a finer leaf touches it. Under strong balance the
|
||||
//! marked set of a leaf is one of five patterns (after the closure in [`super::grid`] has split
|
||||
//! every leaf that had another one), each with one template:
|
||||
//!
|
||||
//! | marks | template | hexahedra |
|
||||
//! |---|---|---|
|
||||
//! | none | the leaf itself | 1 |
|
||||
//! | one corner | corner cube and three blocks fanning to the far corner | 4 |
|
||||
//! | the two ends of an edge | three cubes along the edge and two four-hex slabs | 11 |
|
||||
//! | the four corners of a face | nine cubes on the face, nine above them, four on top | 22 |
|
||||
//! | all eight | the 3 × 3 × 3 split | 27 |
|
||||
//!
|
||||
//! A face of a leaf is cut according to its own marks only (none, one corner, one edge, all
|
||||
//! four), and an edge into thirds at its marked ends, so two leaves sharing a face cut it the same
|
||||
//! way: the mesh is conforming by construction. This is the node-marking form of Schneiders'
|
||||
//! 3-refinement (R. Schneiders, *Refining quadrilateral and hexahedral element meshes*, NUMGRID
|
||||
//! 1996; Y. Ito, A. M. Shih, B. K. Soni, *Octree-based reasonable-quality hexahedral mesh
|
||||
//! generation using a new set of refinement templates*, IJNME 77(13):1809–1833, 2009). The
|
||||
//! templates are our own construction from those papers' description: the face template's top
|
||||
//! block is the 3 × 3 → 1 transition traced from its dual sheets, the edge template is two
|
||||
//! copies of the planar 3 → 1 transition swept across the leaf, and the tests prove every
|
||||
//! template's faces, volume and validity.
|
||||
//!
|
||||
//! Coordinates are integers in sixths of the leaf's edge, so every vertex shared with a neighbour
|
||||
//! lies on the global integer lattice of [`super::grid`] and coincident vertices merge exactly.
|
||||
|
||||
/// A point of a leaf in sixths of its edge, `0..=6` per axis.
|
||||
pub(crate) type Local = [i64; 3];
|
||||
/// A hexahedron in the model's node order (corners 1-2-3-4 then 5-6-7-8 above them).
|
||||
pub(crate) type Hex = [Local; 8];
|
||||
|
||||
/// The marked-corner pattern of a leaf, up to symmetry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Pattern {
|
||||
None,
|
||||
Corner,
|
||||
Edge,
|
||||
Face,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// A symmetry of the cube: coordinate `k` of the image is coordinate `perm[k]` of the point,
|
||||
/// reflected (`6 − v`) when `flip[k]`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Sym {
|
||||
perm: [usize; 3],
|
||||
flip: [bool; 3],
|
||||
}
|
||||
|
||||
impl Sym {
|
||||
pub(crate) fn apply(&self, p: Local) -> Local {
|
||||
let mut q = [0; 3];
|
||||
for (k, out) in q.iter_mut().enumerate() {
|
||||
let v = p[self.perm[k]];
|
||||
*out = if self.flip[k] { 6 - v } else { v };
|
||||
}
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// The 48 symmetries in a fixed order.
|
||||
pub(crate) fn symmetries() -> Vec<Sym> {
|
||||
let perms = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
|
||||
let mut out = Vec::with_capacity(48);
|
||||
for perm in perms {
|
||||
for bits in 0..8u8 {
|
||||
out.push(Sym { perm, flip: [bits & 1 != 0, bits & 2 != 0, bits & 4 != 0] });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The leaf corner `c` (bit 0: x, bit 1: y, bit 2: z) in sixths.
|
||||
pub(crate) fn corner(c: usize) -> Local {
|
||||
[(c & 1) as i64 * 6, ((c >> 1) & 1) as i64 * 6, ((c >> 2) & 1) as i64 * 6]
|
||||
}
|
||||
|
||||
/// The corner index of a corner point.
|
||||
fn corner_index(p: Local) -> usize {
|
||||
usize::from(p[0] == 6) | (usize::from(p[1] == 6) << 1) | (usize::from(p[2] == 6) << 2)
|
||||
}
|
||||
|
||||
/// The image of a corner mask under a symmetry.
|
||||
fn map_mask(sym: &Sym, mask: u8) -> u8 {
|
||||
(0..8).filter(|c| mask & (1 << c) != 0).fold(0u8, |m, c| m | (1 << corner_index(sym.apply(corner(c)))))
|
||||
}
|
||||
|
||||
/// The pattern of a corner mask, or `None` when no template fits it.
|
||||
pub(crate) fn classify(mask: u8) -> Option<Pattern> {
|
||||
let corners: Vec<Local> = (0..8).filter(|c| mask & (1 << c) != 0).map(corner).collect();
|
||||
let shared = |n: usize| (0..3).filter(|&k| corners.iter().all(|p| p[k] == corners[0][k])).count() == n;
|
||||
match corners.len() {
|
||||
0 => Some(Pattern::None),
|
||||
1 => Some(Pattern::Corner),
|
||||
2 if shared(2) => Some(Pattern::Edge),
|
||||
4 if shared(1) => Some(Pattern::Face),
|
||||
8 => Some(Pattern::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical mask of a pattern: corner 0; edge 0–1 (along x); face z = 0.
|
||||
fn canonical_mask(p: Pattern) -> u8 {
|
||||
match p {
|
||||
Pattern::None => 0,
|
||||
Pattern::Corner => 0b1,
|
||||
Pattern::Edge => 0b11,
|
||||
Pattern::Face => 0b1111,
|
||||
Pattern::Full => 0xff,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first symmetry (in [`symmetries`] order) taking the pattern's canonical mask to `mask`.
|
||||
pub(crate) fn orientation(syms: &[Sym], pattern: Pattern, mask: u8) -> Option<Sym> {
|
||||
let c = canonical_mask(pattern);
|
||||
syms.iter().find(|s| map_mask(s, c) == mask).copied()
|
||||
}
|
||||
|
||||
/// Thirds to sixths.
|
||||
fn t(x: i64, y: i64, z: i64) -> Local {
|
||||
[2 * x, 2 * y, 2 * z]
|
||||
}
|
||||
|
||||
/// A thirds-aligned cube with lower corner `(x, y, z)` (in thirds) and edge `e` thirds.
|
||||
fn cube(x: i64, y: i64, z: i64, e: i64) -> Hex {
|
||||
let (a, b) = (t(x, y, z), t(x + e, y + e, z + e));
|
||||
[
|
||||
[a[0], a[1], a[2]],
|
||||
[b[0], a[1], a[2]],
|
||||
[b[0], b[1], a[2]],
|
||||
[a[0], b[1], a[2]],
|
||||
[a[0], a[1], b[2]],
|
||||
[b[0], a[1], b[2]],
|
||||
[b[0], b[1], b[2]],
|
||||
[a[0], b[1], b[2]],
|
||||
]
|
||||
}
|
||||
|
||||
/// The planar 3 → 1 transition in `(x, s)`: x in thirds, `s` in halves of the transition height,
|
||||
/// three segments at `s = 0` and one at `s = 2`.
|
||||
const THREE_TO_ONE: [[(i64, i64); 4]; 4] =
|
||||
[[(0, 0), (1, 0), (1, 1), (0, 2)], [(1, 0), (2, 0), (2, 1), (1, 1)], [(2, 0), (3, 0), (3, 2), (2, 1)], [(1, 1), (2, 1), (3, 2), (0, 2)]];
|
||||
|
||||
/// The canonical template of a pattern, in sixths of the leaf.
|
||||
pub(crate) fn template(p: Pattern) -> Vec<Hex> {
|
||||
match p {
|
||||
Pattern::None => vec![cube(0, 0, 0, 3)],
|
||||
Pattern::Full => (0..27).map(|k| cube(k % 3, (k / 3) % 3, k / 9, 1)).collect(),
|
||||
Pattern::Corner => vec![
|
||||
cube(0, 0, 0, 1),
|
||||
[t(1, 0, 0), t(3, 0, 0), t(3, 3, 0), t(1, 1, 0), t(1, 0, 1), t(3, 0, 3), t(3, 3, 3), t(1, 1, 1)],
|
||||
[t(0, 1, 0), t(1, 1, 0), t(3, 3, 0), t(0, 3, 0), t(0, 1, 1), t(1, 1, 1), t(3, 3, 3), t(0, 3, 3)],
|
||||
[t(0, 0, 1), t(1, 0, 1), t(1, 1, 1), t(0, 1, 1), t(0, 0, 3), t(3, 0, 3), t(3, 3, 3), t(0, 3, 3)],
|
||||
],
|
||||
Pattern::Edge => {
|
||||
let mut out: Vec<Hex> = (0..3).map(|i| cube(i, 0, 0, 1)).collect();
|
||||
// Two slabs over the edge column's faces y = 1 and z = 1, each the 3 → 1 transition
|
||||
// swept from the leaf face to the diagonal plane y = z.
|
||||
for q in THREE_TO_ONE {
|
||||
let b = |(x, s): (i64, i64), w: i64| t(x, 1 + s, w * (1 + s));
|
||||
let c = |(x, s): (i64, i64), w: i64| t(x, w * (1 + s), 1 + s);
|
||||
out.push([b(q[0], 0), b(q[1], 0), b(q[2], 0), b(q[3], 0), b(q[0], 1), b(q[1], 1), b(q[2], 1), b(q[3], 1)]);
|
||||
out.push([c(q[0], 0), c(q[1], 0), c(q[2], 0), c(q[3], 0), c(q[0], 1), c(q[1], 1), c(q[2], 1), c(q[3], 1)]);
|
||||
}
|
||||
out
|
||||
}
|
||||
Pattern::Face => {
|
||||
let mut out: Vec<Hex> = (0..9).map(|k| cube(k % 3, k / 3, 0, 1)).collect();
|
||||
// The layer above: its top is the corners raised to the leaf's top, the side nodes of
|
||||
// the 3 → 1 transitions at two thirds, the inside nodes at half height.
|
||||
let top = |a: i64, b: i64| -> Local {
|
||||
let edge = |v: i64| v == 0 || v == 3;
|
||||
match (edge(a), edge(b)) {
|
||||
(true, true) => [2 * a, 2 * b, 6],
|
||||
(false, false) => [2 * a, 2 * b, 3],
|
||||
_ => [2 * a, 2 * b, 4],
|
||||
}
|
||||
};
|
||||
for k in 0..9 {
|
||||
let (i, j) = (k % 3, k / 3);
|
||||
out.push([
|
||||
t(i, j, 1),
|
||||
t(i + 1, j, 1),
|
||||
t(i + 1, j + 1, 1),
|
||||
t(i, j + 1, 1),
|
||||
top(i, j),
|
||||
top(i + 1, j),
|
||||
top(i + 1, j + 1),
|
||||
top(i, j + 1),
|
||||
]);
|
||||
}
|
||||
// The four on top: the y-arch (low), the x-arch (high) and the two side blocks.
|
||||
out.push([[2, 2, 3], [4, 2, 3], [4, 4, 3], [2, 4, 3], [2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4]]);
|
||||
out.push([[2, 0, 4], [4, 0, 4], [4, 6, 4], [2, 6, 4], [0, 0, 6], [6, 0, 6], [6, 6, 6], [0, 6, 6]]);
|
||||
out.push([[2, 2, 3], [2, 4, 3], [2, 6, 4], [2, 0, 4], [0, 2, 4], [0, 4, 4], [0, 6, 6], [0, 0, 6]]);
|
||||
out.push([[4, 2, 3], [4, 4, 3], [4, 6, 4], [4, 0, 4], [6, 2, 4], [6, 4, 4], [6, 6, 6], [6, 0, 6]]);
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Six times the signed volume of the corner tetrahedron at node 0 (exact on the lattice).
|
||||
pub(crate) fn corner0_orientation(h: &[[i64; 3]; 8]) -> i128 {
|
||||
let e = |j: usize| [0, 1, 2].map(|k| i128::from(h[j][k] - h[0][k]));
|
||||
let (a, b, c) = (e(1), e(3), e(4));
|
||||
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
|
||||
}
|
||||
|
||||
/// The hexahedron with its orientation reversed (the same cell, mirrored node order).
|
||||
pub(crate) fn reversed<T: Copy>(h: [T; 8]) -> [T; 8] {
|
||||
[h[0], h[3], h[2], h[1], h[4], h[7], h[6], h[5]]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::*;
|
||||
use crate::quality::{Validity, element_validity};
|
||||
use fieldcraft_model::ElementKind;
|
||||
|
||||
/// The face of the leaf a quad lies on (axis, value), if any.
|
||||
fn on_leaf_face(q: &[Local; 4]) -> Option<(usize, i64)> {
|
||||
(0..3).find_map(|k| [0, 6].into_iter().find(|&v| q.iter().all(|p| p[k] == v)).map(|v| (k, v)))
|
||||
}
|
||||
|
||||
fn sorted(mut q: [Local; 4]) -> [Local; 4] {
|
||||
q.sort();
|
||||
q
|
||||
}
|
||||
|
||||
/// The quads a leaf face must carry for its four corner marks, as sorted vertex sets, on the
|
||||
/// face `(axis, value)`.
|
||||
fn face_template(axis: usize, value: i64, marks: [bool; 4]) -> Vec<[Local; 4]> {
|
||||
// Face coordinates (u, v) are the other two axes in increasing order; corners in order
|
||||
// (0,0), (6,0), (0,6), (6,6).
|
||||
let (ua, va) = match axis {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let lift = |u: i64, v: i64| {
|
||||
let mut p = [0; 3];
|
||||
p[axis] = value;
|
||||
p[ua] = u;
|
||||
p[va] = v;
|
||||
p
|
||||
};
|
||||
// Build in a canonical frame, then map the 2D symmetry that takes the canonical marks to
|
||||
// these marks.
|
||||
let count = marks.iter().filter(|m| **m).count();
|
||||
let canon: Vec<[(i64, i64); 4]> = match count {
|
||||
0 => vec![[(0, 0), (6, 0), (6, 6), (0, 6)]],
|
||||
1 => vec![[(0, 0), (2, 0), (2, 2), (0, 2)], [(2, 0), (6, 0), (6, 6), (2, 2)], [(0, 2), (2, 2), (6, 6), (0, 6)]],
|
||||
2 => {
|
||||
let mut v: Vec<[(i64, i64); 4]> = (0..3).map(|i| [(2 * i, 0), (2 * i + 2, 0), (2 * i + 2, 2), (2 * i, 2)]).collect();
|
||||
v.extend([
|
||||
[(0, 2), (2, 2), (2, 4), (0, 6)],
|
||||
[(2, 2), (4, 2), (4, 4), (2, 4)],
|
||||
[(4, 2), (6, 2), (6, 6), (4, 4)],
|
||||
[(2, 4), (4, 4), (6, 6), (0, 6)],
|
||||
]);
|
||||
v
|
||||
}
|
||||
_ => (0..9)
|
||||
.map(|k| {
|
||||
[(2 * (k % 3), 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3)), (2 * (k % 3) + 2, 2 * (k / 3) + 2), (2 * (k % 3), 2 * (k / 3) + 2)]
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let canon_marks: [bool; 4] = match count {
|
||||
0 => [false; 4],
|
||||
1 => [true, false, false, false],
|
||||
2 => [true, true, false, false],
|
||||
_ => [true; 4],
|
||||
};
|
||||
let maps: [fn(i64, i64) -> (i64, i64); 8] = [
|
||||
|u, v| (u, v),
|
||||
|u, v| (6 - u, v),
|
||||
|u, v| (u, 6 - v),
|
||||
|u, v| (6 - u, 6 - v),
|
||||
|u, v| (v, u),
|
||||
|u, v| (6 - v, u),
|
||||
|u, v| (v, 6 - u),
|
||||
|u, v| (6 - v, 6 - u),
|
||||
];
|
||||
let corners = [(0, 0), (6, 0), (0, 6), (6, 6)];
|
||||
let idx = |c: (i64, i64)| corners.iter().position(|&x| x == c).unwrap();
|
||||
let m =
|
||||
maps.iter().find(|m| (0..4).all(|c| canon_marks[c] == marks[idx(m(corners[c].0, corners[c].1))])).expect("a face symmetry for the marks");
|
||||
canon.iter().map(|q| sorted(q.map(|(u, v)| m(u, v)).map(|(u, v)| lift(u, v)))).collect()
|
||||
}
|
||||
|
||||
fn face_corner_marks(axis: usize, value: i64, mask: u8) -> [bool; 4] {
|
||||
let (ua, va) = match axis {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
[(0, 0), (6, 0), (0, 6), (6, 6)].map(|(u, v)| {
|
||||
let mut p = [0; 3];
|
||||
p[axis] = value;
|
||||
p[ua] = u;
|
||||
p[va] = v;
|
||||
mask & (1 << corner_index(p)) != 0
|
||||
})
|
||||
}
|
||||
|
||||
/// Every template under every symmetry: valid elements, the leaf's volume, closed (each inner
|
||||
/// face shared by two elements) and, on each leaf face, exactly the face template of that
|
||||
/// face's marks.
|
||||
#[test]
|
||||
fn templates_are_valid_closed_and_conform_to_their_face_marks() {
|
||||
let syms = symmetries();
|
||||
for pattern in [Pattern::None, Pattern::Corner, Pattern::Edge, Pattern::Face, Pattern::Full] {
|
||||
for sym in &syms {
|
||||
let mask = map_mask(sym, canonical_mask(pattern));
|
||||
assert_eq!(classify(mask), Some(pattern));
|
||||
let hexes: Vec<Hex> = template(pattern)
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
let h = h.map(|p| sym.apply(p));
|
||||
if corner0_orientation(&h) < 0 { reversed(h) } else { h }
|
||||
})
|
||||
.collect();
|
||||
let mut volume = 0.0;
|
||||
let mut faces: HashMap<[Local; 4], usize> = HashMap::new();
|
||||
for h in &hexes {
|
||||
let xyz: Vec<[f64; 3]> = h.iter().map(|p| p.map(|v| v as f64)).collect();
|
||||
let check = element_validity(ElementKind::Hex8, &xyz).unwrap();
|
||||
assert_eq!(check.validity, Validity::Valid, "{pattern:?} {sym:?} {h:?}");
|
||||
volume += check.volume;
|
||||
for f in ElementKind::Hex8.faces() {
|
||||
*faces.entry(sorted([h[f[0]], h[f[1]], h[f[2]], h[f[3]]])).or_default() += 1;
|
||||
}
|
||||
}
|
||||
assert!((volume - 216.0).abs() < 1e-9, "{pattern:?}: volume {volume}");
|
||||
let mut outer: HashMap<(usize, i64), Vec<[Local; 4]>> = HashMap::new();
|
||||
for (q, n) in &faces {
|
||||
match on_leaf_face(q) {
|
||||
Some(f) => {
|
||||
assert_eq!(*n, 1);
|
||||
outer.entry(f).or_default().push(*q);
|
||||
}
|
||||
None => assert_eq!(*n, 2, "{pattern:?} {sym:?}: inner face {q:?} used {n} times"),
|
||||
}
|
||||
}
|
||||
for axis in 0..3 {
|
||||
for value in [0, 6] {
|
||||
let mut got = outer.remove(&(axis, value)).unwrap_or_default();
|
||||
let mut want = face_template(axis, value, face_corner_marks(axis, value, mask));
|
||||
got.sort();
|
||||
want.sort();
|
||||
assert_eq!(got, want, "{pattern:?} {sym:?} face {axis}={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_without_a_template_are_recognised() {
|
||||
assert_eq!(classify(0b1001), None, "diagonal of a face");
|
||||
assert_eq!(classify(0b1000_0001), None, "opposite corners");
|
||||
assert_eq!(classify(0b0111), None, "three of a face");
|
||||
assert_eq!(classify(0b0110_1001), None, "four not on a face");
|
||||
assert_eq!(classify(0b0111_1111), None);
|
||||
assert_eq!(classify(0b0011_0011), Some(Pattern::Face));
|
||||
assert_eq!(classify(0b0101_0000), Some(Pattern::Edge));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use super::*;
|
||||
use crate::octree::geom::tests::box_surface;
|
||||
use crate::size::UniformSize;
|
||||
|
||||
#[test]
|
||||
fn a_box_meshes_with_exact_volume_and_tagged_faces() {
|
||||
let s = box_surface([0.0; 3], [10.0, 8.0, 6.0]);
|
||||
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
|
||||
let r = &m.report;
|
||||
assert!((r.volume - 480.0).abs() < 1e-9 * 480.0, "{r:?}");
|
||||
assert!(r.min_scaled_jacobian > 0.3, "{r:?}");
|
||||
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
|
||||
assert_eq!(faces.len(), 6);
|
||||
// Each boundary quad lies in the plane of its face.
|
||||
for b in &m.boundary {
|
||||
let hx = m.hexes[b.element as usize];
|
||||
let q = topo::FACES[(b.face - 1) as usize].map(|i| m.nodes[hx[i] as usize]);
|
||||
let (axis, value) = match b.brep_face {
|
||||
0 => (2, 0.0),
|
||||
1 => (2, 6.0),
|
||||
2 => (1, 0.0),
|
||||
3 => (1, 8.0),
|
||||
4 => (0, 0.0),
|
||||
_ => (0, 10.0),
|
||||
};
|
||||
assert!(q.iter().all(|p| (p[axis] - value).abs() < 1e-9), "{b:?} {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed cylinder of radius `r` and height `h` on the z axis, `n` segments: faces 0 (bottom
|
||||
/// cap), 1 (top cap), 2 (side).
|
||||
pub(crate) fn cylinder_surface(r: f64, h: f64, n: usize) -> BoundarySurface {
|
||||
let mut p = vec![[0.0, 0.0, 0.0], [0.0, 0.0, h]];
|
||||
for z in [0.0, h] {
|
||||
for i in 0..n {
|
||||
let a = std::f64::consts::TAU * i as f64 / n as f64;
|
||||
p.push([r * a.cos(), r * a.sin(), z]);
|
||||
}
|
||||
}
|
||||
let (b, t) = (|i: usize| (2 + i % n) as u32, |i: usize| (2 + n + i % n) as u32);
|
||||
let mut tris = Vec::new();
|
||||
let mut face = Vec::new();
|
||||
for i in 0..n {
|
||||
tris.push([0, b(i + 1), b(i)]);
|
||||
face.push(0);
|
||||
tris.push([1, t(i), t(i + 1)]);
|
||||
face.push(1);
|
||||
tris.push([b(i), b(i + 1), t(i + 1)]);
|
||||
tris.push([b(i), t(i + 1), t(i)]);
|
||||
face.extend([2, 2]);
|
||||
}
|
||||
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// A UV sphere of radius `r`, one face.
|
||||
pub(crate) fn sphere_surface(r: f64, n: usize) -> BoundarySurface {
|
||||
let rings = n / 2;
|
||||
let mut p = vec![[0.0, 0.0, -r], [0.0, 0.0, r]];
|
||||
for j in 1..rings {
|
||||
let th = std::f64::consts::PI * j as f64 / rings as f64 - std::f64::consts::FRAC_PI_2;
|
||||
for i in 0..n {
|
||||
let a = std::f64::consts::TAU * i as f64 / n as f64;
|
||||
p.push([r * th.cos() * a.cos(), r * th.cos() * a.sin(), r * th.sin()]);
|
||||
}
|
||||
}
|
||||
let id = |j: usize, i: usize| (2 + (j - 1) * n + i % n) as u32;
|
||||
let mut tris = Vec::new();
|
||||
for i in 0..n {
|
||||
tris.push([0, id(1, i + 1), id(1, i)]);
|
||||
tris.push([1, id(rings - 1, i), id(rings - 1, i + 1)]);
|
||||
}
|
||||
for j in 1..rings - 1 {
|
||||
for i in 0..n {
|
||||
tris.push([id(j, i), id(j, i + 1), id(j + 1, i + 1)]);
|
||||
tris.push([id(j, i), id(j + 1, i + 1), id(j + 1, i)]);
|
||||
}
|
||||
}
|
||||
let face = vec![0; tris.len()];
|
||||
BoundarySurface::new(&p, &tris, &face, &crate::surface::SurfaceOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cylinder_meshes_with_its_rims_captured() {
|
||||
let s = cylinder_surface(5.0, 8.0, 64);
|
||||
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
|
||||
let r = &m.report;
|
||||
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
|
||||
let faces: BTreeSet<u32> = m.boundary.iter().map(|b| b.brep_face).collect();
|
||||
assert_eq!(faces.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sphere_meshes() {
|
||||
let s = sphere_surface(5.0, 48);
|
||||
let m = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
|
||||
let r = &m.report;
|
||||
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Hexahedral mesh topology for the boundary stages: boundary faces, the manifold clean-up of
|
||||
//! the core, padding a boundary with a layer of hexahedra, and pillowing a shrink set.
|
||||
//!
|
||||
//! *Padding* puts one hexahedron on every boundary quad, between the quad and a copy of it, so
|
||||
//! no hexahedron keeps more than one face on the boundary. *Pillowing* a shrink set `S` inserts
|
||||
//! a layer of hexahedra along the faces between `S` and the rest of the mesh; the layer reaches
|
||||
//! the boundary along the loop where `S`'s boundary faces meet the others' (S. A. Mitchell,
|
||||
//! T. J. Tautges, *Pillowing doublets: refining a mesh to ensure that faces share at most one
|
||||
//! edge*, IMR 1995; Gao, Shen, Panozzo 2019, §3.4 use the same insertion around features).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The six faces of a hex8 as local node positions, right-hand normals outward (the model's
|
||||
/// `ElementKind::Hex8.faces()` order).
|
||||
pub(crate) const FACES: [[usize; 4]; 6] = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
|
||||
|
||||
/// A face key: the vertices sorted.
|
||||
pub(crate) fn key(q: [u32; 4]) -> [u32; 4] {
|
||||
let mut k = q;
|
||||
k.sort_unstable();
|
||||
k
|
||||
}
|
||||
|
||||
/// The root of `i` in the union-find forest `c`, compressing the path from `i`.
|
||||
pub(crate) fn find(c: &mut [usize], i: usize) -> usize {
|
||||
let mut r = i;
|
||||
while let Some(&p) = c.get(r).filter(|&&p| p != r) {
|
||||
r = p;
|
||||
}
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x = r;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A face of an element: (hexahedron, local face 0..6).
|
||||
pub(crate) type FaceRef = (u32, u8);
|
||||
|
||||
/// The vertices of a local face, outward.
|
||||
pub(crate) fn face(h: &[u32; 8], f: u8) -> [u32; 4] {
|
||||
FACES.get(f as usize).map_or([0; 4], |l| l.map(|i| h[i]))
|
||||
}
|
||||
|
||||
/// Every face's elements, keyed by sorted vertices (deterministic map).
|
||||
pub(crate) fn face_map(hexes: &[[u32; 8]]) -> BTreeMap<[u32; 4], Vec<FaceRef>> {
|
||||
let mut m: BTreeMap<[u32; 4], Vec<FaceRef>> = BTreeMap::new();
|
||||
for (i, h) in hexes.iter().enumerate() {
|
||||
for f in 0..6u8 {
|
||||
m.entry(key(face(h, f))).or_default().push((i as u32, f));
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The boundary faces (on one element only), outward, in face-map order.
|
||||
pub(crate) fn boundary(hexes: &[[u32; 8]]) -> Vec<(FaceRef, [u32; 4])> {
|
||||
face_map(hexes)
|
||||
.into_values()
|
||||
.filter(|v| v.len() == 1)
|
||||
.filter_map(|v| v.first().copied())
|
||||
.map(|(h, f)| ((h, f), hexes.get(h as usize).map_or([0; 4], |x| face(x, f))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Edges of a quad list with the quads using each (ascending vertex pair → quad indices).
|
||||
pub(crate) fn quad_edges(quads: &[[u32; 4]]) -> BTreeMap<(u32, u32), Vec<u32>> {
|
||||
let mut m: BTreeMap<(u32, u32), Vec<u32>> = BTreeMap::new();
|
||||
for (i, q) in quads.iter().enumerate() {
|
||||
for k in 0..4 {
|
||||
let (a, b) = (q[k], q[(k + 1) % 4]);
|
||||
m.entry((a.min(b), a.max(b))).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The hexahedra to drop so that the boundary of `keep` is a 2-manifold: elements at edges with
|
||||
/// four boundary faces (elements touching along an edge only) and at vertices whose boundary
|
||||
/// faces form more than one fan. Returns the indices to drop (empty when manifold).
|
||||
pub(crate) fn nonmanifold(hexes: &[[u32; 8]], keep: &[bool]) -> Vec<u32> {
|
||||
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
|
||||
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
|
||||
let b = boundary(&sub);
|
||||
let quads: Vec<[u32; 4]> = b.iter().map(|(_, q)| *q).collect();
|
||||
let mut bad_vertices: Vec<u32> = Vec::new();
|
||||
for ((a, c), qs) in quad_edges(&quads) {
|
||||
if qs.len() != 2 {
|
||||
bad_vertices.extend([a, c]);
|
||||
}
|
||||
}
|
||||
// Fans: around each vertex, boundary quads connected through shared edges at the vertex.
|
||||
let mut at: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||||
for (i, q) in quads.iter().enumerate() {
|
||||
for &v in q {
|
||||
at.entry(v).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
for (v, qs) in &at {
|
||||
let mut comp: Vec<usize> = (0..qs.len()).collect();
|
||||
for i in 0..qs.len() {
|
||||
for j in i + 1..qs.len() {
|
||||
let (qa, qb) = (quads[qs[i] as usize], quads[qs[j] as usize]);
|
||||
// Share an edge through v: another common vertex adjacent to v in both.
|
||||
let nb = |q: [u32; 4]| {
|
||||
let p = q.iter().position(|&x| x == *v).unwrap_or(0);
|
||||
[q[(p + 1) % 4], q[(p + 3) % 4]]
|
||||
};
|
||||
if nb(qa).iter().any(|x| nb(qb).contains(x)) {
|
||||
let (ri, rj) = (find(&mut comp, i), find(&mut comp, j));
|
||||
comp[ri] = rj;
|
||||
}
|
||||
}
|
||||
}
|
||||
let roots = (0..qs.len()).filter(|&i| find(&mut comp, i) == i).count();
|
||||
if roots > 1 {
|
||||
bad_vertices.push(*v);
|
||||
}
|
||||
}
|
||||
bad_vertices.sort_unstable();
|
||||
bad_vertices.dedup();
|
||||
if bad_vertices.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
kept.into_iter().filter(|&i| hexes.get(i as usize).is_some_and(|h| h.iter().any(|v| bad_vertices.binary_search(v).is_ok()))).collect()
|
||||
}
|
||||
|
||||
/// Pad: a hexahedron on each listed boundary face (outward), between it and a copy; the copies
|
||||
/// of the faces' vertices are new points (appended, initially at the original positions).
|
||||
/// Returns the copy of each original vertex.
|
||||
pub(crate) fn pad(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, faces: &[[u32; 4]]) -> Result<BTreeMap<u32, u32>> {
|
||||
let mut copy: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for q in faces {
|
||||
for &v in q {
|
||||
if let std::collections::btree_map::Entry::Vacant(slot) = copy.entry(v) {
|
||||
let n = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
|
||||
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
|
||||
slot.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for q in faces {
|
||||
let c = q.map(|v| copy.get(&v).copied().unwrap_or(v));
|
||||
hexes.push([q[0], q[1], q[2], q[3], c[0], c[1], c[2], c[3]]);
|
||||
}
|
||||
Ok(copy)
|
||||
}
|
||||
|
||||
/// One vertex copy made by [`pillow`]: the original, the copy, and the shrink-set elements
|
||||
/// around the original that now use the copy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PillowCopy {
|
||||
pub original: u32,
|
||||
pub copy: u32,
|
||||
pub elements: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Pillow the shrink set `inside` (one flag per element): a new layer of hexahedra on every face
|
||||
/// between an element of the set and one outside it. Vertices of those faces get one copy per
|
||||
/// group of set elements around them (connected through faces inside the set), which the set's
|
||||
/// elements now use; the new layer joins the originals to the copies. Faces on the boundary are
|
||||
/// not pillowed: where the layer meets the boundary, the set's boundary faces move to the copies,
|
||||
/// so the boundary gains a ring of quads along the loop. New points are appended at the
|
||||
/// originals' positions. Returns the copies made.
|
||||
pub(crate) fn pillow(points: &mut Vec<[f64; 3]>, hexes: &mut Vec<[u32; 8]>, inside: &[bool]) -> Result<Vec<PillowCopy>> {
|
||||
let fm = face_map(hexes);
|
||||
let in_set = |h: u32| inside.get(h as usize).copied().unwrap_or(false);
|
||||
// Faces to pillow, oriented from the outside element into the set.
|
||||
let mut sheet: Vec<(u32, [u32; 4])> = Vec::new();
|
||||
for refs in fm.values() {
|
||||
if let [(a, fa), (b, fb)] = refs.as_slice() {
|
||||
match (in_set(*a), in_set(*b)) {
|
||||
(true, false) => sheet.push((*a, hexes.get(*a as usize).map_or([0; 4], |h| face(h, *fa)))),
|
||||
(false, true) => sheet.push((*b, hexes.get(*b as usize).map_or([0; 4], |h| face(h, *fb)))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sheet.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut on_sheet: Vec<u32> = sheet.iter().flat_map(|(_, q)| *q).collect();
|
||||
on_sheet.sort_unstable();
|
||||
on_sheet.dedup();
|
||||
// Set elements around each sheet vertex.
|
||||
let mut around: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
for (i, h) in hexes.iter().enumerate() {
|
||||
if !in_set(i as u32) {
|
||||
continue;
|
||||
}
|
||||
for &v in h {
|
||||
if on_sheet.binary_search(&v).is_ok() {
|
||||
let e = around.entry(v).or_default();
|
||||
if e.last() != Some(&(i as u32)) {
|
||||
e.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut copies: Vec<PillowCopy> = Vec::new();
|
||||
// (vertex, element) → copy.
|
||||
let mut copy_of: HashMap<(u32, u32), u32> = HashMap::new();
|
||||
for &v in &on_sheet {
|
||||
let mut els = around.get(&v).cloned().unwrap_or_default();
|
||||
els.sort_unstable();
|
||||
els.dedup();
|
||||
// Group by faces inside the set that contain v.
|
||||
let mut group: Vec<usize> = (0..els.len()).collect();
|
||||
for i in 0..els.len() {
|
||||
for j in i + 1..els.len() {
|
||||
let (Some(hi), Some(hj)) = (hexes.get(els[i] as usize), hexes.get(els[j] as usize)) else { continue };
|
||||
let shares = (0..6u8).any(|f| {
|
||||
let q = face(hi, f);
|
||||
q.contains(&v) && (0..6u8).any(|g| key(face(hj, g)) == key(q))
|
||||
});
|
||||
if shares {
|
||||
let (a, b) = (find(&mut group, i), find(&mut group, j));
|
||||
group[a] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per group: its copy and that copy's index in `copies`.
|
||||
let mut made: BTreeMap<usize, (u32, usize)> = BTreeMap::new();
|
||||
for (i, &e) in els.iter().enumerate() {
|
||||
let r = find(&mut group, i);
|
||||
let (c, at) = match made.get(&r) {
|
||||
Some(&m) => m,
|
||||
None => {
|
||||
let c = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points".into()))?;
|
||||
points.push(points.get(v as usize).copied().unwrap_or([0.0; 3]));
|
||||
copies.push(PillowCopy { original: v, copy: c, elements: Vec::new() });
|
||||
made.insert(r, (c, copies.len() - 1));
|
||||
(c, copies.len() - 1)
|
||||
}
|
||||
};
|
||||
copy_of.insert((v, e), c);
|
||||
if let Some(pc) = copies.get_mut(at) {
|
||||
pc.elements.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The new layer: original face (seen from outside, normal into the set) below, copies above.
|
||||
let mut layer: Vec<[u32; 8]> = Vec::with_capacity(sheet.len());
|
||||
for (h, q) in &sheet {
|
||||
let r = [q[0], q[3], q[2], q[1]];
|
||||
let c = r.map(|v| copy_of.get(&(v, *h)).copied().unwrap_or(v));
|
||||
layer.push([r[0], r[1], r[2], r[3], c[0], c[1], c[2], c[3]]);
|
||||
}
|
||||
for (i, h) in hexes.iter_mut().enumerate() {
|
||||
if in_set(i as u32) {
|
||||
for v in h.iter_mut() {
|
||||
if let Some(&c) = copy_of.get(&(*v, i as u32)) {
|
||||
*v = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hexes.extend(layer);
|
||||
Ok(copies)
|
||||
}
|
||||
|
||||
/// Hexahedra to drop so that every vertex of the boundary of `keep` can be pushed outward: the
|
||||
/// normals of its boundary quads must lie strictly inside one half-space (their mean has a
|
||||
/// positive product with each, at least `margin`). Where this fails, padding from the vertex
|
||||
/// to the part's surface inverts elements, so for each such vertex the kept element at it with
|
||||
/// the most boundary faces is dropped (the buffer-zone rule of H. Tong, E. Halilaj, Y. J. Zhang,
|
||||
/// *HybridOctree_Hex*, arXiv 2401.05984, 2024, §2.3, in a mean-normal form).
|
||||
pub(crate) fn hidden_vertices(hexes: &[[u32; 8]], keep: &[bool], xyz: &dyn Fn(u32) -> [f64; 3], margin: f64) -> Vec<u32> {
|
||||
let kept: Vec<u32> = (0..hexes.len() as u32).filter(|&i| keep.get(i as usize).copied().unwrap_or(false)).collect();
|
||||
let sub: Vec<[u32; 8]> = kept.iter().filter_map(|&i| hexes.get(i as usize).copied()).collect();
|
||||
let b = boundary(&sub);
|
||||
let mut normals: BTreeMap<u32, Vec<[f64; 3]>> = BTreeMap::new();
|
||||
let mut faces_of: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for ((h, _), q) in &b {
|
||||
let p = q.map(xyz);
|
||||
let d1 = [0, 1, 2].map(|k| p[2][k] - p[0][k]);
|
||||
let d2 = [0, 1, 2].map(|k| p[3][k] - p[1][k]);
|
||||
let n = [d1[1] * d2[2] - d1[2] * d2[1], d1[2] * d2[0] - d1[0] * d2[2], d1[0] * d2[1] - d1[1] * d2[0]];
|
||||
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if l > 0.0 {
|
||||
for &v in q {
|
||||
normals.entry(v).or_default().push(n.map(|c| c / l));
|
||||
}
|
||||
}
|
||||
*faces_of.entry(*h).or_default() += 1;
|
||||
}
|
||||
let mut drop: Vec<u32> = Vec::new();
|
||||
for (v, ns) in &normals {
|
||||
let mut m = [0.0; 3];
|
||||
for n in ns {
|
||||
for k in 0..3 {
|
||||
m[k] += n[k];
|
||||
}
|
||||
}
|
||||
let l = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
|
||||
let ok = l > 0.0 && ns.iter().all(|n| (n[0] * m[0] + n[1] * m[1] + n[2] * m[2]) / l > margin);
|
||||
if ok {
|
||||
continue;
|
||||
}
|
||||
// The kept element at v with the most boundary faces (lowest index on ties).
|
||||
let best = sub
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.contains(v))
|
||||
.max_by(|a, b| faces_of.get(&(a.0 as u32)).unwrap_or(&0).cmp(faces_of.get(&(b.0 as u32)).unwrap_or(&0)).then(b.0.cmp(&a.0)))
|
||||
.and_then(|(i, _)| kept.get(i).copied());
|
||||
if let Some(e) = best {
|
||||
drop.push(e);
|
||||
}
|
||||
}
|
||||
drop.sort_unstable();
|
||||
drop.dedup();
|
||||
drop
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn faces_follow_the_model_hex8_face_order() {
|
||||
let model = fieldcraft_model::ElementKind::Hex8.faces();
|
||||
assert_eq!(model.len(), FACES.len());
|
||||
for (m, f) in model.iter().zip(FACES) {
|
||||
assert_eq!(*m, &f[..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An `n × n × n` block of unit cubes.
|
||||
pub(crate) fn block(n: u32) -> (Vec<[f64; 3]>, Vec<[u32; 8]>) {
|
||||
let id = |x: u32, y: u32, z: u32| x + (n + 1) * (y + (n + 1) * z);
|
||||
let mut p = Vec::new();
|
||||
for z in 0..=n {
|
||||
for y in 0..=n {
|
||||
for x in 0..=n {
|
||||
p.push([x as f64, y as f64, z as f64]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut h = Vec::new();
|
||||
for z in 0..n {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
h.push([
|
||||
id(x, y, z),
|
||||
id(x + 1, y, z),
|
||||
id(x + 1, y + 1, z),
|
||||
id(x, y + 1, z),
|
||||
id(x, y, z + 1),
|
||||
id(x + 1, y, z + 1),
|
||||
id(x + 1, y + 1, z + 1),
|
||||
id(x, y + 1, z + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(p, h)
|
||||
}
|
||||
|
||||
fn closed(hexes: &[[u32; 8]]) {
|
||||
for (k, v) in face_map(hexes) {
|
||||
assert!(v.len() <= 2, "face {k:?} on {} elements", v.len());
|
||||
}
|
||||
let b = boundary(hexes);
|
||||
for (_, qs) in quad_edges(&b.iter().map(|x| x.1).collect::<Vec<_>>()) {
|
||||
assert_eq!(qs.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_and_pillowing_keep_a_closed_conforming_mesh() {
|
||||
let (mut p, mut h) = block(3);
|
||||
let b: Vec<[u32; 4]> = boundary(&h).into_iter().map(|x| x.1).collect();
|
||||
assert_eq!(b.len(), 54);
|
||||
let copy = pad(&mut p, &mut h, &b).unwrap();
|
||||
assert_eq!(copy.len(), 56);
|
||||
assert_eq!(h.len(), 27 + 54);
|
||||
closed(&h);
|
||||
// Pillow the padding hexes on the face z = 0 (outer faces down).
|
||||
let pad_start = 27;
|
||||
let inside: Vec<bool> = (0..h.len()).map(|i| i >= pad_start && (0..4).all(|k| p[h[i][k] as usize][2] == 0.0)).collect();
|
||||
assert_eq!(inside.iter().filter(|x| **x).count(), 9);
|
||||
let before = h.len();
|
||||
let copies = pillow(&mut p, &mut h, &inside).unwrap();
|
||||
closed(&h);
|
||||
// Sheet: the 9 inner faces plus the 12 side faces to the neighbouring padding.
|
||||
assert_eq!(h.len(), before + 9 + 12);
|
||||
// The boundary gains the ring: 54 + 12 quads.
|
||||
assert_eq!(boundary(&h).len(), 54 + 12);
|
||||
assert!(!copies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonmanifold_edges_and_vertices_are_found() {
|
||||
let (_, h) = block(2);
|
||||
// Two cubes touching along an edge.
|
||||
let keep = |ids: &[usize]| (0..8).map(|i| ids.contains(&i)).collect::<Vec<bool>>();
|
||||
assert!(!nonmanifold(&h, &keep(&[0, 3])).is_empty(), "edge contact");
|
||||
assert!(!nonmanifold(&h, &keep(&[0, 7])).is_empty(), "vertex contact");
|
||||
assert!(nonmanifold(&h, &keep(&[0, 1, 3])).is_empty());
|
||||
assert!(nonmanifold(&h, &keep(&[0, 1, 2, 3, 4, 5, 6, 7])).is_empty());
|
||||
}
|
||||
}
|
||||