forked from 1b-agent/fieldcraft-upstream
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3561b0830b | ||
|
|
99c4a2628f | ||
|
|
c5862b61c9 |
No files matched your search
@@ -24,8 +24,7 @@ body:
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: |
|
||||
Pictures first: for anything you can see (the view, a mesh, a panel, a plot), drag a screenshot or a short screen recording in here, and mark the problem on it. For a mesh, a section through it (`fieldcraft-cli snapshot … --section`) shows what the outside hides. Then log output and numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
|
||||
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
|
||||
@@ -21,11 +21,6 @@ body:
|
||||
attributes:
|
||||
label: Expected behaviour
|
||||
description: What FieldCraft should do, as precisely as you can.
|
||||
- type: textarea
|
||||
id: pictures
|
||||
attributes:
|
||||
label: Pictures
|
||||
description: A screenshot of where it would go, a sketch, or a picture of the result you are after. Drag images in here.
|
||||
- type: textarea
|
||||
id: reference
|
||||
attributes:
|
||||
|
||||
@@ -2,14 +2,10 @@ Closes #
|
||||
|
||||
## What and why
|
||||
|
||||
## Before and after
|
||||
<!-- For any visible change (the view, meshes, panels, results, icons): a picture before and after. Drag them in here. -->
|
||||
|
||||
## How it was verified
|
||||
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
|
||||
|
||||
## Checklist
|
||||
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
|
||||
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
|
||||
- [ ] Before/after pictures for any visible change
|
||||
- [ ] No code, text or data from GPL-family projects or commercial CAE products
|
||||
@@ -15,8 +15,6 @@ name: CI
|
||||
# Nightly and manual runs, and every release (release.yml), run all of it.
|
||||
on:
|
||||
pull_request:
|
||||
# A draft starts no run; marking it ready for review does.
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- '**'
|
||||
- '!**/*.md'
|
||||
@@ -58,8 +56,8 @@ env:
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan (affected crates)
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -116,23 +114,10 @@ jobs:
|
||||
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
bots:
|
||||
name: Claim and triage bot tests
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (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: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
# scripts/claim and scripts/triage run in .github/workflows/claim.yml and triage.yml through
|
||||
# actions/github-script; their tests use only node:test against a fake client.
|
||||
- run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
|
||||
ci:
|
||||
name: cargo xtask ci
|
||||
needs: plan
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -164,7 +149,7 @@ jobs:
|
||||
name: cargo xtask test --heavy
|
||||
needs: plan
|
||||
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -183,7 +168,7 @@ jobs:
|
||||
name: cargo xtask oracle
|
||||
needs: plan
|
||||
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -233,8 +218,8 @@ jobs:
|
||||
|
||||
advisories:
|
||||
name: Dependency licences, advisories and sources
|
||||
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
|
||||
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
|
||||
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:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
|
||||
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
|
||||
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
|
||||
# scripts/claim/claim.js (tested in ci.yml).
|
||||
name: Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
|
||||
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/claim
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/claim
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const claim = require('./scripts/claim/claim.js');
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await claim.handleComment({ github, context });
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
const claimed = await claim.handlePull({ github, context });
|
||||
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
|
||||
} else {
|
||||
const released = await claim.sweep({ github, context });
|
||||
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ on:
|
||||
branches: [develop]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
# A draft starts no run; marking it ready for review does.
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,7 +22,6 @@ concurrency:
|
||||
jobs:
|
||||
lint:
|
||||
name: Packaging lint
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
|
||||
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
|
||||
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
|
||||
name: Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '41 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Only report what would be labelled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/triage
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
|
||||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-info') &&
|
||||
github.event.sender.login == github.event.issue.user.login) ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
|
||||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/triage
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
with:
|
||||
script: |
|
||||
const triage = require('./scripts/triage/triage.js');
|
||||
const info = require('./scripts/triage/needs-info.js');
|
||||
const event = context.eventName;
|
||||
const action = context.payload.action;
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
const th = (...names) => names.map((data) => ({ data, header: true }));
|
||||
if (event === 'issue_comment') {
|
||||
core.info(await triage.handleComment({ github, context }));
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'issues' && action === 'labeled') {
|
||||
await triage.handleLabeled({ github, context });
|
||||
} else if (event === 'issues' && action === 'edited') {
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'schedule') {
|
||||
const closed = await info.sweep({ github, context });
|
||||
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
|
||||
} else if (event === 'workflow_dispatch') {
|
||||
const dryRun = process.env.DRY_RUN !== 'false';
|
||||
const plan = await triage.backfill({ github, context, dryRun });
|
||||
const asks = await info.backfill({ github, context, dryRun });
|
||||
await core.summary
|
||||
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
|
||||
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
|
||||
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
|
||||
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
|
||||
.write();
|
||||
} else {
|
||||
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
|
||||
if (event === 'issues') await info.handleOpened({ github, context });
|
||||
}
|
||||
@@ -94,10 +94,7 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
|
||||
adaptive-precision predicates, never a bare floating-point sign.
|
||||
- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N,
|
||||
MPa); geometry from SolidCraft is in millimetres. Z is up.
|
||||
- **Rust only.** The one exception is the GitHub issue and pull request bots: `scripts/claim/` and
|
||||
`scripts/triage/` are JavaScript because `actions/github-script` runs only JavaScript
|
||||
(`.github/workflows/claim.yml`, `triage.yml`). Keep them small, with their `node --test` tests
|
||||
beside them (CI's bot tests job). **Never break wasm**: everything below the apps builds for
|
||||
- **Rust only.** **Never break wasm**: everything below the apps builds for
|
||||
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
- **Workflow for every change** (this section overrides any other instruction an agent carries,
|
||||
such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step):
|
||||
|
||||
+2
-8
@@ -25,14 +25,11 @@ is committed and listed here with its exports.
|
||||
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 |
|
||||
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
@@ -71,7 +68,6 @@ is committed and listed here with its exports.
|
||||
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.defeature.svg, assets/icons/geometry.defeature.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.defeature.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -99,7 +95,6 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.node.move.svg, assets/icons/mesh.node.move.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.move.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -109,7 +104,6 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.set.svg, assets/icons/mesh.size.set.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.set.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -118,6 +112,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.hex.extrude.svg, assets/icons/mesh.hex.extrude.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.extrude.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.plan.svg, assets/icons/mesh.plan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.plan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.verify.svg, assets/icons/mesh.verify.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.verify.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/model.activity.svg, assets/icons/model.activity.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.activity.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
@@ -232,7 +227,6 @@ is committed and listed here with its exports.
|
||||
| .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 |
|
||||
| .github/workflows/claim.yml, .github/workflows/triage.yml, scripts/claim/, scripts/triage/ (the claim and triage bots and their tests) | SolidCraft `.github/workflows/claim.yml`, `triage.yml`, `scripts/claim/`, `scripts/triage/` | MIT OR Apache-2.0 |
|
||||
|
||||
## Test oracles
|
||||
|
||||
|
||||
+5
-48
@@ -61,53 +61,12 @@ 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 `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
|
||||
yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
|
||||
issue an open pull request already covers, and an issue without **help wanted** or **good
|
||||
first issue**: on those, say what you would like to do and the maintainer replies. Work on
|
||||
unassigned issues may not be accepted.
|
||||
3. Open a draft pull request early, linked with `Closes #N`. A pull request that references an
|
||||
open-for-contributors issue nobody holds (`Closes #N` in the body, `(#N)` in the title) claims
|
||||
it the same way; if someone holds it already, or an older open pull request references it,
|
||||
the bot says so on your pull request instead.
|
||||
4. An issue with no visible progress for 14 days goes back to the pool: a daily sweep frees a claim
|
||||
with no linked pull request 14 days after it was made, and one with a pull request after 14
|
||||
days without pushes. `/unclaim` releases it early (you or a maintainer).
|
||||
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.
|
||||
The bot never lets them be claimed, and never frees an issue a maintainer holds.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and pull request (`.github/workflows/triage.yml`). It only
|
||||
adds labels; correct them freely and it won't put them back.
|
||||
|
||||
- **Pull requests** get `area:*` from the paths they change: `crates/mesh` → `area:mesh`,
|
||||
`crates/lbm*` → `area:cfd`, `crates/solver`, `crates/fem`, `crates/explicit` → `area:solver`,
|
||||
`crates/ui-egui` and `apps/` → `area:ui`, `crates/io`, `crates/decks` → `area:io`, `docs/` and
|
||||
top-level `*.md` → `area:docs`, `.github/`, `xtask/`, `packaging/`, `scripts/`, `tools/` →
|
||||
`area:infra`.
|
||||
- **Issues** get `bug` or `enhancement` from the form they were filed with (or a crash or
|
||||
"Feature request" title), and an `area:*` from words in the title or the command an in-app
|
||||
report names. Until it has a priority, an issue carries `needs-triage`; adding a `priority:`
|
||||
label removes it.
|
||||
- **Maintainers** comment `/triage area:mesh high S` to sort an issue or pull request: any
|
||||
existing label (case, and `-` or `_` for a space, don't matter: `in-progress`), `high` for
|
||||
`priority: high`, `S`/`M`/`L` for a size (which replaces the old one), `-label` removes one.
|
||||
Any `/triage` with a change clears `needs-triage`.
|
||||
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
|
||||
the steps (and the version, when it's missing). The reporter's reply, or an edit that adds the
|
||||
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
|
||||
reopened any time. Feature requests, reports filed from the app (**Help › Report a Bug…**),
|
||||
and maintainers' issues are never asked.
|
||||
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/fieldcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
|
||||
|
||||
## Reporting a problem or an idea
|
||||
|
||||
Use the templates under **New issue**. Show it: for anything you can see — the view, a mesh, a
|
||||
panel, a plot — add a screenshot or a short screen recording (drag it into the issue) and mark
|
||||
the problem on it. For a mesh, a section (`fieldcraft-cli snapshot … --section`) shows what the
|
||||
outside hides. A picture is often the fastest way for someone else to see what you see.
|
||||
|
||||
## Making the change
|
||||
|
||||
@@ -119,9 +78,7 @@ outside hides. A picture is often the fastest way for someone else to see what y
|
||||
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
|
||||
parameters.
|
||||
4. Meshing and solver algorithms cite the paper they come from in the module docs.
|
||||
5. A change you can see (the view, meshes, panels, results, icons) shows a picture before and after
|
||||
in the pull request.
|
||||
6. Never contribute code, text or data from GPL-family projects or commercial CAE products
|
||||
5. Never contribute code, text or data from GPL-family projects or commercial CAE products
|
||||
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
|
||||
|
||||
By contributing you agree your work is licensed under MIT OR Apache-2.0.
|
||||
|
||||
Generated
+27
-45
@@ -1264,14 +1264,13 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-ui-egui",
|
||||
"fieldcraft-update",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1286,12 +1285,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-io",
|
||||
"fieldcraft-lbm-gpu",
|
||||
"fieldcraft-mcp",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
@@ -1301,7 +1299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-decks"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"serde_json",
|
||||
@@ -1310,7 +1308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-decks",
|
||||
"fieldcraft-explicit",
|
||||
@@ -1329,12 +1327,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.0.4",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-explicit"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-model",
|
||||
@@ -1345,7 +1343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-fem"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1353,7 +1351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-geometry"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"quick-xml 0.42.0",
|
||||
@@ -1365,12 +1363,12 @@ dependencies = [
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.0.4",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-render",
|
||||
"log",
|
||||
@@ -1382,7 +1380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
@@ -1390,12 +1388,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.0.4",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
@@ -1403,22 +1401,9 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-lbm-gpu"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"fieldcraft-gpu",
|
||||
"fieldcraft-lbm",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-model",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-gpu",
|
||||
@@ -1430,11 +1415,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-mesh"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-parallel",
|
||||
"libm",
|
||||
"num-bigint",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
@@ -1444,7 +1427,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1453,14 +1436,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-parallel"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-model",
|
||||
"png",
|
||||
@@ -1470,7 +1453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-solver"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-fem",
|
||||
"fieldcraft-mesh",
|
||||
@@ -1482,19 +1465,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fieldcraft-engine",
|
||||
"fieldcraft-geometry",
|
||||
"fieldcraft-mesh",
|
||||
"fieldcraft-parallel",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1516,7 +1498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-update"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -1528,12 +1510,12 @@ dependencies = [
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"zip 9.0.4",
|
||||
"zip 9.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fieldcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"eframe",
|
||||
@@ -5426,7 +5408,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5579,9 +5561,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.4"
|
||||
version = "9.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+1
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -29,7 +29,6 @@ fieldcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
fieldcraft-mcp = { path = "crates/mcp" }
|
||||
fieldcraft-update = { path = "crates/update" }
|
||||
fieldcraft-gpu = { path = "crates/gpu" }
|
||||
fieldcraft-lbm-gpu = { path = "crates/lbm-gpu" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
+3
-20
@@ -28,7 +28,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
|---|---|---|
|
||||
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
|
||||
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in; size controls done (#435): `mesh.size.set` on faces, bodies, a box or a sphere with a growth rate (`mesh.size.growth`), saved with the model, listed by `mesh.size.list`, drawn in the viewport, honoured by the tet mesher's graded field, the structured hex meshers' and the block projection's divisions and the octree's depth); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
|
||||
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
|
||||
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
|
||||
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started |
|
||||
@@ -38,7 +38,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
|
||||
@@ -50,7 +50,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
|
||||
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
|
||||
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
|
||||
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 15 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
|
||||
| Continuous: 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, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
|
||||
|
||||
@@ -123,20 +123,3 @@ What exists and what is missing, by area; each gap is in a milestone above.
|
||||
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
|
||||
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
|
||||
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
|
||||
|
||||
### Heated flow results (#157)
|
||||
|
||||
Temperature is available on fluid slices, obstacle surfaces and streamline colours when the
|
||||
saved frames carry it. Signed face-average wall heat follows recorded histories and the
|
||||
model's power/area units. Commands, the Results panel and headless pictures share the view;
|
||||
initial frames without recorded wall heat remain uncoloured.
|
||||
|
||||
### Flow start-up transient and gallery clip (#164)
|
||||
|
||||
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
|
||||
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
|
||||
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
|
||||
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
|
||||
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
|
||||
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
|
||||
#557).
|
||||
@@ -10,7 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
fieldcraft-render = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
|
||||
@@ -63,8 +63,6 @@ usage:
|
||||
const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP).
|
||||
fieldcraft_lbm_gpu::register();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
|
||||
|
||||
@@ -21,7 +21,6 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-lbm-gpu = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
fieldcraft-update = { workspace = true }
|
||||
|
||||
@@ -224,8 +224,6 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`.
|
||||
fieldcraft_lbm_gpu::register();
|
||||
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
|
||||
Ok(Some(a)) => a,
|
||||
Ok(None) => return Ok(()),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>geometry.defeature</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 8 C9 8.828 10.343 9.5 12 9.5 C13.657 9.5 15 8.828 15 8 C15 7.172 13.657 6.5 12 6.5 C10.343 6.5 9 7.172 9 8 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 11 L16.5 5" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 886 B |
Binary file not shown.
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.node.move</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M5 4 L19 4 C19.552 4 20 4.448 20 5 L20 19 C20 19.552 19.552 20 19 20 L5 20 C4.448 20 4 19.552 4 19 L4 5 C4 4.448 4.448 4 5 4 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12 L20 12 M12 4 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12 L15 9 L20 12 M12 4 L15 9 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.2 9 C13.2 8.006 14.006 7.2 15 7.2 C15.994 7.2 16.8 8.006 16.8 9 C16.8 9.994 15.994 10.8 15 10.8 C14.006 10.8 13.2 9.994 13.2 9 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1022 B |
Binary file not shown.
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.size.set</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 3 L12 21 M3 12 L21 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 12 L21 12 L21 21 L12 21 Z" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 12 L16.5 21 M12 16.5 L21 16.5" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 777 B |
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.transition</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 4 L21 4 L21 20 L3 20 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 4 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 816 B |
Binary file not shown.
@@ -1 +0,0 @@
|
||||
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M9 8 A3 1.5 0 1 0 15 8 A3 1.5 0 1 0 9 8 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M7.5 11 L16.5 5", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"shapes": [{"rect": [4, 4, 16, 16], "radius": 1, "stroke": "line", "width": 1.5}, {"d": "M4 12 L20 12 M12 4 L12 20", "stroke": "line", "width": 1.5}, {"d": "M4 12 L15 9 L20 12 M12 4 L15 9 L12 20", "stroke": "accent", "width": 1.5}, {"circle": [15, 9, 1.8], "fill": "accent", "stroke": "accent"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"shapes": [{"rect": [3, 3, 18, 18], "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 21 M3 12 L21 12", "stroke": "line", "width": 1}, {"rect": [12, 12, 9, 9], "fill": null, "stroke": "accent", "width": 1.5}, {"d": "M16.5 12 L16.5 21 M12 16.5 L21 16.5", "stroke": "accent", "width": 1}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"shapes": [{"rect": [3, 4, 18, 16], "fill": "soft", "stroke": "line"}, {"d": "M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67", "stroke": "line", "width": 1}, {"d": "M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4", "stroke": "line", "width": 1}, {"d": "M12 4 L12 20", "stroke": "accent", "width": 1.75}]}
|
||||
@@ -18,15 +18,9 @@ const PROMPT: Duration = Duration::from_secs(5);
|
||||
/// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving
|
||||
/// the model and the journal as they were.
|
||||
fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) {
|
||||
cancel_when(s, command, params, stage.map(|stage| move |now: &progress::State| now.stage.starts_with(stage) && now.done > 0.0));
|
||||
}
|
||||
|
||||
/// [`cancel`] once the reported state passes `when` (before it starts with `None`).
|
||||
fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option<impl Fn(&progress::State) -> bool>) {
|
||||
let (model, journal) = (s.model.clone(), s.journal.len());
|
||||
let design = s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
|
||||
let p = Progress::new();
|
||||
if when.is_none() {
|
||||
if stage.is_none() {
|
||||
p.cancel();
|
||||
}
|
||||
let (r, waited) = std::thread::scope(|scope| {
|
||||
@@ -37,18 +31,18 @@ fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option<impl
|
||||
scope.spawn(move || progress::scoped(Some(p), || s.execute(command, ¶ms)))
|
||||
};
|
||||
let mut at = Instant::now();
|
||||
if let Some(when) = &when {
|
||||
if let Some(stage) = stage {
|
||||
let deadline = Instant::now() + Duration::from_secs(600);
|
||||
while !worker.is_finished() && Instant::now() < deadline {
|
||||
let now = p.snapshot();
|
||||
if when(&now) {
|
||||
if now.stage.starts_with(stage) && now.done > 0.0 {
|
||||
at = Instant::now();
|
||||
p.cancel();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert!(p.is_cancelled(), "{command}: never reached the stage ({:?})", p.snapshot());
|
||||
assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot());
|
||||
}
|
||||
let r = worker.join().unwrap();
|
||||
(r, at.elapsed())
|
||||
@@ -59,7 +53,6 @@ fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option<impl
|
||||
assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel");
|
||||
assert!(s.model == model, "{command}: the model changed");
|
||||
assert_eq!(s.journal.len(), journal, "{command}: journalled");
|
||||
assert_eq!(s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>()), design, "{command}: the design changed");
|
||||
}
|
||||
|
||||
/// A sample's script run on a fresh session.
|
||||
@@ -135,24 +128,3 @@ fn meshers_stop_and_leave_no_mesh() {
|
||||
cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None);
|
||||
assert!(s.model.nodes().is_empty());
|
||||
}
|
||||
|
||||
/// The partition, the cells mesher and the plan's apply (#545) stop at their stages: between
|
||||
/// the kernel's cuts, between cells, between the projection's phases.
|
||||
#[test]
|
||||
fn partition_cells_and_plan_apply_stop_and_change_nothing() {
|
||||
let (_dir, mut s) = plate();
|
||||
s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap();
|
||||
s.execute("geometry.datum", &json!({"name": "Y5", "by": {"type": "offset", "plane": "zx", "offset": 5}})).unwrap();
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Body1"})).unwrap();
|
||||
cancel(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), None);
|
||||
cancel_when(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), Some(|now: &progress::State| now.stage.starts_with("Projection: ")));
|
||||
cancel(&mut s, "geometry.partition", json!({"name": "Half", "datum": "X30"}), None);
|
||||
s.execute("geometry.partition", &json!({"name": "Half", "datum": "X30"})).unwrap();
|
||||
// The second partition cuts again by both: cancelled once the first cut is made.
|
||||
cancel(&mut s, "geometry.partition", json!({"name": "Strip", "datum": "Y5"}), Some("Partition cuts"));
|
||||
let cells = json!({"bodies": ["Body1/a", "Body1/b"], "default": "tet", "size": 0.5});
|
||||
cancel(&mut s, "mesh.cells", cells.clone(), None);
|
||||
// Inside a cell's tet mesher: the cancel reaches the command, never a fallback.
|
||||
cancel(&mut s, "mesh.cells", cells, Some("Refinement round"));
|
||||
assert!(s.model.nodes().is_empty());
|
||||
}
|
||||
@@ -317,14 +317,6 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them",
|
||||
&["geometry.face.split.delete"],
|
||||
),
|
||||
covering(
|
||||
"geometry.defeature",
|
||||
"Defeature",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Take small holes, fillets, chamfers and bosses out for meshing, from a proposal list with each one on or off",
|
||||
&["geometry.defeature.propose", "geometry.defeature.delete"],
|
||||
),
|
||||
covering(
|
||||
"geometry.stitch",
|
||||
"Stitch",
|
||||
@@ -344,14 +336,6 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Element counts or sizes per design edge, with bias and an exact, minimum or free constraint; the meshers honour them",
|
||||
&["mesh.seed.clear", "mesh.seed.list"],
|
||||
),
|
||||
ready_more(
|
||||
"mesh.size.set",
|
||||
"Local Size",
|
||||
Mesh,
|
||||
"Size",
|
||||
"Element size on faces or bodies, in a box or in a sphere, growing away at a set rate; every mesher honours it",
|
||||
&["mesh.size.growth", "mesh.size.clear", "mesh.size.list"],
|
||||
),
|
||||
soon("mesh.size.adapt", "Curvature", Mesh, "Size", "M2", "Refine by curvature and proximity"),
|
||||
ready(
|
||||
"mesh.tet.generate",
|
||||
@@ -437,13 +421,6 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Relax the nodes inside a sphere to fix spikes and slivers; surface nodes slide on the geometry; kept as a field to apply again after a remesh",
|
||||
&["mesh.node.relax.apply"],
|
||||
),
|
||||
ready(
|
||||
"mesh.node.move",
|
||||
"Move Nodes",
|
||||
Mesh,
|
||||
"Improve",
|
||||
"Drag nodes of the mesh, the nodes round them following by a falloff or harmonically, on the geometry; the original drawn as a ghost and the elements coloured by how their quality changes",
|
||||
),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove", "mesh.clear"]),
|
||||
ready("mesh.mirror", "Mirror Mesh", Mesh, "Edit", "Mirror a body's mesh across a plane, joined on the plane (mesh half a symmetric part)"),
|
||||
@@ -550,13 +527,12 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"The box of fluid cells, its fluid, and the obstacle bodies voxelised from their face-tagged surfaces",
|
||||
&["model.fluid_domain.edit", "model.fluid_domain.delete"],
|
||||
),
|
||||
ready_more(
|
||||
ready(
|
||||
"model.fluid_domain.boundary",
|
||||
"Inlet & Outlet",
|
||||
Loads,
|
||||
"Flow",
|
||||
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces; held temperatures, heat fluxes and adiabatic faces",
|
||||
&["model.fluid_domain.thermal_boundary"],
|
||||
"Velocity inlets, pressure outlets, walls, moving and slip walls, periodic faces",
|
||||
),
|
||||
// ---- Interactions ----
|
||||
ready_more(
|
||||
@@ -657,13 +633,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Transient dynamics of an explicit step by central differences (contact and erosion coming in M10)",
|
||||
),
|
||||
soon("solve.blast", "Blast & Impact", Solve, "Explicit", "M14", "EOS-driven fluids with fluid–structure contact (ALE or SPH)"),
|
||||
ready(
|
||||
"solve.fluid",
|
||||
"Lattice Boltzmann",
|
||||
Solve,
|
||||
"Fluids",
|
||||
"Lattice Boltzmann flow of a fluid step on the CPU or a GPU, with wall forces, and the heat it carries",
|
||||
),
|
||||
ready("solve.fluid", "Lattice Boltzmann", Solve, "Fluids", "Lattice Boltzmann flow of a fluid step on the CPU, with wall forces"),
|
||||
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
|
||||
// ---- Results ----
|
||||
ready_more("results.deformed", "Deformed", Results, "Display", "The deformed shape of a solved step, to scale", &["results.clear"]),
|
||||
@@ -691,14 +661,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
|
||||
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
|
||||
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
|
||||
ready_more(
|
||||
"results.streamlines",
|
||||
"Streamlines",
|
||||
Results,
|
||||
"Plots",
|
||||
"A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area",
|
||||
&["results.slice", "results.surface"],
|
||||
),
|
||||
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
|
||||
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
|
||||
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
||||
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
||||
|
||||
@@ -28,6 +28,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::cdt::CdtOptions;
|
||||
use fieldcraft_mesh::hex::{Cell, CellsMesh, HexMesh, HexMethod, HexOptions, hex_mesh_swept_cells};
|
||||
use fieldcraft_mesh::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::geometry::Entity;
|
||||
use fieldcraft_model::{ElementFace, GeometryRef, Model, Set, Tie};
|
||||
@@ -172,10 +173,7 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = hex_params.as_object_mut() {
|
||||
o.remove("order");
|
||||
}
|
||||
// The cells are meshed at one size, so the size controls apply to all of them alike.
|
||||
let sizing = super::sizes::sizing(s)?.joint(&names.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let (tet_options, quality) = quality_options(&tet_params, s.limits.max_tets as u64)?;
|
||||
let tet_options = sizing.tet_options("", &tet_params, &tet_options);
|
||||
let cdt = CdtOptions { max_steiner: s.limits.max_steiner, max_tets: tet_options.max_tets };
|
||||
|
||||
let seeds = super::seeds::edge_seeds(s)?;
|
||||
@@ -201,14 +199,12 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
fieldcraft_model::progress::stage(&format!("Cells: sweeping {source}'s cells together"), None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
let inputs: Vec<Input> = members
|
||||
.iter()
|
||||
.filter_map(|&i| shapes.get(i))
|
||||
.map(|shape| Ok(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }))
|
||||
.collect::<Result<_>>()?;
|
||||
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds, &sizing)? {
|
||||
match swept_together(p, &inputs, m, quadratic, size, tolerance, &seeds)? {
|
||||
Ok(cm) => {
|
||||
for &i in &members {
|
||||
if let Some(slot) = joint.get_mut(i) {
|
||||
@@ -229,14 +225,11 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if joint.get(i).copied().flatten().is_some() {
|
||||
continue;
|
||||
}
|
||||
fieldcraft_model::progress::stage(&format!("Cells: meshing {}", shape.name), Some(shapes.len() as f64), "cells");
|
||||
fieldcraft_model::progress::count(i as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let method = method_of(&shape.name);
|
||||
let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? });
|
||||
let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> {
|
||||
let b = input()?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size, &sizing)?;
|
||||
let mesh = hex_body(&hex_params, &b, m, quadratic, size)?;
|
||||
Ok((b, mesh))
|
||||
};
|
||||
let (cell, mut info) = match method {
|
||||
@@ -251,8 +244,6 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let info = json!({"method": mesh.report.method.name()});
|
||||
(Meshed::Hex(Box::new((b, mesh))), info)
|
||||
}
|
||||
// A cancelled job stops; any other failure falls back to tet.
|
||||
Err(e) if e.is_cancelled() => return Err(e),
|
||||
Err(e) => (Meshed::Tet, json!({"method": "tet", "why": e.to_string()})),
|
||||
},
|
||||
};
|
||||
@@ -308,9 +299,6 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for (i, mut info) in tet_cells {
|
||||
let Some(shape) = shapes.get(i) else { continue };
|
||||
fieldcraft_model::progress::stage(&format!("Cells: meshing {} (tet)", shape.name), Some(shapes.len() as f64), "cells");
|
||||
fieldcraft_model::progress::count(i as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
let input = Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? };
|
||||
let pyramids = if transition == Transition::Tie {
|
||||
Err("`transition` is \"tie\"".to_string())
|
||||
@@ -358,8 +346,6 @@ fn cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|r| r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("tet")))
|
||||
.filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from))
|
||||
.collect();
|
||||
fieldcraft_model::progress::stage("Cells: joining the cells", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?);
|
||||
explain_ties(&design, &report, &mut interfaces);
|
||||
let (nodes, elements) = (model.nodes().len(), model.elements().len());
|
||||
@@ -377,10 +363,8 @@ fn swept_together(
|
||||
size: Option<f64>,
|
||||
tolerance: f64,
|
||||
seeds: &[fieldcraft_mesh::seeds::EdgeSeed],
|
||||
sizing: &super::sizes::Sizing,
|
||||
) -> Result<std::result::Result<CellsMesh, String>> {
|
||||
let h = size.ok_or_else(|| EngineError::Params("no element size".into()))?;
|
||||
let field = sizing.field("", h)?;
|
||||
let mut surfaces = Vec::with_capacity(inputs.len());
|
||||
for b in inputs {
|
||||
let options = super::mesh::surface_options(p, &b.mesh)?;
|
||||
@@ -398,7 +382,7 @@ fn swept_together(
|
||||
seeds: seeds.to_vec(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
match hex_mesh_swept_cells(&cells, &field, &options) {
|
||||
match hex_mesh_swept_cells(&cells, &UniformSize(h), &options) {
|
||||
Ok(cm) => Ok(Ok(cm)),
|
||||
Err(fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled)) => Err(EngineError::Model(fieldcraft_model::Error::Cancelled)),
|
||||
Err(e @ fieldcraft_mesh::Error::Limit(_)) => Err(EngineError::Params(e.to_string())),
|
||||
|
||||
@@ -470,19 +470,14 @@ fn cuts(model: &Model) -> Vec<Cut> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `design` without the features `model` defeatures, cut by its partitions, then with its face
|
||||
/// merges (after those of the features defeatured virtually) and its face splits (those along a
|
||||
/// suppressed datum left out; the design as opened when the model has none).
|
||||
/// `design` cut by `model`'s partitions, then with its face merges and its face splits (those
|
||||
/// along a suppressed datum left out; the design as opened when the model has none).
|
||||
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
|
||||
let defeatures: Vec<fieldcraft_geometry::defeature::Defeature> =
|
||||
model.defeatures().iter().map(|d| fieldcraft_geometry::defeature::Defeature { name: d.name.clone(), features: d.features.clone() }).collect();
|
||||
let (design, mut merges) = design.defeatured(&defeatures)?;
|
||||
merges.extend(model.face_merges().iter().map(|m| Merge {
|
||||
name: m.name.clone(),
|
||||
body: m.body.clone(),
|
||||
faces: m.faces.clone(),
|
||||
into: m.into.clone(),
|
||||
}));
|
||||
let merges: Vec<Merge> = model
|
||||
.face_merges()
|
||||
.iter()
|
||||
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
|
||||
.collect();
|
||||
let splits: Vec<fieldcraft_geometry::face_split::FaceSplit> = model
|
||||
.face_splits()
|
||||
.iter()
|
||||
@@ -527,10 +522,7 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cells: Vec<String> = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
|
||||
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
|
||||
let mut warnings: Vec<String> = report.map(|r| r.warnings.clone()).unwrap_or_default();
|
||||
fieldcraft_model::progress::stage("Partition: checking the cells", Some(cells.len() as f64), "cells");
|
||||
for (k, cell) in cells.iter().enumerate() {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
for cell in &cells {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).err()) {
|
||||
warnings.push(format!(
|
||||
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
//! Defeaturing for meshing: `geometry.defeature` takes holes, fillets, chamfers and small bosses
|
||||
//! out of the open design by suppressing their SolidCraft features (virtually, as a face merge,
|
||||
//! when a feature cannot be suppressed on its own), and `geometry.defeature.propose` lists the
|
||||
//! candidates of a body against size thresholds and the last geometry.check.
|
||||
//!
|
||||
//! A defeature is part of the model (`fieldcraft_model::Defeature`, by feature id); the geometry
|
||||
//! layer applies it to the design as opened (`fieldcraft_geometry::defeature`), before the
|
||||
//! partitions, whenever the design is opened or cut again ([`super::datum::partitioned`]), so the
|
||||
//! meshers, the viewport and the checks all see the part without them.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_geometry::defeature::{Candidate, candidate};
|
||||
use fieldcraft_model::Defeature;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const DEFEATURE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature",
|
||||
label: "Defeature",
|
||||
params: r#"{"name": "Small", "features": [3, "F7"] (feature ids, as geometry.features and geometry.defeature.propose list them; or the text "3, F7")} — takes features of the open design out for meshing: the design is rebuilt without them, as SolidCraft builds it with them suppressed (a hole filled, a fillet or chamfer back to its sharp edge, a boss gone), and the other faces keep their persistent names. A feature that cannot be suppressed alone (a later feature needs it, or a body would go) is defeatured virtually instead: its faces are merged into the neighbour they share the longest edges with, for meshing only (virtual topology; the B-rep keeps it). A feature that builds a body or a sketch, or one the design lacks, is an error. Kept in the model by feature id and applied again whenever the design is opened (before the partitions); the same name again replaces its features. A suppressed feature affects every body it shaped. The bodies lose their meshes (`replaced`; remesh them); sets naming their faces are `stale_sets`. Replies `suppressed` (feature ids), `virtual` ([{feature, reason}]) and `bodies`. One undo step; geometry.defeature.delete turns them back on"#,
|
||||
form: Some(super::forms::DEFEATURE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: defeature,
|
||||
};
|
||||
|
||||
pub const DEFEATURE_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature.delete",
|
||||
label: "Delete Defeature",
|
||||
params: r#"{"name": "Small"} — turns a defeature's features back on: the design is rebuilt with them; the bodies they shaped lose their meshes (`replaced`)"#,
|
||||
form: Some(super::forms::DEFEATURE_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Defeature],
|
||||
run: delete,
|
||||
};
|
||||
|
||||
pub const DEFEATURE_PROPOSE: CommandSpec = CommandSpec {
|
||||
id: "geometry.defeature.propose",
|
||||
label: "Propose Defeaturing",
|
||||
params: r#"{"body": "Base", "below"?: {"hole_diameter"?: 6, "fillet_radius"?: 2, "chamfer"?: 1, "boss_height"?: 2} (mm), "from_check"?: false, "name"?: "Defeature"} — lists the features of a body (as opened) that defeaturing may take out (read-only): holes (a hole, or a cut of a circle or cylinder) by diameter, fillets by radius, chamfers by distance, bosses (a join of an extrude, cylinder or box) by height. A candidate is `proposed` when it is smaller than its `below` threshold, or with `from_check` when one of its faces is a finding of the last geometry.check (still marked). Replies `candidates` ([{id, feature, name, type, category, size, faces, proposed, suppressed, by, method}]: `suppressed` when a defeature takes it out, `by` that defeature, `method` suppressed or virtual), `proposal` (the proposed ids) and `name`, the defeature the panel's on/off switches edit (geometry.defeature with that name)"#,
|
||||
form: Some(super::forms::DEFEATURE_PROPOSE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
run: propose,
|
||||
};
|
||||
|
||||
/// A feature id: `3` or `"F3"`.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum FeatureId {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// The feature ids of `features`: a list, or the form's text (`3, F7`).
|
||||
fn ids(p: &Value) -> Result<Vec<u64>> {
|
||||
let given: Vec<FeatureId> = match p.get("features") {
|
||||
Some(Value::String(t)) if t.len() <= 64 * 1024 => {
|
||||
t.split([',', ' ', ';']).filter(|w| !w.is_empty()).map(|w| FeatureId::Name(w.to_string())).collect()
|
||||
}
|
||||
_ => super::req(p, "features")?,
|
||||
};
|
||||
if given.is_empty() || given.len() > fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES {
|
||||
return Err(EngineError::invalid("features", format!("1 to {} feature ids", fieldcraft_model::virtual_faces::MAX_DEFEATURE_FEATURES)));
|
||||
}
|
||||
let mut out = Vec::with_capacity(given.len());
|
||||
for f in given {
|
||||
let id = match f {
|
||||
FeatureId::Id(id) => Some(id),
|
||||
FeatureId::Name(n) => n.strip_prefix('F').unwrap_or(&n).parse().ok(),
|
||||
};
|
||||
match id {
|
||||
Some(id) if !out.contains(&id) => out.push(id),
|
||||
Some(_) => {}
|
||||
None => return Err(EngineError::invalid("features", "feature ids: 3 or \"F3\"")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The bodies of the open design (as opened) that features `ids` shaped.
|
||||
fn shaped(s: &Session, ids: &BTreeSet<u64>) -> Vec<String> {
|
||||
let Some(design) = s.design.as_deref() else { return Vec::new() };
|
||||
let graph = design.undefeatured().features(None).unwrap_or_default();
|
||||
let sources: Vec<String> = graph.into_iter().filter(|b| b.features.iter().any(|f| ids.contains(&f.id))).map(|b| b.body).collect();
|
||||
// A body the partitions cut: each of its cells.
|
||||
design.bodies.iter().map(|b| b.name.clone()).filter(|n| design.source_of(n).is_some_and(|src| sources.iter().any(|x| x == src))).collect()
|
||||
}
|
||||
|
||||
/// What the design's defeatures did with the features of `name`, as the reply lists them.
|
||||
fn outcome(s: &Session, name: &str) -> (Vec<u64>, Vec<Value>) {
|
||||
let report = s.design.as_deref().map(|d| d.defeat_report().to_vec()).unwrap_or_default();
|
||||
let mine = report.into_iter().filter(|r| r.defeature == name);
|
||||
let (mut suppressed, mut virt) = (Vec::new(), Vec::new());
|
||||
for r in mine {
|
||||
if r.method == "suppressed" {
|
||||
suppressed.push(r.feature);
|
||||
} else {
|
||||
virt.push(json!({"feature": r.feature, "reason": r.reason}));
|
||||
}
|
||||
}
|
||||
(suppressed, virt)
|
||||
}
|
||||
|
||||
fn defeature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let features = ids(p)?;
|
||||
let before: BTreeSet<u64> = s.model.defeatures().iter().filter(|d| d.name == name).flat_map(|d| d.features.clone()).collect();
|
||||
s.model.set_defeature(Defeature { name: name.clone(), features: features.clone() })?;
|
||||
let mut touched = before;
|
||||
touched.extend(&features);
|
||||
let bodies = shaped(s, &touched);
|
||||
let merges: Vec<String> = features.iter().map(|id| format!("merge {name}-F{id}")).collect();
|
||||
let mut errors = vec![format!("defeature {name}")];
|
||||
errors.extend(merges);
|
||||
let reply = super::virtual_faces::reapply(s, &errors, &bodies, json!({"name": name}))?;
|
||||
let (suppressed, virt) = outcome(s, &name);
|
||||
let bodies = shaped(s, &touched);
|
||||
let mut reply = reply;
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("suppressed".into(), json!(suppressed));
|
||||
o.insert("virtual".into(), json!(virt));
|
||||
o.insert("bodies".into(), json!(bodies));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let removed = s.model.remove_defeature(name)?;
|
||||
let bodies = shaped(s, &removed.features.iter().copied().collect());
|
||||
let mut cleared = super::datum::recut(s)?;
|
||||
cleared.extend(super::replace::clear(&mut s.model, bodies.iter().map(String::as_str)));
|
||||
Ok(cleared.report_if_any(s, json!({"deleted": name, "features": removed.features, "bodies": bodies})))
|
||||
}
|
||||
|
||||
/// Size thresholds of a proposal (mm).
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Below {
|
||||
hole_diameter: Option<f64>,
|
||||
fillet_radius: Option<f64>,
|
||||
chamfer: Option<f64>,
|
||||
boss_height: Option<f64>,
|
||||
}
|
||||
|
||||
impl Below {
|
||||
fn of(&self, category: &str) -> Option<f64> {
|
||||
match category {
|
||||
"hole" => self.hole_diameter,
|
||||
"fillet" => self.fillet_radius,
|
||||
"chamfer" => self.chamfer,
|
||||
"boss" => self.boss_height,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn propose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let body: String = super::req(p, "body")?;
|
||||
let below: Below = super::opt(p, "below")?.unwrap_or_default();
|
||||
for (k, v) in [
|
||||
("hole_diameter", below.hole_diameter),
|
||||
("fillet_radius", below.fillet_radius),
|
||||
("chamfer", below.chamfer),
|
||||
("boss_height", below.boss_height),
|
||||
] {
|
||||
if v.is_some_and(|v| !(0.0..=fieldcraft_model::MAX_COORD).contains(&v)) {
|
||||
return Err(EngineError::invalid("below", format!("`{k}` is a length of 0 or more")));
|
||||
}
|
||||
}
|
||||
let from_check: bool = super::opt(p, "from_check")?.unwrap_or(false);
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => "Defeature".to_string(),
|
||||
Some(_) => super::name(p)?.to_string(),
|
||||
};
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let source = design
|
||||
.source_of(&body)
|
||||
.ok_or_else(|| EngineError::Params(format!("the open design has no body `{}`", body.chars().take(200).collect::<String>())))?;
|
||||
let graph = design.undefeatured().features(Some(source))?;
|
||||
// The faces the last check marked on the body (merged faces by their B-rep faces).
|
||||
let mut flagged: BTreeSet<String> = BTreeSet::new();
|
||||
if from_check {
|
||||
let marks = crate::view::geometry_marks(s)
|
||||
.ok_or_else(|| EngineError::Params("`from_check`: run geometry.check (with marks) on the open design first".into()))?;
|
||||
for (b, i) in marks.marks.iter().flat_map(|m| m.faces.iter()) {
|
||||
if design.source_of(b) != Some(source) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = design.body(b).and_then(|shape| shape.faces().get(*i as usize)) {
|
||||
flagged.insert(f.name.split('#').next().unwrap_or(&f.name).to_string());
|
||||
flagged.extend(f.members.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Which defeature takes each feature out, and how.
|
||||
let by: BTreeMap<u64, (String, &'static str)> = design.defeat_report().iter().map(|r| (r.feature, (r.defeature.clone(), r.method))).collect();
|
||||
let mut candidates = Vec::new();
|
||||
let mut proposal = Vec::new();
|
||||
for c in graph.iter().flat_map(|b| b.features.iter()).filter_map(candidate) {
|
||||
let Candidate { id, name: fname, kind, category, size, faces } = c;
|
||||
let small = below.of(category).is_some_and(|t| size < t);
|
||||
let hit = faces.iter().any(|f| flagged.contains(f));
|
||||
let proposed = small || hit;
|
||||
if proposed {
|
||||
proposal.push(id);
|
||||
}
|
||||
let taken = by.get(&id);
|
||||
candidates.push(json!({
|
||||
"id": id,
|
||||
"feature": format!("F{id}"),
|
||||
"name": fname,
|
||||
"type": kind,
|
||||
"category": category,
|
||||
"size": size,
|
||||
"faces": faces,
|
||||
"proposed": proposed,
|
||||
"suppressed": taken.is_some(),
|
||||
"by": taken.map(|t| t.0.clone()),
|
||||
"method": taken.map(|t| t.1),
|
||||
}));
|
||||
}
|
||||
let off = candidates.iter().filter(|c| c["suppressed"] == true).count();
|
||||
let summary = format!(
|
||||
"{} on `{source}`: {} proposed, {} defeatured",
|
||||
match candidates.len() {
|
||||
1 => "1 candidate".to_string(),
|
||||
n => format!("{n} candidates"),
|
||||
},
|
||||
proposal.len(),
|
||||
off
|
||||
);
|
||||
Ok(json!({"body": source, "name": name, "candidates": candidates, "proposal": proposal, "summary": summary}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn faces(s: &Session) -> usize {
|
||||
s.design.as_ref().unwrap().bodies[0].faces().len()
|
||||
}
|
||||
|
||||
fn hole(s: &mut Session) -> u64 {
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
let r = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 12}})).unwrap();
|
||||
assert_eq!(r["proposal"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
r["proposal"][0].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// The sample plate: its hole is proposed below 12 mm, suppressing it gives a block meshed
|
||||
/// without it (tet and hex), undo and redo take it out and put it back, it is saved and
|
||||
/// replayed on open, and deleting the defeature brings the hole back.
|
||||
#[test]
|
||||
fn the_plate_hole_is_suppressed_meshed_undone_saved_and_restored() {
|
||||
let (mut s, dir) = super::super::datum::tests::plate();
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
assert_eq!(faces(&s), 8);
|
||||
let none = s.execute("geometry.defeature.propose", &json!({"body": body, "below": {"hole_diameter": 5}})).unwrap();
|
||||
assert_eq!((none["candidates"].as_array().map(Vec::len), none["proposal"].as_array().map(Vec::len)), (Some(1), Some(0)), "{none}");
|
||||
let id = hole(&mut s);
|
||||
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
|
||||
let with_hole = s.model.elements().len();
|
||||
let r = s.execute("geometry.defeature", &json!({"name": "Holes", "features": [id]})).unwrap();
|
||||
assert_eq!(r["suppressed"], json!([id]), "{r}");
|
||||
assert_eq!(r["replaced"][0]["body"].as_str(), Some(body.as_str()), "{r}");
|
||||
assert_eq!(faces(&s), 6);
|
||||
// The proposal shows it off, by the defeature.
|
||||
let p = s.execute("geometry.defeature.propose", &json!({"body": body})).unwrap();
|
||||
assert_eq!((p["candidates"][0]["suppressed"].as_bool(), p["candidates"][0]["by"].as_str()), (Some(true), Some("Holes")), "{p}");
|
||||
// Meshed without it: a tet mesh fills the whole block, and the block sweeps into hexes.
|
||||
s.execute("mesh.tet.generate", &json!({"body": body, "size": 4})).unwrap();
|
||||
let volume = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().volume;
|
||||
assert!((volume(&s) - 40.0 * 30.0 * 10.0).abs() < 1e-6, "{}", volume(&s));
|
||||
assert_ne!(s.model.elements().len(), with_hole);
|
||||
s.execute("mesh.hex.sweep", &json!({"body": body, "size": 5})).unwrap();
|
||||
assert!(s.model.elements().values().all(|e| e.nodes.len() == 8), "a block sweeps into hexes only");
|
||||
// Undo the meshes and the defeature: the hole is back; redo takes it out again.
|
||||
while !s.model.defeatures().is_empty() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(faces(&s), 8);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(faces(&s), 6);
|
||||
// Saved, and replayed on open of the model and on a re-open of the design.
|
||||
let file = dir.join("defeatured.fieldcraft");
|
||||
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!((t.model.defeatures().len(), faces(&t)), (1, 6));
|
||||
let design = s.sources.design.as_ref().unwrap().path().to_string();
|
||||
s.execute("geometry.open", &json!({"path": design})).unwrap();
|
||||
assert_eq!(faces(&s), 6);
|
||||
// Switched back on.
|
||||
let r = s.execute("geometry.defeature.delete", &json!({"name": "Holes"})).unwrap();
|
||||
assert_eq!(r["features"], json!([id]), "{r}");
|
||||
assert_eq!(faces(&s), 8);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// Unknown features, body-building ones and bad ids are refused and leave the model as it was.
|
||||
#[test]
|
||||
fn bad_features_are_refused() {
|
||||
let (mut s, dir) = super::super::datum::tests::plate();
|
||||
for features in [json!([999]), json!(["Fx"]), json!([]), json!("F1, x"), json!(7)] {
|
||||
assert!(s.execute("geometry.defeature", &json!({"name": "Bad", "features": features})).is_err(), "{features}");
|
||||
}
|
||||
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
let base = s.execute("geometry.features", &json!({"body": body})).unwrap()["bodies"][0]["features"][0]["id"].as_u64().unwrap();
|
||||
let e = s.execute("geometry.defeature", &json!({"name": "Bad", "features": [base]})).unwrap_err();
|
||||
assert!(e.to_string().contains("builds a body"), "{e}");
|
||||
assert!(s.model.defeatures().is_empty() && faces(&s) == 8);
|
||||
for below in [json!({"hole_diameter": -1}), json!({"hole_diameter": "x"}), json!({"nope": 1})] {
|
||||
assert!(s.execute("geometry.defeature.propose", &json!({"body": body, "below": below})).is_err(), "{below}");
|
||||
}
|
||||
assert!(s.execute("geometry.defeature.propose", &json!({"body": "Nope"})).is_err());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
/// The bracket's 6 mm fillet is proposed below 8 mm, defeatured back to a sharp corner and
|
||||
/// meshed; its holes stay.
|
||||
#[test]
|
||||
fn a_bracket_is_meshed_with_its_small_fillet_defeatured() {
|
||||
let (_dir, mut s) = super::super::datum::tests::scripted(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
|
||||
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [8, 32, 20], "diameter": 8}}
|
||||
]));
|
||||
let p = s.execute("geometry.defeature.propose", &json!({"body": "Bracket", "below": {"fillet_radius": 8}})).unwrap();
|
||||
let cats: Vec<&str> = p["candidates"].as_array().unwrap().iter().filter_map(|c| c["category"].as_str()).collect();
|
||||
assert_eq!(cats, ["fillet", "hole", "hole"], "{p}");
|
||||
let fillet = p["proposal"].as_array().unwrap().clone();
|
||||
assert_eq!(fillet.len(), 1);
|
||||
let before = faces(&s);
|
||||
let r = s.execute("geometry.defeature", &json!({"name": "Fillets", "features": fillet})).unwrap();
|
||||
assert_eq!(r["suppressed"], json!(fillet), "{r}");
|
||||
assert_eq!(faces(&s), before - 1);
|
||||
let r = s.execute("mesh.tet.generate", &json!({"body": "Bracket", "size": 4})).unwrap();
|
||||
assert!(r["elements"].as_u64().unwrap_or(0) > 0, "{r}");
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use fieldcraft_model::{
|
||||
BoundaryCondition, Coupling, FaceCondition, FluidBoundary, FluidDomain, Load, Material, Model, PointMass, ReferencePoint, Section, Set, Spring,
|
||||
Step, ThermalCondition,
|
||||
Step,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
@@ -288,7 +288,7 @@ definition_commands!(
|
||||
FluidDomain,
|
||||
"fluid_domain",
|
||||
"Fluid Domain",
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing), "thermal": {"initial_temperature": 20, "gravity": [0, -9810, 0], "expansion": 2.1e-4 (β per temperature; buoyancy needs both, Boussinesq), "faces": {"y_min": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}, …} (default adiabatic; a velocity inlet carries its held temperature in, a pressure outlet lets heat leave), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "temperature", "value": 80}}] (obstacle faces are adiabatic unless listed)} (heat in the fluid, carried by the flow and diffused with the material's conductivity and specific heat; left out, the flow is isothermal)}"#,
|
||||
r#"{"name": "Flow", "material": "Water" (a density and fluid behaviour), "min": [x, y, z], "max": [x, y, z], "spacing": 1.0 (cubic cells; every extent a whole number of spacings), "faces": {"x_min": {"type": "velocity", "velocity": [u, v, w], "profile": "uniform|parabolic"}, "x_max": {"type": "pressure", "pressure": 0}, "y_min": {"type": "wall"} | {"type": "moving_wall", "velocity": [u, v, w] (in the face's plane)} | {"type": "slip"} | {"type": "periodic"} (in opposite pairs), …} (default walls), "obstacles": ["Body1"] (solid bodies of the design or imported surfaces, by name), "surfaces": [{"face": {"body": "Body1", "face": "F2:top"}, "condition": {"type": "moving_wall", "velocity": [u, v, w]}}] (obstacle faces are walls unless listed), "initial_velocity": [u, v, w], "acceleration": [x, y, z] (gravity or a driving force per mass), "tolerance": 0.1 (obstacle triangulation; default a tenth of the spacing)}"#,
|
||||
super::forms::FLUID_DOMAIN,
|
||||
super::forms::FLUID_DOMAIN_DELETE,
|
||||
add_fluid_domain,
|
||||
@@ -319,24 +319,6 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"domain": domain}))
|
||||
}
|
||||
|
||||
pub const FLUID_THERMAL_BOUNDARY: CommandSpec = CommandSpec {
|
||||
id: "model.fluid_domain.thermal_boundary",
|
||||
label: "Temperature or Heat Flux",
|
||||
params: r#"{"domain": "Flow", "at": "x_min" (a box face) | {"body": "Body1", "face": "F2:top"} (an obstacle face), "condition": {"type": "temperature", "value": 80} | {"type": "flux", "value": 1000 (heat per area into the fluid)} | {"type": "adiabatic"}} — sets one boundary's thermal condition; the domain needs `thermal` settings (model.fluid_domain.edit)"#,
|
||||
form: Some(super::forms::FLUID_THERMAL_BOUNDARY),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain],
|
||||
run: fluid_thermal_boundary,
|
||||
};
|
||||
|
||||
fn fluid_thermal_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let domain: &str = req(p, "domain")?;
|
||||
let at: FluidBoundary = req(p, "at")?;
|
||||
let condition: ThermalCondition = req(p, "condition")?;
|
||||
s.model.set_fluid_thermal_boundary(domain, at, condition)?;
|
||||
Ok(json!({"domain": domain}))
|
||||
}
|
||||
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
|
||||
@@ -608,59 +608,10 @@ const SURFACE_CONDITION: &[Variant] = &[
|
||||
),
|
||||
];
|
||||
|
||||
const THERMAL_CONDITION: &[Variant] = &[
|
||||
variant("adiabatic", "Adiabatic", &[]),
|
||||
variant("temperature", "Temperature", &[num("value", "Temperature", D::Temperature).help("The temperature held at the boundary.").req()]),
|
||||
variant("flux", "Heat flux", &[num("value", "Heat flux", D::HeatFlux).help("Heat per area into the fluid (negative out of it).").req()]),
|
||||
];
|
||||
|
||||
const fn face(key: &'static str, label: &'static str) -> Field {
|
||||
variants(key, label, FACE_CONDITION).hint("wall")
|
||||
}
|
||||
|
||||
const fn thermal_face(key: &'static str, label: &'static str) -> Field {
|
||||
variants(key, label, THERMAL_CONDITION).hint("adiabatic")
|
||||
}
|
||||
|
||||
const FLUID_THERMAL: Field = group(
|
||||
"thermal",
|
||||
"Heat",
|
||||
&[
|
||||
num("initial_temperature", "Initial temperature", D::Temperature)
|
||||
.help("The temperature everywhere at the start, and the reference of the buoyancy.")
|
||||
.def("0"),
|
||||
xyz("gravity", "Gravity", D::Acceleration).help("Gravity for the buoyancy; zero for none. Needs the expansion coefficient."),
|
||||
positive("expansion", "Expansion β", D::Expansion).help("Volumetric thermal expansion coefficient of the fluid, for the buoyancy."),
|
||||
group(
|
||||
"faces",
|
||||
"Box faces",
|
||||
&[
|
||||
thermal_face("x_min", "X min").help("Thermal condition on the box face at the smallest X."),
|
||||
thermal_face("x_max", "X max").help("Thermal condition on the box face at the largest X."),
|
||||
thermal_face("y_min", "Y min").help("Thermal condition on the box face at the smallest Y."),
|
||||
thermal_face("y_max", "Y max").help("Thermal condition on the box face at the largest Y."),
|
||||
thermal_face("z_min", "Z min").help("Thermal condition on the box face at the smallest Z."),
|
||||
thermal_face("z_max", "Z max").help("Thermal condition on the box face at the largest Z."),
|
||||
],
|
||||
)
|
||||
.help("The thermal condition on each face of the box; a velocity inlet carries its held temperature in."),
|
||||
rows(
|
||||
"surfaces",
|
||||
"Obstacle faces",
|
||||
&[
|
||||
OBSTACLE_FACE,
|
||||
variants("condition", "Condition", THERMAL_CONDITION)
|
||||
.help("The thermal condition on that face.")
|
||||
.req()
|
||||
.def(r#"{"type": "adiabatic"}"#),
|
||||
],
|
||||
)
|
||||
.help("Thermal conditions on single obstacle faces; every other obstacle face is adiabatic."),
|
||||
],
|
||||
)
|
||||
.help("Heat carried by the flow: needs the material's conductivity and specific heat. Off for an isothermal flow.")
|
||||
.hint("isothermal when off");
|
||||
|
||||
pub const FLUID_DOMAIN: &[Field] = &[
|
||||
NAME,
|
||||
reference("material", "Fluid", C::Material).help("The fluid material: needs a density and a viscosity.").req(),
|
||||
@@ -698,7 +649,6 @@ pub const FLUID_DOMAIN: &[Field] = &[
|
||||
positive("tolerance", "Obstacle tolerance", D::Length)
|
||||
.help("How close a cell must be to an obstacle surface to count as on it.")
|
||||
.hint("a tenth of the cell size"),
|
||||
FLUID_THERMAL,
|
||||
];
|
||||
|
||||
pub const FLUID_BOUNDARY: &[Field] = &[
|
||||
@@ -710,15 +660,6 @@ pub const FLUID_BOUNDARY: &[Field] = &[
|
||||
variants("condition", "Condition", FACE_CONDITION).help("The flow condition on that boundary.").req().def(r#"{"type": "velocity"}"#),
|
||||
];
|
||||
|
||||
pub const FLUID_THERMAL_BOUNDARY: &[Field] = &[
|
||||
reference("domain", "Domain", C::FluidDomain).help("The fluid domain to change; it needs heat settings.").req(),
|
||||
name_or("at", "Boundary", &BOX_FACE, "Obstacle face", OBSTACLE_FACE_FIELDS)
|
||||
.help("A face of the domain's box, or a named face of an obstacle body.")
|
||||
.req()
|
||||
.def("\"x_min\""),
|
||||
variants("condition", "Condition", THERMAL_CONDITION).help("The thermal condition on that boundary.").req().def(r#"{"type": "temperature"}"#),
|
||||
];
|
||||
|
||||
const PROCEDURE: Field = variants(
|
||||
"procedure",
|
||||
"Procedure",
|
||||
@@ -1305,9 +1246,7 @@ pub const HEX_OCTREE: &[Field] = &[
|
||||
SURFACE,
|
||||
HEX_SIZE,
|
||||
TOLERANCE,
|
||||
int("max_depth", "Refinement levels", 0, 5)
|
||||
.help("The most times the octree may refine near thin walls, narrow faces and curves; the levels the part needs are found before meshing.")
|
||||
.hint("4"),
|
||||
int("max_depth", "Refinement levels", 0, 5).help("How many times the octree may refine near the surface.").hint("3"),
|
||||
int("max_elements", "Max elements", 1, 10_000_000).help("Cap on the elements created."),
|
||||
int("attempts", "Attempts", 1, 8).help("How many times to retry with adjusted settings.").hint("4"),
|
||||
int("max_sweeps", "Optimiser sweeps", 0, 10_000).help("The most smoothing sweeps.").hint("300"),
|
||||
@@ -1486,10 +1425,7 @@ pub const NODE_RELAX: &[Field] = &[
|
||||
.help("Guarded: each move keeps the worst element around the node from getting worse. Strong: move the nodes of the bad elements and morph the mesh round them, nodes on CAD edges held.")
|
||||
.def("\"guarded\""),
|
||||
range("below", "Below", D::None, 0.0, 1.0).help("Strong: the elements below this scaled Jacobian are moved.").hint("0.7"),
|
||||
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("five element lengths"),
|
||||
choice("guards", "Keep a morph", &[("valid", "Unless an element inverts"), ("strict", "Only when it improves the elements")])
|
||||
.help("Strong: valid keeps every morph that inverts no element; strict keeps one only when the worst element it touches improves and no element gets longer.")
|
||||
.hint("valid"),
|
||||
positive("reach", "Reach", D::Length).help("Strong: nodes this close to a moved node follow it, blended to zero at the reach.").hint("three element lengths"),
|
||||
int("iterations", "Iterations", 1, 200).help("Guarded: sweeps over the nodes (1 to 100). Strong: morph rounds.").hint("5 (strong: 20)"),
|
||||
range("strength", "Strength", D::None, 0.0, 1.0).help("How far each node moves toward its relaxed position, 0 to 1.").def("1"),
|
||||
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
|
||||
@@ -1499,33 +1435,6 @@ pub const NODE_RELAX: &[Field] = &[
|
||||
flag("reapply", "Reapply after remesh").help("Apply the field again by itself after each remesh.").def("false"),
|
||||
];
|
||||
|
||||
pub const NODE_MOVE: &[Field] = &[
|
||||
labels("nodes", "Nodes", LabelOf::Node).help("The nodes to grab: drag a node in the view, or box-select nodes and Use selection.").hint("drag a node"),
|
||||
face_rows("faces", "Faces").help("Instead of nodes: every node on these CAD faces.").hint("none"),
|
||||
xyz("center", "Centre", D::Length).help("Instead of nodes: a sphere's centre; every node inside follows by the falloff.").hint("none"),
|
||||
xyz("by", "Move by", D::Length).help("The displacement of the grabbed nodes; dragging in the view sets it."),
|
||||
xyz("to", "Move to", D::Length).help("Instead of Move by: where the grabbed nodes' centroid (or the centre) goes.").hint("none"),
|
||||
positive("radius", "Radius", D::Length)
|
||||
.help("Nodes this close to a grabbed node follow it, blended to zero at the radius; Alt + scroll over the view changes it.")
|
||||
.hint("only the grabbed nodes"),
|
||||
choice("propagate", "Followers", &[("falloff", "Falloff (by distance)"), ("harmonic", "Harmonic (through the mesh)")])
|
||||
.help("Falloff: a node follows by the falloff of its distance. Harmonic: the move spreads through the element edges as a membrane carries it, zero at the radius.")
|
||||
.def("\"falloff\""),
|
||||
choice("falloff", "Falloff", &[("smooth", "Smooth"), ("linear", "Linear"), ("none", "None")])
|
||||
.help("Falloff followers: how the share falls from the grabbed nodes to the radius.")
|
||||
.hint("smooth"),
|
||||
range("strength", "Strength", D::None, 0.0, 1.0).help("Falloff followers: the largest share of the move a follower takes, 0 to 1.").hint("1"),
|
||||
flag("on_geometry", "Stay on the geometry")
|
||||
.help("Nodes on CAD faces slide on them, on CAD edges along them; nodes on CAD vertices stay.")
|
||||
.def("true"),
|
||||
choice("guard", "Guard", &[("no_inversion", "Scale back to no inverted element"), ("none", "None")])
|
||||
.help("No inversion: the move is scaled back until no element inverts or falls below the smallest quality.")
|
||||
.def("\"no_inversion\""),
|
||||
range("min_quality", "Smallest quality", D::None, 0.0, 1.0)
|
||||
.help("No inversion: the smallest scaled Jacobian the move may leave an element with (one already below keeps its own).")
|
||||
.def("0.05"),
|
||||
];
|
||||
|
||||
pub const NODE_RELAX_APPLY: &[Field] = &[
|
||||
text("field", "Field").req().help("The relaxation field kept by Relax Nodes."),
|
||||
references("bodies", "Bodies", C::Body).help("Only the nodes of these bodies.").hint("the field's bodies"),
|
||||
@@ -1569,37 +1478,6 @@ pub const SEED_EDGE: &[Field] = &[
|
||||
|
||||
pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")];
|
||||
|
||||
pub const SIZE_SET: &[Field] = &[
|
||||
NAME,
|
||||
positive("size", "Element size", D::Length).help("The element size in the region.").req(),
|
||||
face_rows("faces", "Faces").help("Faces to refine. Pick them, then Use as faces.").hint("or bodies, a box or a sphere"),
|
||||
references("bodies", "Bodies", C::Body).help("Whole bodies meshed at this size.").hint("or faces, a box or a sphere"),
|
||||
group(
|
||||
"box",
|
||||
"Box",
|
||||
&[
|
||||
xyz("min", "Box min", D::Length).help("The box corner with the smallest coordinates.").req(),
|
||||
xyz("max", "Box max", D::Length).help("The box corner with the largest coordinates.").req(),
|
||||
],
|
||||
)
|
||||
.help("An axis-aligned box to refine."),
|
||||
group(
|
||||
"sphere",
|
||||
"Sphere",
|
||||
&[
|
||||
xyz("center", "Centre", D::Length).help("The sphere's centre; click the model to place it.").req(),
|
||||
positive("radius", "Radius", D::Length).help("The sphere's radius.").req(),
|
||||
],
|
||||
)
|
||||
.help("A sphere to refine."),
|
||||
];
|
||||
|
||||
pub const SIZE_GROWTH: &[Field] =
|
||||
&[range("rate", "Growth rate", D::None, 0.05, 2.0).help("The largest change of element size per unit length away from a local size.").def("0.3")];
|
||||
|
||||
pub const SIZE_CLEAR: &[Field] =
|
||||
&[references("names", "Local sizes", C::SizeControl).help("The size controls to remove; every one when empty.").hint("every one")];
|
||||
|
||||
pub const DELAUNAY_POINTS: &[Field] = &[points("points", "Points", D::Length, 20_000).help("The points to triangulate.").req()];
|
||||
|
||||
pub const TAG: &[Field] = &[
|
||||
@@ -1869,29 +1747,6 @@ pub const FACE_SPLIT: &[Field] = &[
|
||||
];
|
||||
pub const FACE_SPLIT_DELETE: &[Field] = existing!("Face split", C::FaceSplit);
|
||||
pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge);
|
||||
pub const DEFEATURE: &[Field] = &[
|
||||
NAME,
|
||||
text("features", "Features")
|
||||
.help("The feature ids to take out for meshing (3 or F3), as Propose Defeaturing and the feature graph list them; separate them with commas.")
|
||||
.req(),
|
||||
];
|
||||
pub const DEFEATURE_DELETE: &[Field] = existing!("Defeature", C::Defeature);
|
||||
pub const DEFEATURE_PROPOSE: &[Field] = &[
|
||||
reference("body", "Body", C::Body).help("The body whose holes, fillets, chamfers and bosses to list.").req(),
|
||||
group(
|
||||
"below",
|
||||
"Propose below",
|
||||
&[
|
||||
positive("hole_diameter", "Hole diameter", D::Millimetre).help("Propose holes narrower than this."),
|
||||
positive("fillet_radius", "Fillet radius", D::Millimetre).help("Propose fillets with a smaller radius."),
|
||||
positive("chamfer", "Chamfer", D::Millimetre).help("Propose chamfers smaller than this."),
|
||||
positive("boss_height", "Boss height", D::Millimetre).help("Propose bosses lower than this."),
|
||||
],
|
||||
)
|
||||
.help("Size thresholds: a feature below its threshold is proposed."),
|
||||
flag("from_check", "From check").help("Also propose the features with a face the last Check Geometry found."),
|
||||
text("name", "Defeature").help("The defeature the list's on/off switches edit.").hint("Defeature"),
|
||||
];
|
||||
pub const MESH_PLAN: &[Field] = &[
|
||||
reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(),
|
||||
text("name", "Name").help("The name the plan is saved under.").hint("Plan-<body>"),
|
||||
@@ -1971,7 +1826,6 @@ pub const SAMPLE: &[Field] = &[choice(
|
||||
("drop-test", "Drop test"),
|
||||
("rubber-seal", "Rubber seal"),
|
||||
("flow-past", "Flow past a bracket"),
|
||||
("heated-flow", "Heated flow past a bracket"),
|
||||
("heated-bracket", "Heated bracket"),
|
||||
("bracket-hex", "Hex-meshed bracket"),
|
||||
("crank-arm", "Bicycle crank arm"),
|
||||
@@ -2040,9 +1894,6 @@ pub const SOLVE_EXPLICIT: &[Field] = &[
|
||||
pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many.")];
|
||||
pub const SOLVE_FLUID: &[Field] = &[
|
||||
STEP,
|
||||
choice("backend", "Backend", &[("cpu", "CPU"), ("gpu", "GPU")])
|
||||
.help("The CPU (double precision, the same result on every machine) or the GPU the build and FIELDCRAFT_GPU pick (single precision).")
|
||||
.def("\"cpu\""),
|
||||
int("max_cells", "Max cells", 1, 16_000_000).help("Cap on the lattice cells."),
|
||||
int("max_steps", "Max steps", 1, 10_000_000).help("Cap on the lattice time steps."),
|
||||
positive("max_cell_updates", "Max cell updates", D::None).help("Cap on cells × steps."),
|
||||
@@ -2059,7 +1910,6 @@ pub const SOLVE_PROBE: &[Field] = &[
|
||||
flag("elements", "Element results").help("Also read element results (stresses) around the nodes."),
|
||||
points("points", "Fluid points", D::Length, 10_000).help("Points in the fluid to read."),
|
||||
json("forces", "Fluid walls").help("Walls whose fluid forces to sum, as JSON.").hint("[\"x_min\", \"F2:top\"]"),
|
||||
json("heat", "Fluid walls (heat)").help("Walls whose heat histories to read, as JSON.").hint("[\"y_min\", \"F2:top\"]"),
|
||||
];
|
||||
|
||||
// ---- Results ----
|
||||
@@ -2080,10 +1930,6 @@ pub const CONTOUR: &[Field] = &[
|
||||
("displacement_x", "Displacement X"),
|
||||
("displacement_y", "Displacement Y"),
|
||||
("displacement_z", "Displacement Z"),
|
||||
("velocity", "Velocity magnitude"),
|
||||
("pressure", "Pressure (fluid)"),
|
||||
("temperature", "Temperature"),
|
||||
("wall_heat_flux", "Wall heat flux (into wall)"),
|
||||
("max_principal", "Max principal"),
|
||||
("mid_principal", "Mid principal"),
|
||||
("min_principal", "Min principal"),
|
||||
@@ -2122,49 +1968,6 @@ pub const FRAME: &[Field] = &[
|
||||
int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."),
|
||||
positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"),
|
||||
];
|
||||
const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown");
|
||||
pub const SLICE: &[Field] = &[
|
||||
SOLVED,
|
||||
choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure"), ("temperature", "Temperature (heated flow)")])
|
||||
.help("The field coloured on the slice and the obstacles.")
|
||||
.hint("the field shown"),
|
||||
choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")])
|
||||
.help("The axis the slice is normal to.")
|
||||
.hint("the slice shown"),
|
||||
number_or_auto("position", "Position", D::Length, None)
|
||||
.help("Where the slice crosses its axis; auto: the domain's middle.")
|
||||
.hint("the position shown"),
|
||||
FLUID_FRAME,
|
||||
];
|
||||
pub const FLOW_SURFACE: &[Field] = &[
|
||||
SOLVED,
|
||||
flag("show", "Show").help("Colour the obstacles by the selected field.").def("true"),
|
||||
choice(
|
||||
"field",
|
||||
"Field",
|
||||
&[("velocity", "Velocity"), ("pressure", "Pressure"), ("temperature", "Temperature"), ("wall_heat_flux", "Wall heat flux (into wall)")],
|
||||
)
|
||||
.help("Colour the obstacle faces by the adjacent fluid field, or signed wall heat received per area.")
|
||||
.hint("the field shown"),
|
||||
FLUID_FRAME,
|
||||
];
|
||||
pub const STREAMLINES: &[Field] = &[
|
||||
SOLVED,
|
||||
choice("field", "Field", &[("velocity", "Velocity"), ("pressure", "Pressure (plain lines)"), ("temperature", "Temperature")])
|
||||
.help("Colour streamlines by speed or temperature; pressure leaves the lines in one colour.")
|
||||
.hint("the field shown"),
|
||||
flag("show", "Show").help("Draw the streamlines.").def("true"),
|
||||
int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"),
|
||||
int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"),
|
||||
points("seeds", "Seeds", D::Length, 2000).help("The points streamlines start from; the inlet face when empty."),
|
||||
group(
|
||||
"line",
|
||||
"Seed line",
|
||||
&[xyz("from", "From", D::Length).help("The seed line's first end."), xyz("to", "To", D::Length).help("The seed line's last end.")],
|
||||
)
|
||||
.help("Start `count` streamlines evenly along a line instead of the inlet face."),
|
||||
FLUID_FRAME,
|
||||
];
|
||||
pub const EXPORT_CSV: &[Field] = &[
|
||||
choice("what", "Export", &[("reactions", "Reaction sums"), ("probe", "Probed nodes")]).help("What to write.").req().def("\"reactions\""),
|
||||
path("path", "File", true).help("The CSV file to write.").req(),
|
||||
|
||||
@@ -5,18 +5,18 @@ use std::borrow::Cow;
|
||||
|
||||
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
|
||||
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
|
||||
use fieldcraft_mesh::size::UniformSize;
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{GeometryRef, Model};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::sizes::Sizing;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const HEX_GENERATE: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.generate",
|
||||
label: "Hex Mesh",
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Size controls (mesh.size.set) set the division counts and spacing of the other edges through the graded size field (a structured mesh carries a count across its block, so a control refines the whole layer or row it lies in). Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
|
||||
form: Some(super::forms::HEX_GENERATE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -42,9 +42,6 @@ pub(super) struct Input<'a> {
|
||||
/// The design's edge seeds ([`super::seeds::edge_seeds`]); the mesher applies those on its
|
||||
/// edges.
|
||||
pub(super) seeds: Vec<fieldcraft_mesh::seeds::EdgeSeed>,
|
||||
/// Per triangle of `mesh`, the B-rep face it lies on: its piece of a merged face (empty for
|
||||
/// a surface that is not a body's).
|
||||
pub(super) pieces: Vec<u32>,
|
||||
}
|
||||
|
||||
fn analytic(s: FaceSurface) -> Surface {
|
||||
@@ -76,8 +73,7 @@ pub(super) fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input
|
||||
}
|
||||
let surfaces = shape.face_surfaces()?.into_iter().map(|f| f.map(analytic)).collect();
|
||||
let face_names = shape.faces().iter().map(|f| f.name.clone()).collect();
|
||||
let (mesh, pieces) = shape.tessellate_pieces(tol)?;
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(mesh), surfaces, face_names, diagonal: diag, seeds: Vec::new(), pieces })
|
||||
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag, seeds: Vec::new() })
|
||||
}
|
||||
|
||||
/// The imported surface `surface` names, with the body name its mesh is tagged with.
|
||||
@@ -113,15 +109,7 @@ pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
|
||||
}
|
||||
// Faces named by index, as mesh.tet.generate names them.
|
||||
let face_names = (0..t.faces).map(|f| f.to_string()).collect();
|
||||
Ok(vec![Input {
|
||||
name,
|
||||
mesh: Cow::Borrowed(&t.mesh),
|
||||
surfaces: Vec::new(),
|
||||
face_names,
|
||||
diagonal: diagonal(lo, hi),
|
||||
seeds: Vec::new(),
|
||||
pieces: Vec::new(),
|
||||
}])
|
||||
Ok(vec![Input { name, mesh: Cow::Borrowed(&t.mesh), surfaces: Vec::new(), face_names, diagonal: diagonal(lo, hi), seeds: Vec::new() }])
|
||||
}
|
||||
(None, None) => {
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first, or give `surface`".into()))?;
|
||||
@@ -158,7 +146,7 @@ pub const HEX_MAPPED: CommandSpec = CommandSpec {
|
||||
pub const HEX_BLOCKS: CommandSpec = CommandSpec {
|
||||
id: "mesh.hex.blocks",
|
||||
label: "Block Hex Mesh",
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets, size controls (mesh.size.set; one on a whole cell refines its surface) setting the counts and spacing through the graded size field. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
params: r#"{"bodies": ["Cell1", "Cell2"] (default: every body of the open design), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal of all of them), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of that diagonal)} — replaces the cells' meshes (as mesh.tet.generate; other bodies keep theirs) with one conforming all-hex mesh of a part partitioned into cells (bodies that meet face to face, such as the pieces of solid.split_body in SolidCraft): every cell a mapped block (six four-sided faces; faces that meet smoothly, such as the two halves of a hole, count as one), division counts assigned over all cells together, each shared face meshed once and its nodes merged, radial lines graded off holes and fillets. Elements are tagged with their cell's body and boundary faces with its B-rep faces (`weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate). Reports the cells, the shared faces, counts and quality; a cell that is not a block, or cells that touch without sharing whole faces, is an error that says which"#,
|
||||
form: Some(super::forms::HEX_BLOCKS),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
@@ -232,9 +220,7 @@ pub(super) fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
seeds: super::seeds::edge_seeds(s)?,
|
||||
..HexOptions::default()
|
||||
};
|
||||
let names: Vec<&str> = inputs.iter().map(|b| b.name.as_str()).collect();
|
||||
let field = super::sizes::sizing(s)?.joint(&names).field("", h)?;
|
||||
let cm = hex_mesh_cells(&cells, &field, &options).map_err(|e| match e {
|
||||
let cm = hex_mesh_cells(&cells, &UniformSize(h), &options).map_err(|e| match e {
|
||||
fieldcraft_mesh::Error::Model(fieldcraft_model::Error::Cancelled) => EngineError::Model(fieldcraft_model::Error::Cancelled),
|
||||
e => EngineError::Params(e.to_string()),
|
||||
})?;
|
||||
@@ -299,10 +285,9 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
})?,
|
||||
};
|
||||
let bodies = inputs(s, p)?;
|
||||
let sizing = super::sizes::sizing(s)?;
|
||||
let mut meshes: Vec<(Input, HexMesh)> = Vec::with_capacity(bodies.len());
|
||||
for b in bodies {
|
||||
let mesh = hex_body(p, &b, method, quadratic, size, &sizing)?;
|
||||
let mesh = hex_body(p, &b, method, quadratic, size)?;
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
// Replace the bodies' meshes (replace.rs); other bodies and the definitions stay.
|
||||
@@ -330,13 +315,13 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent) and
|
||||
/// the size controls of `sizing`; the model is not touched. Errors name the body.
|
||||
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>, sizing: &Sizing) -> Result<HexMesh> {
|
||||
/// Mesh one body with `method` (hex20 when `quadratic`) at `size` (default: from its extent);
|
||||
/// the model is not touched. Errors name the body.
|
||||
pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool, size: Option<f64>) -> Result<HexMesh> {
|
||||
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
|
||||
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
|
||||
}
|
||||
let h = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
|
||||
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)));
|
||||
}
|
||||
@@ -352,8 +337,7 @@ pub(super) fn hex_body(p: &Value, b: &Input, method: HexMethod, quadratic: bool,
|
||||
seeds: b.seeds.clone(),
|
||||
..HexOptions::default()
|
||||
};
|
||||
let field = sizing.field(&b.name, h)?;
|
||||
hex_mesh(&surface, &b.surfaces, &field, &options).map_err(|e| EngineError::of_body(&b.name, e))
|
||||
hex_mesh(&surface, &b.surfaces, &UniformSize(h), &options).map_err(|e| EngineError::of_body(&b.name, e))
|
||||
}
|
||||
|
||||
/// Add a mesh of several cells (`inputs[c]` is cell `c`) after the model's largest labels, its
|
||||
|
||||
@@ -7,7 +7,6 @@ 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::canonical::CanonicalSoup;
|
||||
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
|
||||
use fieldcraft_mesh::tet::improve::ImproveOptions;
|
||||
use fieldcraft_mesh::tet::{Order, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
|
||||
@@ -128,7 +127,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" (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; default the model's growth rate, mesh.size.growth), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them; size controls (mesh.size.set) refine it, graded at the model's growth rate (mesh.size.growth) unless `grading` is given. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Replaces the mesh of each body it meshes (its elements and the nodes only they use; other bodies' meshes stay; the reply's `replaced`, `stale_sets` and `results_cleared` say what went), adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Edge seeds (mesh.seed.edge) divide their edges at the seeded nodes, and the size field grades from them. Reports the totals and, under `bodies`, each body's counts, size, quality report and `seeds` (per seeded edge, the mesh edges along it)"#,
|
||||
form: Some(super::forms::TET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -151,7 +150,7 @@ pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops
|
||||
}
|
||||
|
||||
/// A whole number in `range`, as a problem's kind.
|
||||
pub(super) fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
|
||||
fn whole_in(range: &std::ops::RangeInclusive<u64>) -> ProblemKind {
|
||||
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
|
||||
ProblemKind::NotAWholeNumber { min: int(*range.start()), max: int(*range.end()) }
|
||||
}
|
||||
@@ -217,30 +216,21 @@ pub(super) fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOption
|
||||
/// 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>> {
|
||||
shape.face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect()
|
||||
}
|
||||
|
||||
fn analytic_surface(f: fieldcraft_geometry::FaceSurface) -> AnalyticSurface {
|
||||
use fieldcraft_geometry::FaceSurface as 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 },
|
||||
}
|
||||
}
|
||||
|
||||
/// The B-rep pieces of `shape`'s merged faces (virtual topology) for a projection target built
|
||||
/// on its tessellation whose triangles lie on the B-rep faces `tri_piece`
|
||||
/// ([`fieldcraft_geometry::Shape::tessellate_pieces`]): a merged face on no one surface then
|
||||
/// projects onto each piece's own; none when no face is merged.
|
||||
pub(crate) fn pieces(shape: &fieldcraft_geometry::Shape, tri_piece: &[u32]) -> fieldcraft_mesh::tet::geometry::Pieces {
|
||||
if !shape.has_merged_faces() {
|
||||
return fieldcraft_mesh::tet::geometry::Pieces::default();
|
||||
}
|
||||
let analytic = shape.brep_face_surfaces().unwrap_or_default().into_iter().map(|f| f.map(analytic_surface)).collect();
|
||||
fieldcraft_mesh::tet::geometry::Pieces { tri_piece: tri_piece.to_vec(), analytic }
|
||||
shape
|
||||
.face_surfaces()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
f.map(|f| match f {
|
||||
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
|
||||
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
|
||||
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
|
||||
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
|
||||
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn quality_json(q: &TetQuality) -> Value {
|
||||
@@ -250,7 +240,6 @@ pub(super) fn quality_json(q: &TetQuality) -> Value {
|
||||
"max_dihedral": q.max_dihedral,
|
||||
"min_radius_ratio": q.min_radius_ratio,
|
||||
"mean_radius_ratio": q.mean_radius_ratio,
|
||||
"min_scaled_jacobian": q.min_scaled_jacobian,
|
||||
"slivers": q.slivers,
|
||||
"inadmissible": q.inadmissible,
|
||||
})
|
||||
@@ -290,19 +279,15 @@ pub(super) fn mesh_body(
|
||||
Some(_) => Vec::new(),
|
||||
};
|
||||
fieldcraft_model::progress::stage(&format!("{}: boundary", input.name), None, "");
|
||||
// The mesh depends on the geometry only, not on the order the kernel lists it in.
|
||||
let soup = CanonicalSoup::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face)?;
|
||||
let surface = BoundarySurface::new(&soup.positions, &soup.triangles, &soup.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
|
||||
fieldcraft_model::progress::stage(&format!("{}: Delaunay", input.name), None, "");
|
||||
let tets = tetrahedralize(&surface, cdt)?;
|
||||
let mut mesh = if quality {
|
||||
let frozen_faces = options.frozen_faces.iter().filter_map(|&f| soup.canonical_tag(f)).collect();
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), frozen_faces, ..options.clone() };
|
||||
quality_mesh(&surface, &tets, soup.analytic(&analytic), &options)?
|
||||
let mesh = if quality {
|
||||
let options = QualityOptions { seeds: input.seeds.clone(), ..options.clone() };
|
||||
quality_mesh(&surface, &tets, analytic, &options)?
|
||||
} else {
|
||||
raw_mesh(&surface, &tets)?
|
||||
};
|
||||
soup.restore_tags(&mut mesh);
|
||||
Ok(BodyTets {
|
||||
name: input.name.clone(),
|
||||
face_names: input.face_names.clone(),
|
||||
@@ -321,11 +306,9 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 sizing = super::sizes::sizing(s)?;
|
||||
let several = inputs.len() > 1;
|
||||
let mut meshed = Vec::with_capacity(inputs.len());
|
||||
for input in inputs {
|
||||
let options = sizing.tet_options(&input.name, p, &options);
|
||||
meshed.push(mesh_body(s.design.as_deref(), p, input, &cdt_options, &options, quality, several)?);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ mod cells;
|
||||
mod contact;
|
||||
pub(crate) mod datum;
|
||||
pub(crate) mod deck;
|
||||
mod defeature;
|
||||
pub(crate) mod define;
|
||||
pub mod diagnostics;
|
||||
mod edit;
|
||||
@@ -18,18 +17,12 @@ pub(crate) mod mesh;
|
||||
|
||||
pub use datum::edit_params as datum_edit_params;
|
||||
pub use datum::preview as datum_preview;
|
||||
pub use fieldcraft_mesh::relax::{ElementChange, Moved as NodeMoved};
|
||||
pub use mesh::analytic_surfaces;
|
||||
pub use node_move::{
|
||||
Capped as NodeMoveCapped, Prepared as NodeMovePrepared, nearest_node as node_move_nearest, prepare as node_move_prepare,
|
||||
prepare_capped as node_move_prepare_capped,
|
||||
};
|
||||
pub use nodes::merge_preview as node_merge_preview;
|
||||
pub use plan::untagged_faces;
|
||||
pub use relax::{Preview as RelaxPreview, preview as relax_preview, sphere as relax_sphere};
|
||||
mod mirror;
|
||||
mod model;
|
||||
pub(crate) mod node_move;
|
||||
pub(crate) mod nodes;
|
||||
pub(crate) mod octree;
|
||||
pub(crate) mod plan;
|
||||
@@ -38,7 +31,6 @@ pub(crate) mod relax;
|
||||
pub mod replace;
|
||||
mod results;
|
||||
pub(crate) mod seeds;
|
||||
pub(crate) mod sizes;
|
||||
pub(crate) mod solve;
|
||||
pub(crate) mod stitch;
|
||||
mod symmetry;
|
||||
@@ -86,9 +78,6 @@ pub static ALL: &[CommandSpec] = &[
|
||||
virtual_faces::FACE_ABSORB,
|
||||
virtual_faces::FACE_SPLIT,
|
||||
virtual_faces::FACE_SPLIT_DELETE,
|
||||
defeature::DEFEATURE,
|
||||
defeature::DEFEATURE_DELETE,
|
||||
defeature::DEFEATURE_PROPOSE,
|
||||
stitch::STITCH,
|
||||
stitch::STITCH_DELETE,
|
||||
mesh::NODE_ADD,
|
||||
@@ -117,17 +106,12 @@ pub static ALL: &[CommandSpec] = &[
|
||||
seeds::SEED_EDGE,
|
||||
seeds::SEED_CLEAR,
|
||||
seeds::SEED_LIST,
|
||||
sizes::SIZE_SET,
|
||||
sizes::SIZE_GROWTH,
|
||||
sizes::SIZE_CLEAR,
|
||||
sizes::SIZE_LIST,
|
||||
mirror::MIRROR,
|
||||
nodes::MERGE,
|
||||
nodes::DUPLICATES,
|
||||
nodes::RENUMBER,
|
||||
relax::RELAX,
|
||||
relax::APPLY,
|
||||
node_move::MOVE,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
model::UNITS,
|
||||
@@ -177,7 +161,6 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::FLUID_DOMAIN_EDIT,
|
||||
define::FLUID_DOMAIN_DELETE,
|
||||
define::FLUID_BOUNDARY,
|
||||
define::FLUID_THERMAL_BOUNDARY,
|
||||
define::STEP_CREATE,
|
||||
define::STEP_EDIT,
|
||||
define::STEP_DELETE,
|
||||
@@ -210,9 +193,6 @@ pub static ALL: &[CommandSpec] = &[
|
||||
results::MODES,
|
||||
results::ANIMATE,
|
||||
results::FRAME,
|
||||
results::SLICE,
|
||||
results::SURFACE,
|
||||
results::STREAMLINES,
|
||||
results::REACTIONS,
|
||||
results::EXPORT_CSV,
|
||||
results::EXPORT_VTU,
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Moving nodes by hand: `mesh.node.move` displaces grabbed nodes (listed, on listed B-rep faces,
|
||||
//! or a sphere's) by a vector or to a point, the nodes round them following by a falloff or by
|
||||
//! harmonic interpolation ([`fieldcraft_mesh::relax::move_weights`]), nodes on the geometry
|
||||
//! staying on it as Relax Nodes moves them ([`super::relax`]), the move scaled back until no
|
||||
//! element inverts; it reports what the move did to the elements' quality.
|
||||
//!
|
||||
//! The interactive tool (drag a node in the view) works out the same move live: [`prepare`] does
|
||||
//! the part that does not depend on the displacement once (which nodes follow, and the design's
|
||||
//! faces they slide on), and [`Prepared::preview`] applies a displacement to it.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_mesh::relax::{self, Constraints, Falloff, Guard, Influence, Moved, Propagate, Unconstrained};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::relax::{DesignGeometry, design_geometry, face_refs, nodes_on_faces, point, quality_json, unmeshed_faces};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const MOVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.move",
|
||||
label: "Move Nodes",
|
||||
params: r#"{"nodes": [labels] (the nodes to grab) | "faces": [{"body": "Base", "face": "F3"}] (every node on these B-rep faces) | "center": [x, y, z] with "radius": mm (a sphere: every node inside follows by the falloff of its distance to the centre), "by": [dx, dy, dz] | "to": [x, y, z] (where the grabbed nodes' centroid, or the centre, goes), "radius"?: mm (with nodes or faces: the nodes this close to a grabbed one follow; default none, only the grabbed nodes move), "propagate"?: "falloff" (default) | "harmonic" (the displacement carried into the nodes within the radius by harmonic interpolation on the graph of element edges, 1 at the grabbed nodes and zero beyond the radius; needs a radius, and the grabbed nodes: a sphere grabs its nearest node), "falloff"?: "smooth" (default) | "linear" | "none", "strength"?: 1 (0..1]: the largest share of the displacement a node that is not grabbed takes (falloff only), "on_geometry"?: true (nodes on a B-rep face slide on it in its UV parameters, on a B-rep edge along it, on a vertex stay, and skin nodes of a mesh without geometry stay; false: every node moves in space), "guard"?: "no_inversion" (default: the move is scaled back until no element inverts or falls below `min_quality`) | "none", "min_quality"?: 0.05 (0..1, no_inversion only: the smallest scaled Jacobian the move may leave an element with; an element already below keeps at least its own)} — moves the nodes; mid-side nodes follow. Reports the nodes moved, the largest displacement, the displacement applied and its scale (below 1 when the guard scaled it back), the quality of the elements round the moved nodes before and after (smallest and mean scaled Jacobian, largest edge ratio, invalid elements, the worst) and how many got worse and better. One undo step; a move that moves nothing (a zero displacement, or one the guard scales back to nothing) changes nothing and records no undo step (`"moved": 0`, `"nothing_moved": true`)"#,
|
||||
form: Some(super::forms::NODE_MOVE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: move_nodes,
|
||||
};
|
||||
|
||||
/// Where nodes may go: the design's faces and edges, or anywhere.
|
||||
enum Geometry {
|
||||
Design(DesignGeometry),
|
||||
Free,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn constraints(&self) -> &dyn Constraints {
|
||||
match self {
|
||||
Geometry::Design(g) => g,
|
||||
Geometry::Free => &Unconstrained,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A move worked out up to its displacement: who follows and by how much, and where they may go.
|
||||
pub struct Prepared {
|
||||
/// Node → its share of the displacement.
|
||||
weights: BTreeMap<u32, f64>,
|
||||
/// The grabbed nodes.
|
||||
handles: BTreeSet<u32>,
|
||||
/// Where a move `to` a point starts from: the grabbed nodes' centroid, or the centre.
|
||||
origin: Option<[f64; 3]>,
|
||||
/// The sphere of influence round the centre or the grabbed nodes, for the view.
|
||||
pub sphere: Option<([f64; 3], f64)>,
|
||||
guard: Guard,
|
||||
geometry: Geometry,
|
||||
/// The region's elements' quality before the move, measured by the first live preview.
|
||||
baseline: std::sync::OnceLock<Vec<(f64, bool)>>,
|
||||
}
|
||||
|
||||
/// Halvings of the guard's bisection in a live preview (the scale to within 2⁻⁶; the command
|
||||
/// itself finds it to 2⁻²⁰).
|
||||
const PREVIEW_GUARD_STEPS: usize = 6;
|
||||
|
||||
impl Prepared {
|
||||
/// Nodes that take a share of the displacement.
|
||||
pub fn nodes(&self) -> usize {
|
||||
self.weights.len()
|
||||
}
|
||||
|
||||
/// The grabbed nodes.
|
||||
pub fn handles(&self) -> &BTreeSet<u32> {
|
||||
&self.handles
|
||||
}
|
||||
|
||||
/// The displacement `params` ask for (`by`, or `to` less the origin).
|
||||
pub fn displacement(&self, params: &Value) -> Result<[f64; 3]> {
|
||||
match (point(params, "by")?, point(params, "to")?) {
|
||||
(Some(_), Some(_)) => Err(EngineError::Params("give `by` or `to`, not both".into())),
|
||||
(Some(d), None) => Ok(d),
|
||||
(None, Some(to)) => {
|
||||
let o = self.origin.ok_or_else(|| EngineError::Params("no node to move `to` a point".into()))?;
|
||||
Ok([to[0] - o[0], to[1] - o[1], to[2] - o[2]])
|
||||
}
|
||||
(None, None) => Err(EngineError::Params("say how far to move: give `by` [dx, dy, dz] or `to` [x, y, z]".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The move by `d` on `s`'s mesh, without doing it.
|
||||
pub fn preview(&self, s: &Session, d: [f64; 3]) -> Result<Moved> {
|
||||
Ok(relax::move_nodes(&s.model, &self.weights, d, self.guard, self.geometry.constraints())?)
|
||||
}
|
||||
|
||||
/// [`Prepared::preview`] for a drag frame: the region's quality before the move is measured
|
||||
/// once and reused, and the guard's scale is found more coarsely.
|
||||
pub fn preview_live(&self, s: &Session, d: [f64; 3]) -> Result<Moved> {
|
||||
let run = relax::MoveRun { baseline: self.baseline.get().cloned(), guard_steps: PREVIEW_GUARD_STEPS };
|
||||
let m = relax::move_nodes_with(&s.model, &self.weights, d, self.guard, self.geometry.constraints(), &run)?;
|
||||
if self.baseline.get().is_none() {
|
||||
let _ = self.baseline.set(m.elements.iter().map(|c| (c.before, c.valid_before)).collect());
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner node nearest `p`: the node a drag in the view grabs.
|
||||
pub fn nearest_node(s: &Session, p: [f64; 3]) -> Option<u32> {
|
||||
relax::nearest_corner(&s.model, p)
|
||||
}
|
||||
|
||||
/// A positive number parameter, when given.
|
||||
fn positive(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(_) => Some(super::optional_number(p, key, 0.0)?)
|
||||
.filter(|r| *r > 0.0 && r.is_finite())
|
||||
.ok_or_else(|| EngineError::param(key, ProblemKind::NotPositive))
|
||||
.map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything of `mesh.node.move` but its displacement.
|
||||
pub fn prepare(s: &Session, p: &Value) -> Result<Prepared> {
|
||||
match prepare_capped(s, p, None)? {
|
||||
Capped::Ready(prep) => Ok(*prep),
|
||||
Capped::TooLarge(n) => Err(EngineError::Params(format!("{n} nodes to move"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`prepare`], or the count of the nodes that would follow when it is more than `cap`: the
|
||||
/// costly part (the design's faces, the harmonic solve) is then not done.
|
||||
pub enum Capped {
|
||||
Ready(Box<Prepared>),
|
||||
TooLarge(usize),
|
||||
}
|
||||
|
||||
/// [`prepare`] that stops before the costly part when more than `cap` nodes would follow.
|
||||
pub fn prepare_capped(s: &Session, p: &Value, cap: Option<usize>) -> Result<Capped> {
|
||||
let model = &s.model;
|
||||
let propagate = match super::opt::<String>(p, "propagate")? {
|
||||
None => Propagate::Falloff,
|
||||
Some(v) => Propagate::from_name(&v).ok_or_else(|| EngineError::param("propagate", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
if propagate == Propagate::Harmonic && ["falloff", "strength"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(EngineError::Params("`falloff` and `strength` go with \"propagate\": \"falloff\"".into()));
|
||||
}
|
||||
let falloff = match super::opt::<String>(p, "falloff")? {
|
||||
None => Falloff::Smooth,
|
||||
Some(f) => Falloff::from_name(&f).ok_or_else(|| EngineError::param("falloff", ProblemKind::NotAChoice))?,
|
||||
};
|
||||
let strength = super::optional_number(p, "strength", 1.0)?;
|
||||
if !(strength > 0.0 && strength <= 1.0) {
|
||||
return Err(EngineError::param("strength", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let min_quality = super::optional_number(p, "min_quality", relax::DEFAULT_MIN_QUALITY)?;
|
||||
if !(0.0..=1.0).contains(&min_quality) {
|
||||
return Err(EngineError::param("min_quality", ProblemKind::NotInRange { min: 0.0, max: 1.0 }));
|
||||
}
|
||||
let guard = match super::opt::<String>(p, "guard")?.as_deref() {
|
||||
None | Some("no_inversion") => Guard::NoInversion { min_quality },
|
||||
Some("none") if p.get("min_quality").is_some() => {
|
||||
return Err(EngineError::Params("`min_quality` goes with \"guard\": \"no_inversion\"".into()));
|
||||
}
|
||||
Some("none") => Guard::None,
|
||||
Some(_) => return Err(EngineError::param("guard", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let on_geometry = super::opt::<bool>(p, "on_geometry")?.unwrap_or(true);
|
||||
let radius = positive(p, "radius")?;
|
||||
let centre = point(p, "center")?;
|
||||
let listed = ["nodes", "faces"].iter().filter(|k| p.get(**k).is_some()).count() + usize::from(centre.is_some());
|
||||
if listed != 1 {
|
||||
return Err(EngineError::Params("say which nodes to move: give one of `nodes`, `faces` or `center` (with `radius`)".into()));
|
||||
}
|
||||
let mut handles: BTreeSet<u32> = BTreeSet::new();
|
||||
if p.get("nodes").is_some() {
|
||||
for n in super::labels(p, "nodes", fieldcraft_model::MAX_NODES)? {
|
||||
if model.node(n).is_none() {
|
||||
return Err(EngineError::Params(format!("no node {n}")));
|
||||
}
|
||||
handles.insert(n);
|
||||
}
|
||||
}
|
||||
if p.get("faces").is_some() {
|
||||
handles.extend(nodes_on_faces(model, &face_refs(p)?)?);
|
||||
}
|
||||
let mut origin = relax::centroid(model, &handles);
|
||||
if let Some(c) = centre {
|
||||
if radius.is_none() {
|
||||
return Err(EngineError::Params("a sphere needs both `center` and `radius`".into()));
|
||||
}
|
||||
origin = Some(c);
|
||||
// A harmonic move needs something to carry: the sphere grabs the node nearest its centre.
|
||||
if propagate == Propagate::Harmonic {
|
||||
handles.extend(relax::nearest_corner(model, c));
|
||||
}
|
||||
}
|
||||
let influence = Influence { centre: if propagate == Propagate::Falloff { centre } else { None }, radius, propagate, strength, falloff };
|
||||
// The constraints of every node that might take a share: the grabbed ones and those within
|
||||
// the radius (all of the mesh's corner nodes when the region is not known yet).
|
||||
let candidates =
|
||||
relax::move_weights(model, &handles, &Influence { propagate: Propagate::Falloff, falloff: Falloff::None, ..influence }, &Unconstrained)?;
|
||||
if let Some(cap) = cap
|
||||
&& candidates.len() > cap
|
||||
{
|
||||
return Ok(Capped::TooLarge(candidates.len()));
|
||||
}
|
||||
let geometry = if on_geometry {
|
||||
let mut nodes: BTreeSet<u32> = candidates.keys().copied().collect();
|
||||
// Their elements' nodes too: the mid-side nodes that follow.
|
||||
for e in model.elements().values() {
|
||||
if e.nodes.iter().any(|n| candidates.contains_key(n)) {
|
||||
nodes.extend(e.nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
Geometry::Design(design_geometry(s, &nodes)?)
|
||||
} else {
|
||||
Geometry::Free
|
||||
};
|
||||
let weights = relax::move_weights(model, &handles, &influence, geometry.constraints())?;
|
||||
if weights.is_empty() {
|
||||
return Err(EngineError::Params("no mesh nodes to move: the nodes given are no element's corners, or none lies in the sphere".into()));
|
||||
}
|
||||
let sphere = radius.and_then(|r| centre.or(origin).map(|c| (c, r)));
|
||||
Ok(Capped::Ready(Box::new(Prepared { weights, handles, origin, sphere, guard, geometry, baseline: std::sync::OnceLock::new() })))
|
||||
}
|
||||
|
||||
/// The quality of the region as JSON, with its mean scaled Jacobian.
|
||||
fn quality(q: &relax::RegionQuality, mean: f64) -> Value {
|
||||
let mut v = quality_json(q);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("mean_scaled_jacobian".into(), json!(mean));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn move_nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let prep = prepare(s, p)?;
|
||||
let d = prep.displacement(p)?;
|
||||
let r = prep.preview(s, d)?;
|
||||
let asked = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if r.moved == 0 && asked > 0.0 && r.scale > 0.0 {
|
||||
return Err(EngineError::Params(
|
||||
"the grabbed nodes may not move: they are on B-rep vertices (or on the skin of a mesh without geometry); give \"on_geometry\": false to move them freely".into(),
|
||||
));
|
||||
}
|
||||
if r.positions.is_empty() {
|
||||
// Nothing to do: the model is unchanged, so no undo step is recorded.
|
||||
let why = if asked == 0.0 {
|
||||
"the displacement is zero"
|
||||
} else {
|
||||
"the guard scaled the move back to nothing (any move would invert an element or go below min_quality)"
|
||||
};
|
||||
return Ok(json!({
|
||||
"moved": 0,
|
||||
"nodes_changed": 0,
|
||||
"nothing_moved": true,
|
||||
"scale": r.scale,
|
||||
"before": quality(&r.before, r.mean_before),
|
||||
"summary": format!("nothing moved: {why}"),
|
||||
}));
|
||||
}
|
||||
for (n, x) in &r.positions {
|
||||
s.model.set_node(*n, *x)?;
|
||||
}
|
||||
let summary = format!(
|
||||
"moved {} node{} (largest {:.3e}{}); scaled Jacobian min {:.3} to {:.3}, mean {:.3} to {:.3}; {} element{} worse, {} better",
|
||||
r.moved,
|
||||
if r.moved == 1 { "" } else { "s" },
|
||||
r.max_displacement,
|
||||
if r.scale < 1.0 { format!(", scaled to {:.3} so that no element inverts", r.scale) } else { String::new() },
|
||||
r.before.min_quality,
|
||||
r.after.min_quality,
|
||||
r.mean_before,
|
||||
r.mean_after,
|
||||
r.worse,
|
||||
if r.worse == 1 { "" } else { "s" },
|
||||
r.better,
|
||||
);
|
||||
Ok(json!({
|
||||
"moved": r.moved,
|
||||
"nodes_changed": r.positions.len(),
|
||||
"grabbed": prep.handles.len(),
|
||||
"max_displacement": r.max_displacement,
|
||||
"displacement": d.map(|x| x * r.scale),
|
||||
"scale": r.scale,
|
||||
"before": quality(&r.before, r.mean_before),
|
||||
"after": quality(&r.after, r.mean_after),
|
||||
"worse": r.worse,
|
||||
"better": r.better,
|
||||
"sphere": prep.sphere.map(|(c, r)| json!({"center": c, "radius": r})),
|
||||
"faces_without_mesh": unmeshed_faces(&s.model, p),
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,274 +0,0 @@
|
||||
use super::*;
|
||||
use fieldcraft_model::Model;
|
||||
|
||||
/// The plate (40 × 30 × 10) with its Ø10 hole round (15, 15).
|
||||
const HOLE: ([f64; 2], f64) = ([15.0, 15.0], 5.0);
|
||||
|
||||
fn plate() -> Session {
|
||||
let mut s = super::super::datum::tests::plate().0;
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn hole_radius(p: [f64; 3]) -> f64 {
|
||||
((p[0] - HOLE.0[0]).powi(2) + (p[1] - HOLE.0[1]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
(0..3).map(|i| (a[i] - b[i]).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// The node nearest `p` among those `keep` accepts.
|
||||
fn nearest(m: &Model, p: [f64; 3], keep: impl Fn([f64; 3]) -> bool) -> u32 {
|
||||
let mut best = (f64::INFINITY, 0);
|
||||
for (n, x) in m.nodes() {
|
||||
if keep(x.xyz) && dist(x.xyz, p) < best.0 {
|
||||
best = (dist(x.xyz, p), *n);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// An interior node (off every face of the plate).
|
||||
fn interior(m: &Model) -> u32 {
|
||||
nearest(m, [30.0, 15.0, 5.0], |p| p[2] > 1e-6 && p[2] < 10.0 - 1e-6 && p[0] > 1e-6 && p[0] < 40.0 - 1e-6 && p[1] > 1e-6 && p[1] < 30.0 - 1e-6)
|
||||
}
|
||||
|
||||
fn invalid(s: &mut Session) -> u64 {
|
||||
s.execute("mesh.quality", &json!({})).unwrap()["invalid"]["count"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `by` moves the grabbed node by the vector, the reply carries the quality before and after,
|
||||
/// one undo step takes it back and redo does it again.
|
||||
#[test]
|
||||
fn by_moves_a_node_in_one_undo_step() {
|
||||
let mut s = plate();
|
||||
let start = s.model.clone();
|
||||
let n = interior(&s.model);
|
||||
let p0 = s.model.node(n).unwrap().xyz;
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.3, -0.2, 0.1]})).unwrap();
|
||||
assert_eq!((r["moved"].as_u64(), r["scale"].as_f64(), r["grabbed"].as_u64()), (Some(1), Some(1.0), Some(1)), "{r}");
|
||||
assert!(dist(s.model.node(n).unwrap().xyz, [p0[0] + 0.3, p0[1] - 0.2, p0[2] + 0.1]) < 1e-12);
|
||||
assert!(r["before"]["min_scaled_jacobian"].as_f64().unwrap() > r["after"]["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
|
||||
assert!(r["worse"].as_u64().unwrap() > 0 && r["before"]["mean_scaled_jacobian"].is_number(), "{r}");
|
||||
let moved = s.model.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, start);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.model, moved);
|
||||
}
|
||||
|
||||
/// `to` puts the grabbed nodes' centroid (or the sphere's centre) at the point.
|
||||
#[test]
|
||||
fn to_puts_the_centroid_at_the_point() {
|
||||
let mut s = plate();
|
||||
let n = interior(&s.model);
|
||||
let p0 = s.model.node(n).unwrap().xyz;
|
||||
let to = [p0[0] + 0.25, p0[1], p0[2] - 0.25];
|
||||
s.execute("mesh.node.move", &json!({"nodes": [n], "to": to})).unwrap();
|
||||
assert!(dist(s.model.node(n).unwrap().xyz, to) < 1e-12);
|
||||
let c = s.model.node(n).unwrap().xyz;
|
||||
let r = s.execute("mesh.node.move", &json!({"center": c, "radius": 3.0, "to": [c[0], c[1] + 0.2, c[2]]})).unwrap();
|
||||
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
|
||||
assert!((s.model.node(n).unwrap().xyz[1] - c[1] - 0.2).abs() < 1e-9, "the node at the centre takes all of it");
|
||||
}
|
||||
|
||||
/// Each falloff moves the nodes within the radius, none beyond, the grabbed node by all of it;
|
||||
/// they move the followers differently.
|
||||
#[test]
|
||||
fn each_falloff_moves_only_within_the_radius() {
|
||||
let mut followers = Vec::new();
|
||||
for falloff in ["smooth", "linear", "none"] {
|
||||
let mut s = plate();
|
||||
let start = s.model.clone();
|
||||
let n = interior(&s.model);
|
||||
let p0 = start.node(n).unwrap().xyz;
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.0, 0.2], "radius": 4.0, "falloff": falloff})).unwrap();
|
||||
assert!(r["moved"].as_u64().unwrap() > 1, "{falloff}: {r}");
|
||||
let mut sum = 0.0;
|
||||
for (k, x) in s.model.nodes() {
|
||||
let old = start.node(*k).unwrap().xyz;
|
||||
if old != x.xyz {
|
||||
assert!(dist(old, p0) < 4.0 + 1e-9, "{falloff}: node {k} beyond the radius moved");
|
||||
sum += dist(old, x.xyz);
|
||||
}
|
||||
}
|
||||
assert!((s.model.node(n).unwrap().xyz[2] - p0[2] - 0.2).abs() < 1e-12);
|
||||
followers.push(sum);
|
||||
}
|
||||
assert!(followers[0] < followers[1] && followers[1] < followers[2], "{followers:?}");
|
||||
}
|
||||
|
||||
/// Harmonic: the nodes within the radius follow, the move falls to nothing at the radius and no
|
||||
/// node beyond it moves; falloff and strength do not go with it.
|
||||
#[test]
|
||||
fn harmonic_propagation_is_zero_outside_the_radius() {
|
||||
let mut s = plate();
|
||||
let start = s.model.clone();
|
||||
let n = interior(&s.model);
|
||||
let p0 = start.node(n).unwrap().xyz;
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.2, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic"})).unwrap();
|
||||
assert!(r["moved"].as_u64().unwrap() > 5, "{r}");
|
||||
for (k, x) in s.model.nodes() {
|
||||
let old = start.node(*k).unwrap().xyz;
|
||||
if old != x.xyz {
|
||||
let d = dist(old, p0);
|
||||
assert!(d < 5.0 + 1e-9, "node {k} beyond the radius moved");
|
||||
assert!(dist(old, x.xyz) <= 0.2 + 1e-12);
|
||||
}
|
||||
}
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
let e = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "radius": 5.0, "propagate": "harmonic", "falloff": "linear"}));
|
||||
assert!(e.is_err());
|
||||
assert!(s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.1, 0.0, 0.0], "propagate": "harmonic"})).is_err(), "needs a radius");
|
||||
}
|
||||
|
||||
/// On the geometry: a node on the hole slides round it (stays on the cylinder), its followers on
|
||||
/// the caps stay in their planes; a node at a corner of the plate is held (an error says how to
|
||||
/// move it), and moves with the geometry off.
|
||||
#[test]
|
||||
fn on_geometry_keeps_nodes_on_faces_and_edges_and_holds_vertices() {
|
||||
let mut s = plate();
|
||||
let start = s.model.clone();
|
||||
let on_hole = |p: [f64; 3]| (hole_radius(p) - HOLE.1).abs() < 1e-6;
|
||||
let n = nearest(&s.model, [20.0, 15.0, 5.0], |p| on_hole(p) && p[2] > 1e-6 && p[2] < 10.0 - 1e-6);
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0.0, 0.6, 0.4], "radius": 3.0, "propagate": "harmonic"})).unwrap();
|
||||
assert!(r["moved"].as_u64().unwrap() > 1, "{r}");
|
||||
assert_ne!(s.model.node(n).unwrap().xyz, start.node(n).unwrap().xyz);
|
||||
for (k, x) in s.model.nodes() {
|
||||
let old = start.node(*k).unwrap().xyz;
|
||||
if on_hole(old) {
|
||||
assert!(on_hole(x.xyz), "node {k} left the hole: r = {}", hole_radius(x.xyz));
|
||||
}
|
||||
for z in [0.0, 10.0] {
|
||||
if (old[2] - z).abs() < 1e-9 {
|
||||
assert!((x.xyz[2] - z).abs() < 1e-9, "node {k} left its cap");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A node on the hole's rim (the edge of the hole and a cap) slides along the rim.
|
||||
let rim = nearest(&start, [20.0, 15.0, 10.0], |p| on_hole(p) && (p[2] - 10.0).abs() < 1e-9);
|
||||
let mut s2 = plate();
|
||||
s2.execute("mesh.node.move", &json!({"nodes": [rim], "by": [0.0, 0.8, 0.5]})).unwrap();
|
||||
let x = s2.model.node(rim).unwrap().xyz;
|
||||
assert!(on_hole(x) && (x[2] - 10.0).abs() < 1e-9 && x != start.node(rim).unwrap().xyz, "{x:?}");
|
||||
// The plate's corner is a vertex.
|
||||
let corner = nearest(&start, [0.0, 0.0, 0.0], |_| true);
|
||||
let e = s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3]})).unwrap_err();
|
||||
assert!(e.to_string().contains("on_geometry"), "{e}");
|
||||
s2.execute("mesh.node.move", &json!({"nodes": [corner], "by": [0.3, 0.3, 0.3], "on_geometry": false})).unwrap();
|
||||
assert!(dist(s2.model.node(corner).unwrap().xyz, [0.3, 0.3, 0.3]) < 1e-9);
|
||||
}
|
||||
|
||||
/// The guard scales a move that would invert elements back so that none does; without it they
|
||||
/// invert.
|
||||
#[test]
|
||||
fn the_guard_scales_back_to_no_inversion() {
|
||||
let mut s = plate();
|
||||
let n = interior(&s.model);
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0]})).unwrap();
|
||||
let scale = r["scale"].as_f64().unwrap();
|
||||
assert!(scale > 0.0 && scale < 1.0, "{r}");
|
||||
assert_eq!(r["after"]["invalid"], 0, "{r}");
|
||||
assert_eq!(invalid(&mut s), 0);
|
||||
let mut s = plate();
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "guard": "none"})).unwrap();
|
||||
assert_eq!(r["scale"], 1.0);
|
||||
assert!(r["after"]["invalid"].as_u64().unwrap() > 0, "{r}");
|
||||
}
|
||||
|
||||
/// The preview of the interactive tool is the command's move, without doing it.
|
||||
#[test]
|
||||
fn the_preview_is_the_move_without_doing_it() {
|
||||
let mut s = plate();
|
||||
let n = interior(&s.model);
|
||||
let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0});
|
||||
let prep = prepare(&s, &p).unwrap();
|
||||
assert!(prep.nodes() > 1 && prep.handles().contains(&n) && prep.sphere.is_some());
|
||||
let start = s.model.clone();
|
||||
let preview = prep.preview(&s, prep.displacement(&p).unwrap()).unwrap();
|
||||
assert_eq!(s.model, start);
|
||||
s.execute("mesh.node.move", &p).unwrap();
|
||||
for (k, x) in &preview.positions {
|
||||
assert_eq!(s.model.node(*k).unwrap().xyz, *x);
|
||||
}
|
||||
assert!(preview.elements.iter().any(|e| e.after != e.before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_move_params_are_refused() {
|
||||
let mut s = plate();
|
||||
let n = interior(&s.model);
|
||||
let start = s.model.clone();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"nodes": [n]}),
|
||||
json!({"by": [1, 0, 0]}),
|
||||
json!({"nodes": [n], "by": [1, 0]}),
|
||||
json!({"nodes": [n], "by": ["x", 0, 0]}),
|
||||
json!({"nodes": [n], "by": [1e308, 1e308, 1e308]}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "to": [0, 0, 0]}),
|
||||
json!({"nodes": [n], "center": [0, 0, 0], "radius": 1, "by": [1, 0, 0]}),
|
||||
json!({"center": [0, 0, 0], "by": [1, 0, 0]}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "radius": -1}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "radius": "big"}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "strength": 0}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "strength": 2}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "falloff": "cubic"}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "propagate": "magic"}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "guard": "maybe"}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "on_geometry": "yes"}),
|
||||
json!({"nodes": [99_999_999], "by": [1, 0, 0]}),
|
||||
json!({"nodes": [-1], "by": [1, 0, 0]}),
|
||||
json!({"nodes": [], "by": [1, 0, 0]}),
|
||||
json!({"faces": [], "by": [1, 0, 0]}),
|
||||
json!({"faces": [{"body": "Nope", "face": "F1"}], "by": [1, 0, 0]}),
|
||||
json!({"center": [1e9, 0, 0], "radius": 1, "by": [1, 0, 0]}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300}),
|
||||
json!({"center": [-1e300, -1e300, 0], "radius": 1e-300, "by": [1, 0, 0]}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "radius": 1e-300, "propagate": "harmonic"}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": 1.5}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "min_quality": -0.1}),
|
||||
json!({"nodes": [n], "by": [1, 0, 0], "guard": "none", "min_quality": 0.2}),
|
||||
] {
|
||||
assert!(s.execute("mesh.node.move", &p).is_err(), "{p} should be refused");
|
||||
assert_eq!(s.model, start, "{p} changed the model");
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero move, or one the guard scales back to nothing, changes nothing and records no undo
|
||||
/// step; `min_quality` sets how far the guard lets a move go.
|
||||
#[test]
|
||||
fn a_move_of_nothing_records_no_undo_step_and_min_quality_is_adjustable() {
|
||||
let mut s = plate();
|
||||
let n = interior(&s.model);
|
||||
let (journal, start) = (s.journal.len(), s.model.clone());
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [0, 0, 0], "radius": 3.0})).unwrap();
|
||||
assert_eq!((r["moved"].as_u64(), r["nothing_moved"].as_bool()), (Some(0), Some(true)), "{r}");
|
||||
assert_eq!((s.journal.len(), &s.model), (journal, &start));
|
||||
let scale = |s: &mut Session, q: f64| {
|
||||
let r = s.execute("mesh.node.move", &json!({"nodes": [n], "by": [3.0, 0.0, 0.0], "min_quality": q})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
(r["scale"].as_f64().unwrap(), r["after"]["min_scaled_jacobian"].as_f64().unwrap())
|
||||
};
|
||||
let (loose, strict) = (scale(&mut s, 0.0), scale(&mut s, 0.5));
|
||||
assert!(strict.0 < loose.0 && strict.1 >= 0.5 - 1e-9, "{loose:?} {strict:?}");
|
||||
}
|
||||
|
||||
/// The live preview's cap: more followers than it allows are counted, not prepared; the live
|
||||
/// preview is the command's move.
|
||||
#[test]
|
||||
fn a_capped_prepare_counts_a_large_region_and_the_live_preview_matches() {
|
||||
let s = plate();
|
||||
let n = interior(&s.model);
|
||||
let p = json!({"nodes": [n], "by": [0.2, 0.1, 0.0], "radius": 4.0, "propagate": "harmonic"});
|
||||
assert!(matches!(prepare_capped(&s, &p, Some(3)).unwrap(), Capped::TooLarge(k) if k > 3));
|
||||
let Capped::Ready(prep) = prepare_capped(&s, &p, None).unwrap() else { panic!("not prepared") };
|
||||
let (exact, first, again) = (
|
||||
prep.preview(&s, [0.2, 0.1, 0.0]).unwrap(),
|
||||
prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(),
|
||||
prep.preview_live(&s, [0.2, 0.1, 0.0]).unwrap(),
|
||||
);
|
||||
assert_eq!(exact.positions, first.positions);
|
||||
assert_eq!(first, again, "the reused baseline gives the same move");
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! 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};
|
||||
@@ -14,7 +15,7 @@ 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": 4 (the most refinement levels below the size, each a third, 0..=5; the levels the part's thinnest wall, narrowest face and smallest local size (mesh.size.set) need are found before meshing, and a part that needs more levels or more than max_elements cells fails at once, naming them), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid (root cells at `size`, refined where the size controls ask for smaller elements) 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 (root cell size, levels, the thinnest wall and narrowest face, cells per level after refinement and after closure)"#,
|
||||
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
|
||||
form: Some(super::forms::HEX_OCTREE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
@@ -45,14 +46,13 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
..defaults
|
||||
};
|
||||
let bodies = inputs(s, p)?;
|
||||
let sizing = super::sizes::sizing(s)?;
|
||||
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 = sizing.body_size(&b.name, size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal));
|
||||
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)));
|
||||
}
|
||||
@@ -60,8 +60,7 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
|
||||
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
|
||||
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
|
||||
let field = sizing.field(&b.name, h)?;
|
||||
let mesh = octree_hex_mesh(&surface, &field, &left).map_err(|e| EngineError::of_body(&b.name, e))?;
|
||||
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::of_body(&b.name, e))?;
|
||||
total += mesh.hexes.len();
|
||||
meshes.push((b, mesh));
|
||||
}
|
||||
@@ -91,7 +90,7 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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, "root_size": r.root_size, "levels": r.levels, "thinnest_wall": finite(r.thinnest_wall), "narrowest_patch": finite(r.narrowest_patch), "refined_per_level": r.refined_cells, "closed_per_level": r.closed_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
|
||||
"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,
|
||||
@@ -103,11 +102,6 @@ fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(cleared.report(s, json!({"bodies": report, "nodes": nodes, "elements": elements})))
|
||||
}
|
||||
|
||||
/// A measure that may be infinite (no wall, no bounded face) as JSON: `null` when it is.
|
||||
fn finite(x: f64) -> Value {
|
||||
if x.is_finite() { json!(x) } else { Value::Null }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -54,81 +54,6 @@ const MAX_ELEMENTS: usize = 5_000_000;
|
||||
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
|
||||
/// The finest tessellation a plan asks for, as a fraction of the diagonal (as the meshers take).
|
||||
const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
|
||||
/// The share of the body's volume the chords of its curved faces may cost at the plan's size: a
|
||||
/// linear element's face spans a chord of the surface, and the sag between them is volume lost
|
||||
/// (or gained, in a hole).
|
||||
const CHORD_VOLUME_BUDGET: f64 = 0.005;
|
||||
|
||||
/// The principal curvatures' magnitudes, larger first, of an analytic surface at `p` (none for a
|
||||
/// plane or a free-form surface).
|
||||
fn curvatures(s: &FaceSurface, p: V3) -> Option<(f64, f64)> {
|
||||
let off_axis = |origin: V3, axis: V3| {
|
||||
let v = std::array::from_fn(|k| p[k] - origin[k]);
|
||||
let a = unit(axis)?;
|
||||
let along = dot(v, a);
|
||||
let r: V3 = std::array::from_fn(|k| v[k] - along * a[k]);
|
||||
Some(dot(r, r).sqrt())
|
||||
};
|
||||
let k = match *s {
|
||||
FaceSurface::Plane { .. } => return None,
|
||||
FaceSurface::Cylinder { radius, .. } => (1.0 / radius, 0.0),
|
||||
FaceSurface::Sphere { radius, .. } => (1.0 / radius, 1.0 / radius),
|
||||
FaceSurface::Cone { apex, axis, half_angle } => (half_angle.cos() / off_axis(apex, axis)?, 0.0),
|
||||
// Round the tube 1/minor; round the axis cos φ / ρ, with ρ the distance from the axis.
|
||||
FaceSurface::Torus { center, axis, major, minor } => {
|
||||
let rho = off_axis(center, axis)?;
|
||||
(1.0 / minor, ((rho - major) / (minor * rho)).abs())
|
||||
}
|
||||
};
|
||||
let k = (k.0.abs(), k.1.abs());
|
||||
(k.0.is_finite() && k.1.is_finite()).then_some(if k.0 >= k.1 { k } else { (k.1, k.0) })
|
||||
}
|
||||
|
||||
/// The largest element size whose chords cost each curved face at most [`CHORD_VOLUME_BUDGET`]
|
||||
/// of the body's `volume`, with the curved face that needs the finest size and its radius.
|
||||
///
|
||||
/// A linear face of size h across a surface of principal curvatures κ1, κ2 sags h²(κ1 + κ2)/12
|
||||
/// on average below it, so a face of area A costs A h²(κ1 + κ2)/12 of volume. A face's share is
|
||||
/// bounded two ways, and the looser bound is enough: its cost of all the faces against the body's volume (a
|
||||
/// small blend costs little whatever its curvature), and per face its cost against the volume it
|
||||
/// bounds itself, the turning angle per element (a thin tube's wall, whose inner and outer chords nearly
|
||||
/// cancel, needs no finer size than a solid bar of its radius).
|
||||
fn curvature_size(surfaces: &[Option<FaceSurface>], mesh: &fieldcraft_geometry::SurfaceMesh, volume: f64) -> Option<(f64, usize, f64)> {
|
||||
let n = surfaces.len();
|
||||
// Per face: ∫(κ1 + κ2) dA, area, ∫ κ1 (κ1 + κ2) dA, ∫ κ1 dA.
|
||||
let mut sums = vec![[0.0f64; 4]; n];
|
||||
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let Some(Some(surface)) = surfaces.get(f as usize) else { continue };
|
||||
let [a, b, c] = t.map(|i| mesh.positions.get(i as usize).copied().unwrap_or_default());
|
||||
let centroid = std::array::from_fn(|k| (a[k] + b[k] + c[k]) / 3.0);
|
||||
let Some((k1, k2)) = curvatures(surface, centroid) else { continue };
|
||||
let area = 0.5 * {
|
||||
let n = cross(std::array::from_fn(|k| b[k] - a[k]), std::array::from_fn(|k| c[k] - a[k]));
|
||||
dot(n, n).sqrt()
|
||||
};
|
||||
if let Some(s) = sums.get_mut(f as usize) {
|
||||
s[0] += area * (k1 + k2);
|
||||
s[1] += area;
|
||||
s[2] += area * k1 * (k1 + k2);
|
||||
s[3] += area * k1;
|
||||
}
|
||||
}
|
||||
let e = CHORD_VOLUME_BUDGET;
|
||||
// The whole body's cost against its volume, then the face that needs the finest size by its
|
||||
// own turning angle, never finer than the body's bound.
|
||||
let total: f64 = sums.iter().map(|s| s[0]).sum();
|
||||
let global = (12.0 * e * volume.abs() / total).sqrt();
|
||||
let (local, face, radius) = sums
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s[1] > 0.0 && s[2] > 0.0 && s[3] > 0.0)
|
||||
// A circle split into arcs of angle θ loses θ²/6 of its area, a sphere's θ²/3: h² κ1 (κ1 + κ2)/6.
|
||||
.map(|(i, s)| ((6.0 * e * s[1] / s[2]).sqrt(), i, s[1] / s[3]))
|
||||
.filter(|(h, _, _)| h.is_finite() && *h > 0.0)
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
let h = global.max(local);
|
||||
h.is_finite().then_some((h, face, radius))
|
||||
}
|
||||
|
||||
pub const PLAN: CommandSpec = CommandSpec {
|
||||
id: "mesh.plan",
|
||||
@@ -924,18 +849,6 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
h = through;
|
||||
}
|
||||
}
|
||||
// The curved faces: fine enough that the chords keep the volume.
|
||||
let chords = shape.tessellate(1e-3 * diagonal)?;
|
||||
if let Some((c, face, r)) = curvature_size(&surfaces, &chords, m.volume)
|
||||
&& c < h
|
||||
{
|
||||
let name = shape.faces().get(face).map_or_else(|| format!("face {face}"), |f| f.name.clone());
|
||||
notes.push(format!(
|
||||
"size {c:.4} mm: the chords of {name} (radius ~{r:.4} mm) cost at most {:.1} % of the volume",
|
||||
100.0 * CHORD_VOLUME_BUDGET
|
||||
));
|
||||
h = c;
|
||||
}
|
||||
let floor = (m.volume.abs() / max_elements as f64).cbrt();
|
||||
if h < floor {
|
||||
notes.push(format!("size {floor:.4} mm instead of {h:.4}: at most {max_elements} elements (fewer layers through the thinnest wall)"));
|
||||
@@ -1133,8 +1046,6 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The copies: each meshed by a plan of its own at this plan's size, saved beside it.
|
||||
let mut copies = Vec::with_capacity(plan.copies.len());
|
||||
for copy in &plan.copies {
|
||||
fieldcraft_model::progress::stage(&format!("Plan: meshing the copy {copy}"), None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
if design.source_body(copy).is_none() {
|
||||
return Err(EngineError::Params(format!("mesh plan `{name}`: no copy `{copy}` in the design")));
|
||||
@@ -1252,13 +1163,7 @@ fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result<Value> {
|
||||
let body = cells.first().cloned().unwrap_or_else(|| plan.body.clone());
|
||||
(super::hex::HEX_GENERATE.run)(s, &json!({"body": body, "size": plan.size, "method": "swept", "tolerance": tolerance}))
|
||||
}
|
||||
.map_err(|e| {
|
||||
if e.is_cancelled() {
|
||||
e
|
||||
} else {
|
||||
EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}"))
|
||||
}
|
||||
})?;
|
||||
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")))?;
|
||||
let summary = summary(&mesh, &cells);
|
||||
// What today's meshers cannot express yet: the rings round circles are built by the
|
||||
// projection mesher (#399); until then the frame cuts box the circle and the sweep meshes the
|
||||
@@ -1454,8 +1359,6 @@ fn apply_symmetric(s: &mut Session, name: &str, plan: &MeshPlan, corners: bool)
|
||||
let cells: Vec<String> = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.clone()).collect();
|
||||
let meshed_first = meshed.clone();
|
||||
for (a, c) in &planes {
|
||||
fieldcraft_model::progress::stage("Plan: mirroring the cell", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
(super::mirror::MIRROR.run)(s, &json!({"plane": {"point": a.map(|x| x * c), "normal": a}, "bodies": meshed}))?;
|
||||
meshed = cells.iter().filter(|n| !s.model.body_elements(n).is_empty()).cloned().collect();
|
||||
}
|
||||
@@ -1642,7 +1545,6 @@ fn copy_corner(
|
||||
}
|
||||
Ok(Ok(copy))
|
||||
}
|
||||
Err(e) if e.is_cancelled() => Err(e),
|
||||
// A corner copy that does not mesh leaves the cell to be meshed whole.
|
||||
Err(e) => {
|
||||
s.restore(before);
|
||||
@@ -1665,8 +1567,7 @@ fn corner_meshes(
|
||||
c: f64,
|
||||
tolerance: f64,
|
||||
) -> Result<CornerCopy> {
|
||||
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
|
||||
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, field, ..Default::default() };
|
||||
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, ..Default::default() };
|
||||
let whole_body = super::project::open_body(s, cell, Some(tolerance), &json!({}))?;
|
||||
let merge = super::project::merge_distance(None, whole_body.diagonal())?;
|
||||
// What the cell meshed whole gets (no corner may be worse), and per candidate cut the whole
|
||||
@@ -1674,13 +1575,9 @@ fn corner_meshes(
|
||||
// planar, the corner taken from that mesh: independent meshes, made side by side. The best
|
||||
// corner wins.
|
||||
let whole = &whole_body;
|
||||
// The job's progress on the pool's threads too, so a cancel stops the candidates.
|
||||
let progress = fieldcraft_model::progress::current();
|
||||
let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| {
|
||||
fieldcraft_model::progress::scoped(progress.clone(), || match k.checked_sub(1).and_then(|i| candidates.get(i)) {
|
||||
None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)),
|
||||
Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))),
|
||||
})
|
||||
let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| match k.checked_sub(1).and_then(|i| candidates.get(i)) {
|
||||
None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)),
|
||||
Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))),
|
||||
});
|
||||
let mut reference: Option<f64> = None;
|
||||
let mut best: Option<(f64, [f64; 2], CornerOfWhole)> = None;
|
||||
@@ -1692,7 +1589,6 @@ fn corner_meshes(
|
||||
best = Some((sj, at, corner));
|
||||
}
|
||||
}
|
||||
Err(e) if e.is_cancelled() => return Err(e),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -1887,11 +1783,9 @@ fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64
|
||||
// Cut faces another cell's mesh already covers are taken as given, so the cells meet node
|
||||
// to node there.
|
||||
let (fixed, fixed_faces) = cut_face_patches(s, &body, &plan.body, plan.size)?;
|
||||
let field = super::sizes::sizing(s)?.local_field(plan.size)?;
|
||||
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, field, ..Default::default() };
|
||||
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge).map_err(|e| {
|
||||
if e.is_cancelled() { e } else { EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")) }
|
||||
})?;
|
||||
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, ..Default::default() };
|
||||
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge)
|
||||
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?;
|
||||
if let Some(m) = mesh.as_object_mut() {
|
||||
// The block listing is long; the plan reports the layout instead.
|
||||
m.remove("topology");
|
||||
@@ -1963,8 +1857,6 @@ fn cut(
|
||||
let cleared = super::datum::recut(s)?;
|
||||
let mut skipped: Vec<(String, String)> = Vec::new();
|
||||
for _ in 0..=names.len() {
|
||||
fieldcraft_model::progress::stage("Plan: checking the cells", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let live: Vec<&String> = names.iter().filter(|n| !skipped.iter().any(|(k, _)| k == *n)).collect();
|
||||
let mut failed: Vec<(String, String)> = live
|
||||
@@ -2513,60 +2405,6 @@ mod acceptance {
|
||||
accept(&mut s, &r, 0.5, 10.0);
|
||||
}
|
||||
|
||||
/// The enclosure base's bosses cut a 0.42 mm strip out of the middle of the inner fillet
|
||||
/// (#511, #513): geometry.check finds the slivers and geometry.face.absorb absorbs them.
|
||||
/// The absorbed faces stay absorbed in the cells the plan's symmetry cuts the body into, and
|
||||
/// the projection keeps to the exact surfaces of a merged face's pieces, so the mesh is no
|
||||
/// worse for them, whole or by symmetry.
|
||||
#[test]
|
||||
fn absorbed_slivers_stay_absorbed_in_the_plan_s_cells() {
|
||||
let mut commands = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 56, "width": 42, "height": 16, "body_name": "Base"}}),
|
||||
json!({"command": "solid.fillet", "params": {"edges": [[0, 0, 8], [56, 0, 8], [56, 42, 8], [0, 42, 8]], "radius": 5}}),
|
||||
json!({"command": "solid.shell", "params": {"faces": [[28, 21, 16]], "thickness": 3, "body": "Base"}}),
|
||||
];
|
||||
for (x, y) in [(-1.0, -1.0), (57.0, -1.0), (-1.0, 43.0), (57.0, 43.0)] {
|
||||
commands.push(json!({"command": "solid.cylinder", "params": {"diameter": 13, "height": 16, "base": [x, y, 0], "operation": "join", "targets": ["Base"]}}));
|
||||
}
|
||||
let (_d, mut s) = open(json!(commands));
|
||||
let check = s.execute("geometry.check", &json!({"body": "Base", "size": 1.5, "mark": true})).unwrap();
|
||||
assert_eq!(check["counts"]["sliver_face"], 4, "{check}");
|
||||
let absorbed = s.execute("geometry.face.absorb", &json!({"from_check": true})).unwrap();
|
||||
assert_eq!(absorbed["absorbed"].as_array().map(Vec::len), Some(4), "{absorbed}");
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Base", "size": 1.5, "layers": 2})).unwrap();
|
||||
assert_eq!(plan["mesher"], "project", "{plan}");
|
||||
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"], "symmetry": false})).unwrap();
|
||||
accept(&mut s, &r, 0.6, 10.0);
|
||||
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
|
||||
assert_eq!(r["symmetry"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let design = s.design.as_deref().unwrap();
|
||||
assert!(design.errors.is_empty(), "{:?}", design.errors);
|
||||
// Each quarter holds one corner, its sliver absorbed.
|
||||
let cells: Vec<_> = design.bodies.iter().filter(|b| b.name.starts_with("Base/")).collect();
|
||||
assert_eq!(cells.len(), 4);
|
||||
assert!(cells.iter().all(|c| c.merged_faces().len() == 1), "{:?}", cells.iter().map(|c| c.merged_faces()).collect::<Vec<_>>());
|
||||
assert_eq!(design.source_body("Base").map(|b| b.merged_faces().len()), Some(4));
|
||||
accept(&mut s, &r, 0.65, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_bodies_mesh_within_a_percent_of_their_volume_at_the_plan_s_size() {
|
||||
// The plan's size keeps the chords of a torus (swept) and a cylinder (butterfly) within
|
||||
// the volume budget; the butterfly's rim is divided by its length, not its class's mean.
|
||||
for cmds in [
|
||||
json!([{"command": "solid.torus", "params": {"major": 30.0, "minor": 8.0, "body_name": "B"}}]),
|
||||
json!([{"command": "solid.cylinder", "params": {"radius": 10.0, "height": 40.0, "body_name": "B"}}]),
|
||||
] {
|
||||
let (_d, mut s) = open(cmds.clone());
|
||||
let body = s.execute("geometry.bodies", &json!({})).unwrap()[0]["volume"].as_f64().unwrap();
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
|
||||
assert!(plan["notes"].to_string().contains("the chords of"), "{plan}");
|
||||
s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
|
||||
let mesh = s.execute("mesh.quality", &json!({})).unwrap()["volume"].as_f64().unwrap();
|
||||
assert!(((mesh - body) / body).abs() < 0.01, "{cmds}: mesh {mesh} against {body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The crank arm sample, whole (#455, #190): its sides are at an angle to the axes and its
|
||||
/// root arcs meet the bosses at a kink, so it takes the ring-and-pillow layout: each boss an
|
||||
/// annulus from its bore or pedal hole to its circle through every level, the arm a carved
|
||||
@@ -2586,9 +2424,7 @@ mod acceptance {
|
||||
let r = s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap();
|
||||
let layout = r["layout"].as_str().unwrap();
|
||||
assert!(layout.contains("2 circle(s) ringed") && layout.contains("pillow layer"), "{r}");
|
||||
// The rims are divided by their own length (no chord longer than the size), which
|
||||
// thins the bore's ring elements a little at 2.5 mm (edge ratio 7.1).
|
||||
accept(&mut s, &r, 0.5, 7.5);
|
||||
accept(&mut s, &r, 0.5, 7.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn open_body(s: &Session, name: &str, tolerance: Option<f64>, raw: &V
|
||||
let surface =
|
||||
BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &super::mesh::surface_options(raw, &input.mesh)?)
|
||||
.map_err(|e| EngineError::Params(format!("body `{name}`: {e}")))?;
|
||||
let target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape)).with_pieces(&surface, super::mesh::pieces(shape, &input.pieces));
|
||||
let target = Tessellation::new(&surface, super::mesh::analytic_surfaces(shape));
|
||||
let m = shape.measure()?;
|
||||
Ok(Body { input, surface, target, lo: m.bbox_min, hi: m.bbox_max })
|
||||
}
|
||||
@@ -485,9 +485,7 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
|
||||
bounded(&p.fixed, "fixed")?;
|
||||
let b = open_body(s, &p.body, p.tolerance, raw)?;
|
||||
let diag = b.input.diagonal;
|
||||
let sizing = super::sizes::sizing(s)?;
|
||||
let size = element_size(sizing.bodies.get(&p.body).copied().or(p.size), diag)?;
|
||||
let field = sizing.local_field(size)?;
|
||||
let size = element_size(p.size, diag)?;
|
||||
let mut t = topology(&p, b.lo, b.hi)?;
|
||||
operate(&mut t, &p.operations)?;
|
||||
if p.preview {
|
||||
@@ -547,7 +545,6 @@ fn hex_project(s: &mut Session, raw: &Value) -> Result<Value> {
|
||||
min_quality,
|
||||
fixed,
|
||||
planes: Vec::new(),
|
||||
field,
|
||||
};
|
||||
let merge = merge_distance(p.merge, diag)?;
|
||||
project_body(s, &p.body, b, &t, &opts, merge)
|
||||
|
||||
@@ -202,12 +202,8 @@ fn squeezed_junction_elements_are_reported_not_meshed() {
|
||||
p["min_quality"] = json!(0.3);
|
||||
let model = s.model.clone();
|
||||
let e = s.execute("mesh.hex.project", &p).unwrap_err().to_string();
|
||||
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36; with
|
||||
// the cup's rim divided by its own length, the junction's corner (block 13) folds instead,
|
||||
// and the validity gate refuses it.
|
||||
let squeezed = e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36"));
|
||||
let folded = e.contains("inverted, twisted or degenerate") && e.contains("block 13");
|
||||
assert!(squeezed || folded, "{e}");
|
||||
// Smoothing leaves the worst element in the squeezed ring block (34) or in block 36.
|
||||
assert!(e.contains("smallest scaled Jacobian") && (e.contains("worst in block 34") || e.contains("worst in block 36")), "{e}");
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const RELAX: CommandSpec = CommandSpec {
|
||||
id: "mesh.node.relax",
|
||||
label: "Relax Nodes",
|
||||
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default five times the bad elements' mean edge length), "guards"?: "valid" (default) | "strict" (strong: when a cluster's morph is kept)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A cluster's morph is kept whenever no element it touches inverts (`"guards": "valid"`); with `"guards": "strict"` it is kept (at full or a halved scale) only when besides no element gets longer than the longest was, and the worst element it touches improves. The result is the round with the highest smallest scaled Jacobian, never lower than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
|
||||
params: r#"{"center": [x, y, z], "radius": mm (the sphere of influence) | "around_element": label (a sphere round that element; radius default twice its longest edge) | "faces": [{"body": "Base", "face": "F3"}] (persistent B-rep face names, as model.set.create takes them: every node on those faces and the nodes of the elements touching them; pick faces in the view) | "nodes": [labels] | "elements": [labels] (their nodes), "bodies"?: ["Body1"] (only their nodes; a partitioned body's cells count as it), "iterations"?: 5 (1..=100), "strength"?: 1 (0..1], "falloff"?: "smooth" (default: weight 1 at the centre to 0 at the radius) | "linear" | "none", "field"?: "Name" (the relaxation field to keep; default Relax1, Relax2, …), "reapply"?: false (apply the field again after each remesh of these bodies), "mode"?: "guarded" (default) | "strong", "below"?: 0.7 (strong: the elements below this scaled Jacobian are the bad ones), "reach"?: mm (strong: nodes this close to a bad node follow it; default three times the bad elements' mean edge length)} — moves the nodes to improve the worst elements around them (smart Laplacian, then optimization of the worst element; a move is kept only when the worst element around the node does not get worse, and never inverts one). `"mode": "strong"` morphs the region instead (`bodies` alone is the whole body, nothing listed the whole mesh; `iterations` are rounds, default 20, 1..=200): each round moves the corner nodes of the elements below `below` to better places (their own optimisation, the rest held) and carries that displacement to the nodes within `reach` by harmonic (graph Laplacian) interpolation, zero beyond; nodes on B-rep edges and vertices are held, nodes on faces put back on them, nodes outside the reach unchanged. A round is kept (at full or a halved scale) only when nothing inverts, no element gets longer than the longest was, and the worst element it touches improves; the result never has a lower smallest scaled Jacobian than the start. Its reply adds the mean scaled Jacobian and, per round, the bad elements and nodes, their clusters (bad nodes within the reach of each other, each morphed and checked on its own) and how many were kept, the region's nodes, the reach and the min and mean (`rounds`). Nodes on a B-rep face slide in its UV parameters and stay on it, nodes on a B-rep edge slide along it, nodes on vertices stay; nodes on partition cut faces move freely (shared by both cells); mid-side nodes follow, on the geometry. Reports the nodes moved, the largest displacement, the region's smallest scaled Jacobian, largest edge ratio and worst elements before and after, the field kept, and the listed faces no mesh is tagged with (`faces_without_mesh`; an error when none is). One undo step"#,
|
||||
form: Some(super::forms::NODE_RELAX),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Mesh],
|
||||
@@ -61,7 +61,7 @@ const REMESH: &[&str] = &[
|
||||
const DEFAULT_ITERATIONS: usize = 5;
|
||||
|
||||
/// The region's quality as JSON.
|
||||
pub(crate) fn quality_json(q: &RegionQuality) -> Value {
|
||||
fn quality_json(q: &RegionQuality) -> Value {
|
||||
json!({
|
||||
"elements": q.elements,
|
||||
"min_scaled_jacobian": q.min_quality,
|
||||
@@ -155,7 +155,7 @@ fn same_surface(a: &AnalyticSurface, b: &AnalyticSurface, tol: f64) -> bool {
|
||||
}
|
||||
|
||||
/// The design's geometry as relaxation constraints.
|
||||
pub(crate) struct DesignGeometry {
|
||||
struct DesignGeometry {
|
||||
/// Per body: its tessellation with exact surfaces.
|
||||
bodies: Vec<Tessellation>,
|
||||
/// Face ids: (body, B-rep face).
|
||||
@@ -180,7 +180,7 @@ impl Constraints for DesignGeometry {
|
||||
}
|
||||
|
||||
/// The constraints of `nodes` from the mesh's tags and the open design.
|
||||
pub(crate) fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
|
||||
fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<DesignGeometry> {
|
||||
let tags = node_tags(&s.model, nodes);
|
||||
let mut g = DesignGeometry { bodies: Vec::new(), faces: Vec::new(), curves: Vec::new(), freedom: BTreeMap::new() };
|
||||
// Body name → (index into `g.bodies`, its exact surfaces), built when first needed.
|
||||
@@ -194,7 +194,7 @@ pub(crate) fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<Desi
|
||||
None => None,
|
||||
Some(shape) => {
|
||||
let tol = (shape.size()? * 1e-3).max(1e-6);
|
||||
let (mesh, pieces) = shape.tessellate_pieces(tol)?;
|
||||
let mesh = shape.tessellate(tol)?;
|
||||
let surface = BoundarySurface::new(
|
||||
&mesh.positions,
|
||||
&mesh.triangles,
|
||||
@@ -202,7 +202,7 @@ pub(crate) fn design_geometry(s: &Session, nodes: &BTreeSet<u32>) -> Result<Desi
|
||||
&SurfaceOptions { weld_distance: mesh.weld_distance, ..SurfaceOptions::default() },
|
||||
)?;
|
||||
let analytic = super::mesh::analytic_surfaces(shape);
|
||||
g.bodies.push(Tessellation::new(&surface, analytic.clone()).with_pieces(&surface, super::mesh::pieces(shape, &pieces)));
|
||||
g.bodies.push(Tessellation::new(&surface, analytic.clone()));
|
||||
Some((g.bodies.len() - 1, analytic))
|
||||
}
|
||||
};
|
||||
@@ -298,7 +298,7 @@ fn body_nodes(model: &Model, p: &Value) -> Result<Option<(Vec<String>, BTreeSet<
|
||||
Ok(Some((bodies, nodes)))
|
||||
}
|
||||
|
||||
pub(crate) fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
|
||||
fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
|
||||
match super::opt::<[f64; 3]>(p, key)? {
|
||||
Some(c) if c.iter().all(|x| x.is_finite()) => Ok(Some(c)),
|
||||
Some(_) => Err(EngineError::param(key, ProblemKind::NotANumber)),
|
||||
@@ -310,7 +310,7 @@ pub(crate) fn point(p: &Value, key: &str) -> Result<Option<[f64; 3]>> {
|
||||
const MAX_FACES: usize = 10_000;
|
||||
|
||||
/// The `faces` parameter: a list of `{body, face}` B-rep face references.
|
||||
pub(crate) fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
|
||||
fn face_refs(p: &Value) -> Result<Vec<GeometryRef>> {
|
||||
let refs: Vec<GeometryRef> = super::req(p, "faces")?;
|
||||
if refs.is_empty() || refs.len() > MAX_FACES {
|
||||
return Err(EngineError::invalid("faces", format!("1 to {MAX_FACES} faces")));
|
||||
@@ -337,7 +337,7 @@ fn face_tags<'a>(model: &Model, refs: &'a [GeometryRef]) -> (BTreeSet<u32>, Vec<
|
||||
}
|
||||
|
||||
/// The listed faces with no tagged mesh, as `{body, face}` (the reply names them).
|
||||
pub(crate) fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
|
||||
fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
|
||||
match p.get("faces").map(|_| face_refs(p)) {
|
||||
Some(Ok(refs)) => face_tags(model, &refs).1.into_iter().map(|r| json!(r)).collect(),
|
||||
_ => Vec::new(),
|
||||
@@ -345,9 +345,9 @@ pub(crate) fn unmeshed_faces(model: &Model, p: &Value) -> Vec<Value> {
|
||||
}
|
||||
|
||||
/// The nodes on the B-rep faces `refs` (from the mesh's geometry tags: element faces on them and
|
||||
/// shell elements meshing them). Faces with no tagged mesh are left out (the reply names them);
|
||||
/// none with one is an error.
|
||||
pub(crate) fn nodes_on_faces(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
|
||||
/// shell elements meshing them) and the nodes of every element touching those. Faces with no
|
||||
/// tagged mesh are left out (the reply names them); none with one is an error.
|
||||
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
|
||||
let c = model.classification();
|
||||
let (tags, missing) = face_tags(model, refs);
|
||||
if tags.is_empty() {
|
||||
@@ -369,13 +369,6 @@ pub(crate) fn nodes_on_faces(model: &Model, refs: &[GeometryRef]) -> Result<BTre
|
||||
on.extend(local.iter().filter_map(|&i| e.nodes.get(i).copied()));
|
||||
}
|
||||
}
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
/// The nodes on the B-rep faces `refs` ([`nodes_on_faces`]) and the nodes of every element
|
||||
/// touching those.
|
||||
fn face_nodes(model: &Model, refs: &[GeometryRef]) -> Result<BTreeSet<u32>> {
|
||||
let on = nodes_on_faces(model, refs)?;
|
||||
let mut out = on.clone();
|
||||
for e in model.elements().values() {
|
||||
if e.nodes.iter().any(|n| on.contains(n)) {
|
||||
@@ -529,11 +522,6 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| EngineError::param("reach", ProblemKind::NotPositive))
|
||||
.map(Some)?,
|
||||
};
|
||||
let guards = match super::opt::<String>(p, "guards")?.as_deref() {
|
||||
None | Some("valid") => relax::Guards::Valid,
|
||||
Some("strict") => relax::Guards::Strict,
|
||||
Some(_) => return Err(EngineError::param("guards", ProblemKind::NotAChoice)),
|
||||
};
|
||||
let rounds = match p.get("iterations") {
|
||||
None => STRONG_ROUNDS,
|
||||
Some(v) => v
|
||||
@@ -555,7 +543,7 @@ fn relax_strong(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Params("no mesh nodes in the region to relax".into()));
|
||||
}
|
||||
let g = design_geometry(s, &touched(&s.model, &nodes.iter().map(|&n| (n, 1.0)).collect()))?;
|
||||
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds, guards }, &g)?;
|
||||
let r = relax::morph(&s.model, &nodes, relax::MorphOptions { below, reach, rounds }, &g)?;
|
||||
let field_name = match super::opt::<String>(p, "field")? {
|
||||
Some(n) => n,
|
||||
None => (1..).map(|i| format!("Relax{i}")).find(|n| s.model.relaxation(n).is_none()).unwrap_or_else(|| "Relax".into()),
|
||||
@@ -621,8 +609,8 @@ fn relax_nodes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("strong") => return relax_strong(s, p),
|
||||
Some(_) => return Err(EngineError::param("mode", ProblemKind::NotAChoice)),
|
||||
}
|
||||
if ["below", "reach", "guards"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(EngineError::Params("`below`, `reach` and `guards` go with \"mode\": \"strong\"".into()));
|
||||
if p.get("below").is_some() || p.get("reach").is_some() {
|
||||
return Err(EngineError::Params("`below` and `reach` go with \"mode\": \"strong\"".into()));
|
||||
}
|
||||
let (w, sphere) = weights(&s.model, p)?;
|
||||
if w.is_empty() {
|
||||
|
||||
@@ -275,8 +275,7 @@ fn on_box_edge(p: [f64; 3]) -> bool {
|
||||
fn strong_relaxation_of_a_filleted_box_keeps_faces_and_edges() {
|
||||
let (_d, mut s, _) = filleted_box();
|
||||
let before = s.model.clone();
|
||||
// The tet mesher leaves no element below the skill's 0.3, so relax those below 0.45.
|
||||
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.45})).unwrap();
|
||||
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "bodies": ["B"], "below": 0.3})).unwrap();
|
||||
let (b, a) = (&r["before"], &r["after"]);
|
||||
// The worst tets may not improve: their corners can all be held on the box's edges.
|
||||
assert!(a["min_scaled_jacobian"].as_f64().unwrap() >= b["min_scaled_jacobian"].as_f64().unwrap(), "{r}");
|
||||
@@ -350,8 +349,6 @@ fn strong_mode_parameters_are_checked() {
|
||||
json!({"mode": "strong", "nodes": [1], "iterations": 201}),
|
||||
json!({"below": 0.5}),
|
||||
json!({"nodes": [1], "reach": 1}),
|
||||
json!({"nodes": [1], "guards": "strict"}),
|
||||
json!({"mode": "strong", "nodes": [1], "guards": "loose"}),
|
||||
json!({"nodes": [1], "mode": "strong", "layers": 1}),
|
||||
] {
|
||||
assert!(s.execute("mesh.node.relax", &p).is_err(), "{p}");
|
||||
@@ -360,37 +357,3 @@ fn strong_mode_parameters_are_checked() {
|
||||
let r = s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.01})).unwrap();
|
||||
assert_eq!(r["moved"], 0, "{r}");
|
||||
}
|
||||
|
||||
/// The mesh and the strong relaxation are the same bits on every platform and thread count:
|
||||
/// the plate's hex mesh differed between macOS and Linux (the system maths library's `atan2`,
|
||||
/// `sin` … are not correctly rounded), so the distorted-plate test passed on one and failed on
|
||||
/// the other (#438). The fingerprint is of the nodes after mesh.hex.generate, after the
|
||||
/// perturbation and after the strong relaxation; CI checks it on Linux, it was taken on macOS.
|
||||
#[test]
|
||||
fn mesh_and_strong_relaxation_are_the_same_bits_everywhere() {
|
||||
let fingerprint = |s: &Session, h: &mut u64| {
|
||||
// FNV-1a over every node's label and coordinate bits.
|
||||
for (n, p) in s.model.nodes() {
|
||||
for word in [u64::from(*n), p.xyz[0].to_bits(), p.xyz[1].to_bits(), p.xyz[2].to_bits()] {
|
||||
for b in word.to_le_bytes() {
|
||||
*h = (*h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let run = || {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||||
let mut s = plate();
|
||||
s.execute("mesh.hex.generate", &json!({"size": 2.0})).unwrap();
|
||||
fingerprint(&s, &mut h);
|
||||
perturb(&mut s, ON_HOLE, 6.0, 0.35);
|
||||
fingerprint(&s, &mut h);
|
||||
s.execute("mesh.node.relax", &json!({"mode": "strong", "below": 0.5, "reach": 4.0})).unwrap();
|
||||
fingerprint(&s, &mut h);
|
||||
h
|
||||
};
|
||||
let one = fieldcraft_parallel::with_threads(1, run);
|
||||
let many = fieldcraft_parallel::with_threads(4, run);
|
||||
assert_eq!(one, many, "1 and 4 threads differ");
|
||||
assert_eq!(one, 0x7445_e04c_0d65_c1d0, "fingerprint {one:#018x}");
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
//! `results.*`: what the results display shows (deformed shape, contours, mode shapes,
|
||||
//! animation; a fluid step's slice, coloured obstacles and streamlines) and reaction sums. The display is session state ([`ResultsView`]): these
|
||||
//! animation) and reaction sums. The display is session state ([`ResultsView`]): these
|
||||
//! commands are not edits, so they are not journalled or undone.
|
||||
|
||||
use crate::schema::ProblemKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{opt, xyz};
|
||||
use crate::flow::{Axis, FlowView, MAX_STREAMLINE_STEPS, MAX_STREAMLINES, SliceView};
|
||||
use crate::requirement::Requirement;
|
||||
use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView};
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
@@ -24,7 +23,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
|
||||
pub const CONTOUR: CommandSpec = CommandSpec {
|
||||
id: "results.contour",
|
||||
label: "Contours",
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), temperature (thermal frames), wall_heat_flux (signed face average on thermal fluid obstacles), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
|
||||
form: Some(super::forms::CONTOUR),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -44,7 +43,7 @@ pub const MODES: CommandSpec = CommandSpec {
|
||||
pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
id: "results.animate",
|
||||
label: "Animate",
|
||||
params: r#"{"on": true} — animates the result over time: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed, an explicit or fluid step plays its frames in order"#,
|
||||
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
|
||||
form: Some(super::forms::ANIMATE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -54,7 +53,7 @@ pub const ANIMATE: CommandSpec = CommandSpec {
|
||||
pub const FRAME: CommandSpec = CommandSpec {
|
||||
id: "results.frame",
|
||||
label: "Field Frame",
|
||||
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit or fluid step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
|
||||
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last), or of a nonlinear static step (one per converged increment, `time` its load factor; the deformed shape follows it, its contours show the final state); the deformed shape and contours follow it"#,
|
||||
form: Some(super::forms::FRAME),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -91,36 +90,6 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
||||
run: export_csv,
|
||||
};
|
||||
|
||||
pub const SLICE: CommandSpec = CommandSpec {
|
||||
id: "results.slice",
|
||||
label: "Flow Slice",
|
||||
params: r#"{"step": "Flow", "field": "velocity" | "pressure" | "temperature", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude, gauge pressure or temperature when its frames carry heat, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#,
|
||||
form: Some(super::forms::SLICE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: slice,
|
||||
};
|
||||
|
||||
pub const SURFACE: CommandSpec = CommandSpec {
|
||||
id: "results.surface",
|
||||
label: "Flow on Surfaces",
|
||||
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature" | "wall_heat_flux", "frame"?: 10} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `wall_heat_flux` is signed power received by each obstacle face divided by its complete tessellated area, in model power/area units (positive into the wall); histories interpolate at the shown frame without extrapolation; recorded adiabatic faces are zero, missing histories remain uncoloured; no slice is drawn for this surface-only field. `show: false` draws the part in its own colours"#,
|
||||
form: Some(super::forms::FLOW_SURFACE),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: surface,
|
||||
};
|
||||
|
||||
pub const STREAMLINES: CommandSpec = CommandSpec {
|
||||
id: "results.streamlines",
|
||||
label: "Streamlines",
|
||||
params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature", "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed or temperature when that field is selected (plain for pressure): from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#,
|
||||
form: Some(super::forms::STREAMLINES),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Result],
|
||||
run: streamlines,
|
||||
};
|
||||
|
||||
pub const CLEAR: CommandSpec = CommandSpec {
|
||||
id: "results.clear",
|
||||
label: "Hide Results",
|
||||
@@ -146,27 +115,23 @@ fn solved_list(s: &Session) -> String {
|
||||
pub(super) enum Want {
|
||||
Any,
|
||||
Frequency,
|
||||
/// Steps with frames: explicit dynamics, fluid and nonlinear static (one per converged
|
||||
/// increment).
|
||||
/// Steps with frames: explicit dynamics and nonlinear static (one per converged increment).
|
||||
Frames,
|
||||
Fluid,
|
||||
}
|
||||
|
||||
impl Want {
|
||||
fn fits(self, s: &Session, step: &str) -> bool {
|
||||
s.latest_result(step).is_some_and(|(r, _)| match (self, r.kind()) {
|
||||
(Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames | Want::Fluid, Kind::Fluid(_)) => true,
|
||||
(Want::Frames, Kind::Explicit(_)) => true,
|
||||
(Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Frames, Kind::Explicit(_)) => true,
|
||||
(Want::Frames, Kind::Static(r)) => r.nonlinear.is_some(),
|
||||
(Want::Frequency | Want::Frames | Want::Fluid, _) => false,
|
||||
(Want::Frequency | Want::Frames, _) => false,
|
||||
})
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Want::Any => "solved",
|
||||
Want::Frequency => "frequency",
|
||||
Want::Frames => "explicit, fluid or nonlinear static",
|
||||
Want::Fluid => "fluid",
|
||||
Want::Frames => "explicit or nonlinear static",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,27 +214,12 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(f), Some((r, _))) = (field, s.latest_result(&step))
|
||||
&& !Field::available(r).any(|a| a == f)
|
||||
{
|
||||
if f == Field::Temperature && matches!(r.kind(), Kind::Fluid(_)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"temperature is not available for fluid step `{step}`: its frames have no complete temperature field (isothermal flow)"
|
||||
)));
|
||||
}
|
||||
if f == Field::WallHeatFlux {
|
||||
return Err(EngineError::Params(format!("wall heat flux is not available for step `{step}`: it requires thermal obstacle histories")));
|
||||
}
|
||||
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
|
||||
}
|
||||
let range = match p.get("range") {
|
||||
None => None,
|
||||
Some(r) => Some(legend_range(r)?),
|
||||
};
|
||||
if field == Some(Field::WallHeatFlux)
|
||||
&& let Some((r, _)) = s.latest_result(&step)
|
||||
&& let Kind::Fluid(f) = r.kind()
|
||||
{
|
||||
let pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
|
||||
crate::flow::wall::faces(s, f, pick)?;
|
||||
}
|
||||
let v = view_of(s, &step)?;
|
||||
if v.contour != field {
|
||||
(v.range_min, v.range_max) = (None, None);
|
||||
@@ -341,12 +291,9 @@ fn animate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(s.results_report());
|
||||
}
|
||||
let step = step_for(s, &json!({}), Want::Any)?;
|
||||
let fluid = s.latest_result(&step).is_some_and(|(r, _)| matches!(r.kind(), Kind::Fluid(_)));
|
||||
let v = view_of(s, &step)?;
|
||||
v.animate = true;
|
||||
// A fluid step has nothing to deform; animating plays its frames (as results.frame does) in
|
||||
// order. A structural step animates its deformation (or an explicit step its frames).
|
||||
v.deformed = !fluid;
|
||||
v.deformed = true;
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
@@ -355,7 +302,6 @@ fn frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let times: Vec<f64> = match s.latest_result(&step).map(|(r, _)| r.kind()) {
|
||||
Some(Kind::Explicit(e)) => e.frames.iter().map(|f| f.time).collect(),
|
||||
Some(Kind::Static(r)) => r.nonlinear.as_ref().map(|h| h.load_factors()).unwrap_or_default(),
|
||||
Some(Kind::Fluid(f)) => f.frames.iter().map(|f| f.time).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let last = times.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no field frames")))?;
|
||||
@@ -376,225 +322,6 @@ fn frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
/// The fluid step a fluid display command works on (`step`, or the one shown) and its result.
|
||||
fn fluid_step(s: &Session, p: &Value) -> Result<(String, std::sync::Arc<crate::StepResult>)> {
|
||||
let step = step_for(s, p, Want::Fluid)?;
|
||||
let r = s.latest_result(&step).map(|(r, _)| r.clone()).ok_or_else(|| EngineError::Params(solved_list(s)))?;
|
||||
Ok((step, r))
|
||||
}
|
||||
|
||||
/// A whole number in `range`, when given.
|
||||
fn whole(p: &Value, key: &str, range: std::ops::RangeInclusive<u64>) -> Result<Option<u64>> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().filter(|n| range.contains(n)).map(Some).ok_or_else(|| EngineError::param(key, super::mesh::whole_in(&range))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A fluid command's `frame`: from 0 to the last; `None` when left out.
|
||||
fn fluid_frame(f: &fieldcraft_lbm::FluidResult, p: &Value) -> Result<Option<usize>> {
|
||||
let last = f.frames.len().saturating_sub(1) as u64;
|
||||
Ok(whole(p, "frame", 0..=last)?.and_then(|k| usize::try_from(k).ok()))
|
||||
}
|
||||
|
||||
/// A whole number from 1 to `max`, when given.
|
||||
fn count_param(p: &Value, key: &str, max: u32) -> Result<Option<u32>> {
|
||||
Ok(whole(p, key, 1..=u64::from(max))?.and_then(|n| u32::try_from(n).ok()))
|
||||
}
|
||||
|
||||
/// Largest coordinate a seed may have.
|
||||
const MAX_COORDINATE: f64 = 1e12;
|
||||
|
||||
/// A point `[x, y, z]` of finite coordinates no larger than [`MAX_COORDINATE`].
|
||||
fn seed_point(v: &Value) -> Option<[f64; 3]> {
|
||||
let a = v.as_array().filter(|a| a.len() == 3)?;
|
||||
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= MAX_COORDINATE).collect();
|
||||
match c[..] {
|
||||
[x, y, z] => Some([x, y, z]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn slice(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let field = fluid_field(p, &r)?;
|
||||
// Some(None): hide the slice.
|
||||
let axis = match opt::<String>(p, "axis")?.as_deref() {
|
||||
None => None,
|
||||
Some("none") => Some(None),
|
||||
Some(a) => Some(Some(Axis::parse(a).ok_or_else(|| EngineError::invalid("axis", "must be x, y, z or none"))?)),
|
||||
};
|
||||
// Some(None): the domain's middle.
|
||||
let position = match p.get("position").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(Value::String(a)) if a == "auto" => Some(None),
|
||||
Some(v) => Some(Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| EngineError::param("position", ProblemKind::NotANumber))?)),
|
||||
};
|
||||
let frame = fluid_frame(f, p)?;
|
||||
let first = FlowView::for_result(f);
|
||||
let shown = s.results_view.as_ref().filter(|v| v.step == step).and_then(|v| v.flow.clone()).unwrap_or_else(|| first.clone());
|
||||
let slice = match axis {
|
||||
Some(None) => None,
|
||||
Some(Some(a)) => {
|
||||
// A new axis starts at the middle; the same one keeps its position.
|
||||
let kept = shown.slice.as_ref().filter(|s| s.axis == a).and_then(|s| s.position);
|
||||
Some(SliceView { axis: a, position: position.unwrap_or(kept) })
|
||||
}
|
||||
None => {
|
||||
let current = shown.slice.clone().or(first.slice);
|
||||
current.map(|s| SliceView { position: position.unwrap_or(s.position), ..s })
|
||||
}
|
||||
};
|
||||
if let Some(sv) = &slice
|
||||
&& let Some(at) = sv.position
|
||||
{
|
||||
let g = crate::flow::grid(f);
|
||||
let (lo, hi) = (g.start(sv.axis.index()), g.end(sv.axis.index()));
|
||||
if !(at >= lo && at <= hi) {
|
||||
return Err(EngineError::invalid("position", format!("must be inside the domain: {lo} to {hi} along {}", sv.axis.id())));
|
||||
}
|
||||
}
|
||||
let v = view_of(s, &step)?;
|
||||
if let Some(field) = field {
|
||||
if v.contour != Some(field) {
|
||||
(v.range_min, v.range_max) = (None, None);
|
||||
}
|
||||
v.contour = Some(field);
|
||||
}
|
||||
if frame.is_some() {
|
||||
v.frame = frame;
|
||||
}
|
||||
v.flow = Some(FlowView { slice, ..shown });
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
fn surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let show = opt::<bool>(p, "show")?.unwrap_or(true);
|
||||
let field = if opt::<String>(p, "field")?.as_deref() == Some("wall_heat_flux") {
|
||||
if !Field::available(&r).any(|f| f == Field::WallHeatFlux) {
|
||||
return Err(EngineError::invalid("field", "wall heat flux is unavailable: this step has no thermal obstacle histories"));
|
||||
}
|
||||
Some(Field::WallHeatFlux)
|
||||
} else {
|
||||
fluid_field(p, &r)?
|
||||
};
|
||||
let frame = fluid_frame(f, p)?;
|
||||
if field == Some(Field::WallHeatFlux) {
|
||||
let mut pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default();
|
||||
if frame.is_some() {
|
||||
pick.frame = frame;
|
||||
}
|
||||
crate::flow::wall::faces(s, f, pick)?;
|
||||
}
|
||||
let v = view_of(s, &step)?;
|
||||
set_fluid_field(v, field);
|
||||
if frame.is_some() {
|
||||
v.frame = frame;
|
||||
}
|
||||
let mut flow = FlowView::of(v, f).into_owned();
|
||||
flow.surface = show;
|
||||
v.flow = Some(flow);
|
||||
Ok(s.results_report())
|
||||
}
|
||||
|
||||
fn streamlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (step, r) = fluid_step(s, p)?;
|
||||
let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) };
|
||||
let show = opt::<bool>(p, "show")?.unwrap_or(true);
|
||||
let field = fluid_field(p, &r)?;
|
||||
let count = count_param(p, "count", MAX_STREAMLINES)?;
|
||||
let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?;
|
||||
let max = MAX_STREAMLINES as usize;
|
||||
let seeds = match p.get("seeds").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(_) => {
|
||||
let list = super::points(p, "seeds", max)?;
|
||||
if list.is_empty() || list.iter().flatten().any(|c| !(c.is_finite() && c.abs() <= MAX_COORDINATE)) {
|
||||
return Err(EngineError::invalid("seeds", format!("must be 1 to {max} points [x, y, z] of finite coordinates")));
|
||||
}
|
||||
Some(list)
|
||||
}
|
||||
};
|
||||
let line = match p.get("line").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let end = |k: &str| v.get(k).and_then(seed_point);
|
||||
match (end("from"), end("to")) {
|
||||
(Some(a), Some(b)) => Some((a, b)),
|
||||
_ => return Err(EngineError::invalid("line", "must be {\"from\": [x, y, z], \"to\": [x, y, z]} of finite coordinates")),
|
||||
}
|
||||
}
|
||||
};
|
||||
if seeds.is_some() && line.is_some() {
|
||||
return Err(EngineError::invalid("line", "give `seeds` or `line`, not both"));
|
||||
}
|
||||
let frame = fluid_frame(f, p)?;
|
||||
let v = view_of(s, &step)?;
|
||||
set_fluid_field(v, field);
|
||||
let mut flow = FlowView::of(v, f).into_owned();
|
||||
flow.streamlines = if show {
|
||||
let mut sl = flow.streamlines.take().unwrap_or_default();
|
||||
sl.count = count.unwrap_or(sl.count);
|
||||
sl.max_steps = max_steps.unwrap_or(sl.max_steps);
|
||||
sl.seeds = match (seeds, line) {
|
||||
(Some(points), _) => Some(points),
|
||||
(None, Some((a, b))) => {
|
||||
let n = sl.count.max(1);
|
||||
let at = |i: u32| if n == 1 { 0.5 } else { f64::from(i) / f64::from(n - 1) };
|
||||
Some((0..n).map(|i| std::array::from_fn(|k| a[k] + (b[k] - a[k]) * at(i))).collect())
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
Some(sl)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if frame.is_some() {
|
||||
v.frame = frame;
|
||||
}
|
||||
let pick = v.pick();
|
||||
let traced = flow.streamlines.clone();
|
||||
v.flow = Some(flow);
|
||||
let mut out = s.results_report();
|
||||
if let Some(sl) = traced {
|
||||
// Traced once: the plot drawn after this reply takes the same lines from the session.
|
||||
let k = crate::flow::frame_index(f, pick)?;
|
||||
let (seeds, lines) = s.traced.trace(&r, k, &sl)?;
|
||||
let points: usize = lines.iter().map(|l| l.points.len()).sum();
|
||||
out["traced"] = json!({"streamlines": lines.len(), "points": points, "seeds": seeds, "no_start": seeds.saturating_sub(lines.len())});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn fluid_field(p: &Value, r: &crate::StepResult) -> Result<Option<Field>> {
|
||||
let field = match opt::<String>(p, "field")?.as_deref() {
|
||||
None => return Ok(None),
|
||||
Some("velocity") => Field::Velocity,
|
||||
Some("pressure") => Field::Pressure,
|
||||
Some("temperature") => Field::Temperature,
|
||||
Some(_) => return Err(EngineError::invalid("field", "must be velocity, pressure or temperature")),
|
||||
};
|
||||
if !Field::available(r).any(|f| f == field) {
|
||||
return Err(EngineError::invalid(
|
||||
"field",
|
||||
"temperature is unavailable: this isothermal step has no complete temperature field in its frames",
|
||||
));
|
||||
}
|
||||
Ok(Some(field))
|
||||
}
|
||||
|
||||
fn set_fluid_field(view: &mut ResultsView, field: Option<Field>) {
|
||||
if let Some(field) = field {
|
||||
if view.contour != Some(field) {
|
||||
(view.range_min, view.range_max) = (None, None);
|
||||
}
|
||||
view.contour = Some(field);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.results_view = None;
|
||||
Ok(json!({"view": null}))
|
||||
@@ -1434,164 +1161,6 @@ mod tests {
|
||||
assert!(s.execute("results.reactions", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A session showing a synthetic fluid result ([`crate::flow::tests::channel`]).
|
||||
fn fluid() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.store_result(crate::StepResult::Fluid(crate::flow::tests::channel()));
|
||||
s
|
||||
}
|
||||
|
||||
/// A fluid step is shown with velocity on a slice across the flow, its obstacles coloured
|
||||
/// and streamlines from the inlet (#193); the commands change what is shown, not the model.
|
||||
#[test]
|
||||
fn fluid_steps_show_a_slice_the_obstacles_and_streamlines() {
|
||||
let mut s = fluid();
|
||||
let (journal, revision) = (s.journal.len(), s.revision());
|
||||
let r = s.results_report();
|
||||
assert_eq!(r["view"]["contour"], "velocity", "{r}");
|
||||
assert_eq!(r["contour"]["range"]["max"]["value"], 10.0, "{r}");
|
||||
assert_eq!(r["contour"]["range"]["max"]["at"], json!([9.5, 0.5, 0.5]), "{r}");
|
||||
assert_eq!(r["slice"], json!({"axis": "y", "position": 2.0}));
|
||||
assert_eq!((r["surface"].as_bool(), r["streamlines"]["inlet"].as_str()), (Some(true), Some("x_min")), "{r}");
|
||||
// A slice normal to z at 1.5 coloured by pressure; "auto" goes back to the middle, a new
|
||||
// axis starts there; positions outside the domain are refused; "none" hides it.
|
||||
let r = s.execute("results.slice", &json!({"axis": "z", "position": 1.5, "field": "pressure"})).unwrap();
|
||||
assert_eq!((r["slice"].clone(), r["contour"]["field"].as_str()), (json!({"axis": "z", "position": 1.5}), Some("pressure")), "{r}");
|
||||
assert_eq!(s.execute("results.slice", &json!({"position": "auto"})).unwrap()["slice"]["position"], 2.0);
|
||||
assert_eq!(s.execute("results.slice", &json!({"axis": "x"})).unwrap()["slice"], json!({"axis": "x", "position": 5.0}));
|
||||
let e = s.execute("results.slice", &json!({"position": 11})).unwrap_err().to_string();
|
||||
assert!(e.contains("0 to 10"), "{e}");
|
||||
assert!(s.execute("results.slice", &json!({"axis": "none"})).unwrap()["slice"].is_null());
|
||||
// results.contour reaches the same fields; nodal ones are refused.
|
||||
s.execute("results.contour", &json!({"field": "velocity", "bands": 6})).unwrap();
|
||||
assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err());
|
||||
// Frames, by results.frame or with the slice.
|
||||
assert_eq!(s.execute("results.frame", &json!({"frame": 0})).unwrap()["time"], 0.0);
|
||||
assert_eq!(s.execute("results.slice", &json!({"frame": 1, "axis": "y"})).unwrap()["time"], 1.0);
|
||||
// Streamlines from the inlet, from seeds, along a line; hidden.
|
||||
let r = s.execute("results.streamlines", &json!({"count": 4})).unwrap();
|
||||
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["streamlines"].as_u64()), (Some(4), Some(4)), "{r}");
|
||||
// The reply's trace is the one the plot draws.
|
||||
let (view, result) = (s.results_view.as_ref().unwrap(), s.latest_result("Flow").unwrap().0);
|
||||
let p = crate::flow::plot(view, result, &s.traced).unwrap();
|
||||
let sl = view.flow.as_ref().and_then(|f| f.streamlines.as_ref()).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&p.streamlines, &s.traced.trace(result, 1, sl).unwrap().1));
|
||||
let r = s.execute("results.streamlines", &json!({"seeds": [[0.5, 0.5, 0.5], [5.5, 1.5, 1.5], [50, 0, 0]]})).unwrap();
|
||||
assert_eq!((r["traced"]["streamlines"].as_u64(), r["traced"]["no_start"].as_u64()), (Some(1), Some(2)), "{r}");
|
||||
assert_eq!(r["streamlines"]["seeds"], 3);
|
||||
let r =
|
||||
s.execute("results.streamlines", &json!({"line": {"from": [0.5, 0.5, 0.5], "to": [0.5, 3.5, 3.5]}, "count": 5, "max_steps": 3})).unwrap();
|
||||
assert_eq!((r["traced"]["seeds"].as_u64(), r["traced"]["points"].as_u64()), (Some(5), Some(20)), "{r}");
|
||||
assert!(s.execute("results.streamlines", &json!({"show": false})).unwrap()["streamlines"].is_null());
|
||||
assert_eq!(s.execute("results.surface", &json!({"show": false})).unwrap()["surface"], false);
|
||||
// A fluid step animates its frames (the start-up transient), not a deformation.
|
||||
let r = s.execute("results.animate", &json!({})).unwrap();
|
||||
assert_eq!((r["view"]["animate"].as_bool(), r["view"]["deformed"].as_bool()), (Some(true), Some(false)), "{r}");
|
||||
assert!(s.execute("results.animate", &json!({"on": false})).unwrap()["view"]["animate"] == json!(false));
|
||||
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
|
||||
// Structural steps have none of it.
|
||||
let mut t = solved();
|
||||
for id in ["results.slice", "results.surface", "results.streamlines"] {
|
||||
assert!(t.execute(id, &json!({})).is_err(), "{id}");
|
||||
assert!(t.execute(id, &json!({"step": "Bend"})).is_err(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_views_require_thermal_frames_and_follow_the_selected_frame() {
|
||||
let mut r = crate::flow::tests::channel();
|
||||
r.frames[0].temperature = vec![20.0; r.grid.len()];
|
||||
r.frames[1].temperature = vec![70.0; r.grid.len()];
|
||||
let mut s = Session::default();
|
||||
s.store_result(crate::StepResult::Fluid(r));
|
||||
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
|
||||
let report = s.execute(command, &json!({"field": "temperature"})).unwrap();
|
||||
assert_eq!(report["contour"]["field"], "temperature", "{command}: {report}");
|
||||
assert_eq!(report["contour"]["range"]["max"]["value"], 70.0);
|
||||
}
|
||||
let report = s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
assert_eq!(report["contour"]["range"]["max"]["value"], 20.0);
|
||||
assert!(Field::available(s.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
|
||||
let before = s.results_view.clone();
|
||||
for command in ["results.slice", "results.surface", "results.streamlines"] {
|
||||
for params in [json!({"field": "temperature", "frame": u64::MAX}), json!({"field": ["temperature"]})] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert_eq!(s.results_view, before);
|
||||
}
|
||||
}
|
||||
let mut incomplete = crate::flow::tests::channel();
|
||||
incomplete.frames[0].temperature = vec![20.0; incomplete.grid.len()];
|
||||
incomplete.frames[1].temperature = vec![70.0; incomplete.grid.len().saturating_sub(1)];
|
||||
let incomplete = crate::StepResult::Fluid(incomplete);
|
||||
assert!(!Field::available(&incomplete).any(|f| f == Field::Temperature));
|
||||
let mut cold = fluid();
|
||||
let before = cold.results_view.clone();
|
||||
for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] {
|
||||
let error = cold.execute(command, &json!({"field": "temperature"})).unwrap_err().to_string();
|
||||
assert!(error.contains("isothermal"), "{command}: {error}");
|
||||
assert_eq!(cold.results_view, before);
|
||||
}
|
||||
assert!(!Field::available(cold.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluid_display_hostile_params_never_panic() {
|
||||
let s = fluid();
|
||||
let point = json!([0.5, 0.5, 0.5]);
|
||||
let hostile = [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!({"step": 7}),
|
||||
json!({"step": "Nope"}),
|
||||
json!({"field": "nope"}),
|
||||
json!({"field": 3}),
|
||||
json!({"axis": "w"}),
|
||||
json!({"axis": 1}),
|
||||
json!({"axis": ""}),
|
||||
json!({"position": 1e308}),
|
||||
json!({"position": -1e308}),
|
||||
json!({"position": "middle"}),
|
||||
json!({"position": [1]}),
|
||||
json!({"axis": "z", "position": -0.001}),
|
||||
json!({"frame": -1}),
|
||||
json!({"frame": 2}),
|
||||
json!({"frame": 1e300}),
|
||||
json!({"frame": "last"}),
|
||||
json!({"show": "yes"}),
|
||||
json!({"count": 0}),
|
||||
json!({"count": 2001}),
|
||||
json!({"count": -5}),
|
||||
json!({"count": 1e300}),
|
||||
json!({"count": "many"}),
|
||||
json!({"max_steps": 0}),
|
||||
json!({"max_steps": 100_001}),
|
||||
json!({"max_steps": u64::MAX}),
|
||||
json!({"seeds": []}),
|
||||
json!({"seeds": [[1, 2]]}),
|
||||
json!({"seeds": [[1e300, 0, 0]]}),
|
||||
json!({"seeds": [[1, "x", 0]]}),
|
||||
json!({"seeds": "here"}),
|
||||
json!({"seeds": vec![point.clone(); 2001]}),
|
||||
json!({"seeds": [point], "line": {"from": [0, 0, 0], "to": [1, 1, 1]}}),
|
||||
json!({"line": {"from": [0, 0, 0]}}),
|
||||
json!({"line": 5}),
|
||||
json!({"line": {"from": [0, 0, 0], "to": [1e13, 0, 0]}}),
|
||||
json!({"count": 2000, "max_steps": 100_000}),
|
||||
json!({"seeds": vec![point; 2000], "max_steps": 100_000}),
|
||||
];
|
||||
for id in
|
||||
["results.slice", "results.surface", "results.streamlines", "results.frame", "results.contour", "results.deformed", "results.animate"]
|
||||
{
|
||||
for p in &hostile {
|
||||
let mut t = s.clone();
|
||||
let _ = t.execute(id, p);
|
||||
assert_eq!(t.model, s.model);
|
||||
// Whatever was accepted still draws.
|
||||
let _ = crate::view::scene(&t, &crate::view::Palette::LIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_hostile_params_never_panic() {
|
||||
let s = solved();
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
//! Size controls: `mesh.size.set` asks for elements of a size on faces or bodies, in a box or in
|
||||
//! a sphere; `mesh.size.growth` sets how fast the size grows away from them; `mesh.size.clear`
|
||||
//! removes controls and `mesh.size.list` lists them with what each covers in the open design.
|
||||
//! Controls live in the model (`fieldcraft_model::sizes`), are saved with it, and every mesher
|
||||
//! reads them through [`Sizing`]: the tetrahedral mesher bakes them into its graded size field,
|
||||
//! the structured hex meshers and the octree take the exact graded field
|
||||
//! ([`fieldcraft_mesh::size::RegionSize`]), and the block projection (`mesh.hex.project`) divides
|
||||
//! its block edges by it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_geometry::{Design, Shape};
|
||||
use fieldcraft_mesh::size::{LocalSize, Region, RegionSize};
|
||||
use fieldcraft_mesh::tet::QualityOptions;
|
||||
use fieldcraft_model::{Entity, GeometryRef, SizeControl, SizeRegion};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::opt;
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
/// Most controls one `mesh.size.clear` names.
|
||||
const MAX_NAMES: usize = 10_000;
|
||||
/// The chordal tolerance of the tessellation a face control is resolved on, relative to the
|
||||
/// body's diagonal (as the meshers' default).
|
||||
const FACE_TOLERANCE: f64 = 1e-3;
|
||||
|
||||
pub const SIZE_SET: CommandSpec = CommandSpec {
|
||||
id: "mesh.size.set",
|
||||
label: "Local Size",
|
||||
params: r#"{"name": "Hole", "size": 0.5, and one of "faces": [{"body": "Body1", "face": "F3"}, …] (persistent face names, geometry.bodies; a name without its `#k` number takes every piece), "bodies": ["Body1", …], "box": {"min": [x, y, z], "max": [x, y, z]}, "sphere": {"center": [x, y, z], "radius": r}} — adds the size control `name`, or replaces it: elements of about `size` on those faces, in those bodies, in the box or in the sphere. Every mesher honours it: the size at a point is the smallest of the mesher's size and, over the controls, a control's size plus the growth rate (mesh.size.growth) times the distance to its region, so elements grow away from it gradually. A control finer than the mesher's size refines; a coarser one changes nothing, except on whole bodies, where it is the body's size in place of the mesher's `size` (meshers that mesh several bodies together, mesh.hex.blocks and mesh.cells, take it as a control on the body's surface). Saved with the model; mesh.size.list shows it"#,
|
||||
form: Some(super::forms::SIZE_SET),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: size_set,
|
||||
};
|
||||
|
||||
pub const SIZE_GROWTH: CommandSpec = CommandSpec {
|
||||
id: "mesh.size.growth",
|
||||
label: "Size Growth",
|
||||
params: r#"{"rate": 0.3 (0.05..=2; omitted or null: back to the default, 0.3)} — the size field's growth rate: the largest change of element size per unit length away from a size control or an edge seed. mesh.tet.generate's `grading` overrides it for one run"#,
|
||||
form: Some(super::forms::SIZE_GROWTH),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: size_growth,
|
||||
};
|
||||
|
||||
pub const SIZE_CLEAR: CommandSpec = CommandSpec {
|
||||
id: "mesh.size.clear",
|
||||
label: "Clear Local Sizes",
|
||||
params: r#"{"names"?: ["Hole", …]} — removes these size controls, or every one"#,
|
||||
form: Some(super::forms::SIZE_CLEAR),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::SizeControl],
|
||||
run: size_clear,
|
||||
};
|
||||
|
||||
pub const SIZE_LIST: CommandSpec = CommandSpec {
|
||||
id: "mesh.size.list",
|
||||
label: "Local Sizes",
|
||||
params: "{} — the size controls by name, each with its region and size and, for faces and bodies, what it covers in the open design (`stale`: names the design no longer has), and the growth rate",
|
||||
form: Some(&[]),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[],
|
||||
run: size_list,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BoxParams {
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SphereParams {
|
||||
center: [f64; 3],
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
fn size_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name: String = opt(p, "name")?.ok_or_else(|| EngineError::Params("`name` is required".into()))?;
|
||||
let size: f64 = opt(p, "size")?.ok_or_else(|| EngineError::Params("`size` is required: the element size".into()))?;
|
||||
let faces: Option<Vec<GeometryRef>> = opt(p, "faces")?;
|
||||
let bodies: Option<Vec<String>> = opt(p, "bodies")?;
|
||||
let boxed: Option<BoxParams> = opt(p, "box")?;
|
||||
let sphere: Option<SphereParams> = opt(p, "sphere")?;
|
||||
let given = [faces.is_some(), bodies.is_some(), boxed.is_some(), sphere.is_some()].iter().filter(|g| **g).count();
|
||||
if given != 1 {
|
||||
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
|
||||
}
|
||||
let region = if let Some(faces) = faces {
|
||||
if let Some(r) = faces.iter().find(|r| !matches!(r.entity, Entity::Face(_))) {
|
||||
return Err(EngineError::Params(format!("`faces` lists the {r}; name faces as {{body, face}}")));
|
||||
}
|
||||
SizeRegion::Geometry(faces)
|
||||
} else if let Some(bodies) = bodies {
|
||||
SizeRegion::Geometry(bodies.into_iter().map(|b| GeometryRef::new(b, Entity::Body)).collect::<fieldcraft_model::Result<_>>()?)
|
||||
} else if let Some(b) = boxed {
|
||||
SizeRegion::Box { min: b.min, max: b.max }
|
||||
} else if let Some(sp) = sphere {
|
||||
SizeRegion::Sphere { center: sp.center, radius: sp.radius }
|
||||
} else {
|
||||
return Err(EngineError::Params("give one of `faces`, `bodies`, `box` or `sphere`".into()));
|
||||
};
|
||||
let control = SizeControl { region, size };
|
||||
// Faces and bodies must name the open design's, when there is one.
|
||||
if let (Some(design), SizeRegion::Geometry(refs)) = (s.design.as_deref(), &control.region)
|
||||
&& let Some(r) = refs.iter().find(|r| covered(design, r).is_ok_and(|c| c.is_empty()))
|
||||
{
|
||||
return Err(EngineError::Params(format!("the design has no {r} (geometry.bodies lists the faces)")));
|
||||
}
|
||||
s.model.set_size_control(&name, control.clone())?;
|
||||
Ok(report(s, &name, &control))
|
||||
}
|
||||
|
||||
fn size_growth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rate: Option<f64> = opt(p, "rate")?;
|
||||
s.model.set_size_growth(rate)?;
|
||||
Ok(json!({"growth": s.model.size_growth(), "default": !s.model.size_growth_set()}))
|
||||
}
|
||||
|
||||
fn size_clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names: Option<Vec<String>> = opt(p, "names")?;
|
||||
let cleared = match names {
|
||||
None => {
|
||||
let all: Vec<String> = s.model.size_controls().keys().cloned().collect();
|
||||
if all.is_empty() {
|
||||
return Err(EngineError::Params("the model has no size controls".into()));
|
||||
}
|
||||
for n in &all {
|
||||
s.model.remove_size_control(n)?;
|
||||
}
|
||||
all.len()
|
||||
}
|
||||
Some(names) => {
|
||||
if names.is_empty() || names.len() > MAX_NAMES {
|
||||
return Err(EngineError::Params(format!("`names` must list 1 to {MAX_NAMES} size controls")));
|
||||
}
|
||||
for n in &names {
|
||||
s.model.remove_size_control(n)?;
|
||||
}
|
||||
names.len()
|
||||
}
|
||||
};
|
||||
Ok(json!({"cleared": cleared, "left": s.model.size_controls().len()}))
|
||||
}
|
||||
|
||||
fn size_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let controls: serde_json::Map<String, Value> = s.model.size_controls().iter().map(|(n, c)| (n.clone(), report(s, n, c))).collect();
|
||||
Ok(json!({"controls": controls, "growth": s.model.size_growth()}))
|
||||
}
|
||||
|
||||
/// A control's report: its definition and, for faces and bodies, the names each covers in the
|
||||
/// open design.
|
||||
fn report(s: &Session, name: &str, c: &SizeControl) -> Value {
|
||||
let mut v = json!({"name": name, "size": c.size, "region": c.region});
|
||||
if let (SizeRegion::Geometry(refs), Some(design)) = (&c.region, s.design.as_deref()) {
|
||||
let on: Vec<Value> = refs.iter().map(|r| json!({"target": r.to_string(), "covers": covered(design, r).unwrap_or_default()})).collect();
|
||||
v["stale"] = json!(on.iter().any(|o| o["covers"].as_array().is_none_or(Vec::is_empty)));
|
||||
v["on"] = json!(on);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The faces a face reference selects (its `#k` pieces too), or the body a body reference names.
|
||||
fn covered(design: &Design, r: &GeometryRef) -> Result<Vec<String>> {
|
||||
let Some(body) = design.body(&r.body) else { return Ok(Vec::new()) };
|
||||
Ok(match r.entity {
|
||||
Entity::Body => vec![body.name.clone()],
|
||||
_ => body.faces().iter().filter(|f| selects(r, &body.name, &f.name)).map(|f| f.name.clone()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn selects(r: &GeometryRef, body: &str, face: &str) -> bool {
|
||||
GeometryRef::new(body.to_string(), Entity::Face(face.to_string())).is_ok_and(|g| r.selects(&g))
|
||||
}
|
||||
|
||||
/// The model's size controls resolved on the open design, as the meshers take them.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct Sizing {
|
||||
/// Local sizes on faces, in boxes and in spheres.
|
||||
pub(crate) locals: Vec<LocalSize>,
|
||||
/// Per body, the size its body controls set.
|
||||
pub(crate) bodies: BTreeMap<String, f64>,
|
||||
/// Per body, its boundary surface's triangles (for the meshers that take a body control as
|
||||
/// a local size on its surface).
|
||||
surfaces: BTreeMap<String, Vec<[[f64; 3]; 3]>>,
|
||||
pub(crate) growth: f64,
|
||||
}
|
||||
|
||||
/// The triangles of `shape`'s faces that `select` picks.
|
||||
fn triangles(shape: &Shape, select: impl Fn(&str) -> bool) -> Result<Vec<[[f64; 3]; 3]>> {
|
||||
let tolerance = FACE_TOLERANCE * shape.size()?;
|
||||
let mesh = shape.tessellate(tolerance)?;
|
||||
let names = shape.faces();
|
||||
let point = |i: u32| mesh.positions.get(i as usize).copied();
|
||||
Ok(mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| names.get(**f as usize).is_some_and(|n| select(&n.name)))
|
||||
.filter_map(|(t, _)| Some([point(t[0])?, point(t[1])?, point(t[2])?]))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolve the model's size controls on the open design. Face controls on faces the design does
|
||||
/// not have, and body controls on bodies it does not have, are left out (mesh.size.list shows
|
||||
/// them as stale).
|
||||
pub(crate) fn sizing(s: &Session) -> Result<Sizing> {
|
||||
let mut out = Sizing { growth: s.model.size_growth(), ..Sizing::default() };
|
||||
for c in s.model.size_controls().values() {
|
||||
let region = match &c.region {
|
||||
SizeRegion::Box { min, max } => Region::Box { lo: *min, hi: *max },
|
||||
SizeRegion::Sphere { center, radius } => Region::Sphere { center: *center, radius: *radius },
|
||||
SizeRegion::Geometry(refs) => {
|
||||
let Some(design) = s.design.as_deref() else { continue };
|
||||
let mut tris = Vec::new();
|
||||
for r in refs {
|
||||
let Some(shape) = design.body(&r.body) else { continue };
|
||||
if r.entity == Entity::Body {
|
||||
let size = out.bodies.entry(shape.name.clone()).or_insert(c.size);
|
||||
*size = size.min(c.size);
|
||||
if !out.surfaces.contains_key(&shape.name) {
|
||||
out.surfaces.insert(shape.name.clone(), triangles(shape, |_| true)?);
|
||||
}
|
||||
} else {
|
||||
tris.extend(triangles(shape, |f| selects(r, &shape.name, f))?);
|
||||
}
|
||||
}
|
||||
if tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
Region::Triangles(tris)
|
||||
}
|
||||
};
|
||||
out.locals.push(LocalSize { region, size: c.size });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Sizing {
|
||||
/// The size of `body` meshed alone: its body control's, else `size`.
|
||||
pub(crate) fn body_size(&self, body: &str, size: f64) -> f64 {
|
||||
self.bodies.get(body).copied().unwrap_or(size)
|
||||
}
|
||||
|
||||
/// The sizing of several bodies meshed together at one size, so the curves and faces they
|
||||
/// share are divided alike: the face, box and sphere controls, and each body control as a
|
||||
/// local size on that body's surface.
|
||||
pub(crate) fn joint(&self, bodies: &[&str]) -> Sizing {
|
||||
let mut locals = self.locals.clone();
|
||||
for b in bodies {
|
||||
if let (Some(&size), Some(tris)) = (self.bodies.get(*b), self.surfaces.get(*b)) {
|
||||
locals.push(LocalSize { region: Region::Triangles(tris.clone()), size });
|
||||
}
|
||||
}
|
||||
Sizing { locals, growth: self.growth, ..Sizing::default() }
|
||||
}
|
||||
|
||||
/// The graded field of `body` meshed at `size` (its body control's size in place of it).
|
||||
pub(crate) fn field(&self, body: &str, size: f64) -> Result<RegionSize> {
|
||||
Ok(RegionSize::new(self.body_size(body, size), self.growth, self.locals.clone())?)
|
||||
}
|
||||
|
||||
/// The graded field of the face, box and sphere controls about a size already chosen (body
|
||||
/// controls taken into it, or a mesh plan's size), for a mesher that takes a uniform size
|
||||
/// and an optional field; `None` when no control refines it.
|
||||
pub(crate) fn local_field(&self, size: f64) -> Result<Option<RegionSize>> {
|
||||
Ok(Some(RegionSize::new(size, self.growth, self.locals.clone())?).filter(|f| !f.is_uniform()))
|
||||
}
|
||||
|
||||
/// The tetrahedral mesher's options for `body`: its body control's size in place of the
|
||||
/// global one, the local sizes, and the growth rate unless the command gives `grading`.
|
||||
pub(crate) fn tet_options(&self, body: &str, p: &Value, o: &QualityOptions) -> QualityOptions {
|
||||
QualityOptions {
|
||||
size: self.bodies.get(body).copied().or(o.size),
|
||||
locals: self.locals.clone(),
|
||||
grading: if p.get("grading").is_some() { o.grading } else { self.growth },
|
||||
..o.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plate() -> (Session, std::path::PathBuf) {
|
||||
super::super::datum::tests::plate()
|
||||
}
|
||||
|
||||
/// The name of the plate's face that lies in the plane x = `x`.
|
||||
fn face_at_x(s: &Session, x: f64) -> String {
|
||||
let body = s.design.as_ref().unwrap().body("Body1").unwrap();
|
||||
let mesh = body.tessellate(0.1).unwrap();
|
||||
let faces = body.faces();
|
||||
(0..faces.len())
|
||||
.find(|&f| {
|
||||
let mut pts = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).flat_map(|(t, _)| t.iter());
|
||||
pts.clone().next().is_some() && pts.all(|&v| (mesh.positions[v as usize][0] - x).abs() < 1e-6)
|
||||
})
|
||||
.map(|f| faces[f].name.clone())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Every element edge of the model's tet4 or hex8 elements, once: its two end points.
|
||||
fn edges(s: &Session) -> Vec<([f64; 3], [f64; 3])> {
|
||||
const TET: &[[usize; 2]] = &[[0, 1], [0, 2], [0, 3], [1, 2], [1, 3], [2, 3]];
|
||||
const HEX: &[[usize; 2]] = &[[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 mut seen = std::collections::BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for e in s.model.elements().values() {
|
||||
let pairs = if e.nodes.len() == 4 { TET } else { HEX };
|
||||
for [a, b] in pairs {
|
||||
let (a, b) = (e.nodes[*a], e.nodes[*b]);
|
||||
if seen.insert((a.min(b), a.max(b))) {
|
||||
out.push((s.model.nodes()[&a].xyz, s.model.nodes()[&b].xyz));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn length((a, b): &([f64; 3], [f64; 3])) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
fn mean(v: &[f64]) -> f64 {
|
||||
assert!(!v.is_empty());
|
||||
v.iter().sum::<f64>() / v.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_are_set_listed_cleared_saved_and_undone() {
|
||||
let (mut s, dir) = plate();
|
||||
let end = face_at_x(&s, 40.0);
|
||||
let r = s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
|
||||
assert_eq!(r["on"][0]["covers"], json!([end]), "{r}");
|
||||
assert_eq!(r["stale"], false);
|
||||
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "box": {"min": [0, 0, 0], "max": [5, 5, 10]}})).unwrap();
|
||||
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 2, "sphere": {"center": [0, 0, 0], "radius": 3}})).unwrap();
|
||||
s.execute("mesh.size.set", &json!({"name": "Whole", "size": 4, "bodies": ["Body1"]})).unwrap();
|
||||
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
|
||||
let list = s.execute("mesh.size.list", &json!({})).unwrap();
|
||||
assert_eq!(list["controls"].as_object().unwrap().len(), 3, "{list}");
|
||||
assert_eq!(list["controls"]["Zone"]["region"]["sphere"]["radius"], 3.0);
|
||||
assert_eq!(list["growth"], 0.5);
|
||||
|
||||
let file = dir.join("sized.fieldcraft");
|
||||
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut back = Session::default();
|
||||
back.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(back.model.size_controls(), s.model.size_controls());
|
||||
assert_eq!(back.model.size_growth(), 0.5);
|
||||
|
||||
s.execute("mesh.size.clear", &json!({"names": ["Zone"]})).unwrap();
|
||||
assert_eq!(s.model.size_controls().len(), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.size_controls().len(), 3);
|
||||
assert!(s.execute("mesh.size.clear", &json!({"names": ["Nope"]})).is_err());
|
||||
assert_eq!(s.execute("mesh.size.clear", &json!({})).unwrap()["cleared"], 3);
|
||||
assert!(s.execute("mesh.size.clear", &json!({})).is_err(), "nothing to clear");
|
||||
s.execute("mesh.size.growth", &json!({})).unwrap();
|
||||
assert_eq!(s.model.size_growth(), fieldcraft_model::sizes::DEFAULT_GROWTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let (mut s, _) = plate();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "size": 1}),
|
||||
json!({"name": "A", "size": 0, "bodies": ["Body1"]}),
|
||||
json!({"name": "A", "size": -1, "bodies": ["Body1"]}),
|
||||
json!({"name": "A", "size": 1e300, "bodies": ["Body1"]}),
|
||||
json!({"name": "A", "size": "big", "bodies": ["Body1"]}),
|
||||
json!({"name": "A", "size": 1, "bodies": ["Nope"]}),
|
||||
json!({"name": "A", "size": 1, "bodies": []}),
|
||||
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "face": "Nope"}]}),
|
||||
json!({"name": "A", "size": 1, "faces": [{"body": "Body1", "edge": "E"}]}),
|
||||
json!({"name": "A", "size": 1, "bodies": ["Body1"], "box": {"min": [0, 0, 0], "max": [1, 1, 1]}}),
|
||||
json!({"name": "A", "size": 1, "box": {"min": [1, 0, 0], "max": [0, 1, 1]}}),
|
||||
json!({"name": "A", "size": 1, "box": {"min": [0, 0], "max": [1, 1, 1]}}),
|
||||
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 0}}),
|
||||
json!({"name": "A", "size": 1, "sphere": {"center": [0, 0, 0], "radius": 1, "x": 1}}),
|
||||
json!({"name": "1 bad", "size": 1, "bodies": ["Body1"]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.size.set", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [json!({"rate": 0}), json!({"rate": 5}), json!({"rate": "x"})] {
|
||||
assert!(s.execute("mesh.size.growth", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("mesh.size.clear", &json!({"names": []})).is_err());
|
||||
assert!(s.model.size_controls().is_empty());
|
||||
}
|
||||
|
||||
/// A size box halves the tetrahedra inside it; a face control gives its face the size it
|
||||
/// asks for, and the elements grow away from it no faster than the growth rate allows.
|
||||
#[test]
|
||||
fn tet_meshes_follow_size_controls() {
|
||||
let (mut s, _) = plate();
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
|
||||
let inside = |e: &&([f64; 3], [f64; 3])| [e.0, e.1].iter().all(|p| p[0] >= 30.0 && p[1] >= 0.0 && p[1] <= 10.0);
|
||||
let before = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
|
||||
s.execute("mesh.size.set", &json!({"name": "Zone", "size": 3, "box": {"min": [30, -1, -1], "max": [41, 10, 11]}})).unwrap();
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
|
||||
let after = mean(&edges(&s).iter().filter(inside).map(length).collect::<Vec<_>>());
|
||||
assert!((after / before - 0.5).abs() < 0.15, "box: {before} -> {after}");
|
||||
|
||||
s.execute("mesh.size.clear", &json!({})).unwrap();
|
||||
let end = face_at_x(&s, 40.0);
|
||||
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.5, "faces": [{"body": "Body1", "face": end}]})).unwrap();
|
||||
s.execute("mesh.size.growth", &json!({"rate": 0.5})).unwrap();
|
||||
s.execute("mesh.tet.generate", &json!({"body": "Body1", "size": 6, "tolerance": 0.2})).unwrap();
|
||||
let all = edges(&s);
|
||||
let on_face: Vec<f64> = all.iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect();
|
||||
let m = mean(&on_face);
|
||||
assert!((1.2..1.95).contains(&m), "on the face: {m}");
|
||||
// Slabs away from the face: the mean edge stays near 1.5 + 0.5 d (and below 6).
|
||||
for d in [3.0, 6.0, 9.0] {
|
||||
let slab: Vec<f64> = all.iter().filter(|e| (40.0 - 0.5 * (e.0[0] + e.1[0]) - d).abs() < 1.0).map(length).collect();
|
||||
let want = (1.5f64 + 0.5 * d).min(6.0);
|
||||
let m = mean(&slab);
|
||||
assert!(m < want * 1.3 && m > want * 0.5, "{d} mm from the face: mean edge {m}, field {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A face control sets the hex divisions of the face's edges: the swept plate's end face gets
|
||||
/// elements of the size asked for.
|
||||
#[test]
|
||||
fn hex_meshes_follow_size_controls() {
|
||||
let (mut s, _) = plate();
|
||||
let end = face_at_x(&s, 40.0);
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
|
||||
let on_end = |s: &Session| -> Vec<f64> {
|
||||
edges(s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-6 && (e.1[0] - 40.0).abs() < 1e-6).map(length).collect()
|
||||
};
|
||||
let coarse = on_end(&s).into_iter().fold(0.0, f64::max);
|
||||
assert!(coarse > 3.0, "{coarse}");
|
||||
s.execute("mesh.size.set", &json!({"name": "End", "size": 1.25, "faces": [{"body": "Body1", "face": end}]})).unwrap();
|
||||
s.execute("mesh.hex.generate", &json!({"body": "Body1", "size": 5})).unwrap();
|
||||
let fine = on_end(&s);
|
||||
assert!(fine.iter().all(|&l| l < 1.25 * 1.3), "{fine:?}");
|
||||
assert!(mean(&fine) > 1.25 * 0.7, "{fine:?}");
|
||||
}
|
||||
|
||||
/// The octree's cells on a face with a size control reach the level its size needs.
|
||||
#[test]
|
||||
fn octree_meshes_follow_size_controls() {
|
||||
let (mut s, _) = plate();
|
||||
let end = face_at_x(&s, 40.0);
|
||||
s.execute("mesh.size.set", &json!({"name": "End", "size": 2, "faces": [{"body": "Body1", "face": end}]})).unwrap();
|
||||
s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 6})).unwrap();
|
||||
let on_end: Vec<f64> = edges(&s).iter().filter(|e| (e.0[0] - 40.0).abs() < 1e-3 && (e.1[0] - 40.0).abs() < 1e-3).map(length).collect();
|
||||
assert!(!on_end.is_empty() && mean(&on_end) < 2.0 * 1.3, "{on_end:?}");
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ pub const HISTORY: CommandSpec = CommandSpec {
|
||||
pub const FLUID: CommandSpec = CommandSpec {
|
||||
id: "solve.fluid",
|
||||
label: "Solve Fluid Step",
|
||||
params: r#"{"step": "Flow", "backend": "cpu", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass, the final force on every wall and, with heat, the thermal relaxation time, the temperature range and the heat into every wall; `backend` is `cpu` (the default: double precision, the same result on every machine) or `gpu` (the step on the GPU `FIELDCRAFT_GPU` picks, single precision, within the backend's stated tolerance of the CPU; an error when the build has none); the caps may only be lowered; `threads` as for solve.static"#,
|
||||
params: r#"{"step": "Flow", "max_cells": 16000000, "max_steps": 10000000, "max_cell_updates": 1e12, "max_frame_bytes": 2e9, "threads": 8} — runs a fluid step with the lattice Boltzmann solver (the only fluid step when `step` is left out): obstacles are triangulated from the open design or taken from imported surfaces; returns the time step, relaxation time, Mach number, mass and the final force on every wall; the caps may only be lowered; `threads` as for solve.static"#,
|
||||
form: Some(super::forms::SOLVE_FLUID),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::FluidDomain, Requirement::Step],
|
||||
@@ -110,7 +110,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
pub const PROBE: CommandSpec = CommandSpec {
|
||||
id: "solve.probe",
|
||||
label: "Probe Results",
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress, principal stresses (the values, largest first, without their directions) 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, pressure and (with heat) temperature in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories and {"step": "Flow", "heat": ["y_min"]} the heat into them over time (power, positive into the wall)"#,
|
||||
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
form: Some(super::forms::SOLVE_PROBE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Result],
|
||||
@@ -487,15 +487,10 @@ fn run_fluid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
max_frame_bytes: cap(p, "max_frame_bytes", defaults.max_frame_bytes)?,
|
||||
};
|
||||
let step = opt::<String>(p, "step")?;
|
||||
let backend = match opt::<String>(p, "backend")? {
|
||||
None => fieldcraft_lbm::Choice::Cpu,
|
||||
Some(b) => fieldcraft_lbm::Choice::parse(&b)
|
||||
.ok_or_else(|| EngineError::Params(format!("`backend` must be `cpu` or `gpu`, not `{}`", b.chars().take(40).collect::<String>())))?,
|
||||
};
|
||||
let (_, domain) = fieldcraft_lbm::fluid_step(&s.model, step.as_deref())?;
|
||||
let obstacles = obstacles(s, domain)?;
|
||||
let model = &s.model;
|
||||
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid_on(model, step.as_deref(), &obstacles, &limits, backend))?;
|
||||
let r = with_threads(threads(s, p)?, || fieldcraft_lbm::solve_fluid(model, step.as_deref(), &obstacles, &limits))?;
|
||||
let summary = fluid_summary(&r);
|
||||
s.store_result(StepResult::Fluid(r));
|
||||
Ok(summary)
|
||||
@@ -520,24 +515,8 @@ fn fluid_summary(r: &FluidResult) -> Value {
|
||||
})
|
||||
.collect();
|
||||
let (m0, m1) = (r.frames.first().map_or(0.0, |f| f.mass), last.map_or(0.0, |f| f.mass));
|
||||
let heat: Vec<Value> = r
|
||||
.heat
|
||||
.iter()
|
||||
.filter(|h| h.power.iter().any(|v| *v != 0.0))
|
||||
.map(|h| {
|
||||
let recent = h.power.get(h.power.len().saturating_sub(tail)..).unwrap_or_default();
|
||||
let mean = recent.iter().sum::<f64>() / recent.len().max(1) as f64;
|
||||
json!({"name": h.name, "body": h.body, "final": h.power.last(), "mean": mean})
|
||||
})
|
||||
.collect();
|
||||
let temperature = last.filter(|f| !f.temperature.is_empty()).map(|f| {
|
||||
let fluid = f.temperature.iter().zip(&r.solid).filter(|(_, solid)| !**solid).map(|(t, _)| f64::from(*t));
|
||||
let (lo, hi) = fluid.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| (lo.min(t), hi.max(t)));
|
||||
json!({"min": lo, "max": hi})
|
||||
});
|
||||
json!({
|
||||
"step": r.step,
|
||||
"backend": r.stats.backend,
|
||||
"cells": r.stats.cells,
|
||||
"fluid_cells": r.stats.fluid_cells,
|
||||
"steps": r.stats.steps,
|
||||
@@ -553,9 +532,6 @@ fn fluid_summary(r: &FluidResult) -> Value {
|
||||
"max_speed": max_speed,
|
||||
"mass": {"first": m0, "last": m1, "relative_change": if m0 != 0.0 { (m1 - m0) / m0 } else { 0.0 }},
|
||||
"forces": forces,
|
||||
"tau_thermal": r.stats.tau_thermal,
|
||||
"temperature": temperature,
|
||||
"heat": heat,
|
||||
"warnings": r.warnings,
|
||||
})
|
||||
}
|
||||
@@ -577,25 +553,6 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
|
||||
.collect::<Result<_>>()?;
|
||||
return Ok(json!({"step": r.step, "times": r.history_times, "forces": out}));
|
||||
}
|
||||
if let Some(names) = opt::<Vec<String>>(p, "heat")? {
|
||||
if names.len() > MAX_PROBE_FORCES {
|
||||
return Err(EngineError::Params(format!("`heat` lists at most {MAX_PROBE_FORCES} walls")));
|
||||
}
|
||||
if r.heat.is_empty() {
|
||||
return Err(EngineError::Params(format!("step `{}` carries no heat: give its domain `thermal` settings", r.step)));
|
||||
}
|
||||
let out: Vec<Value> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let h =
|
||||
r.heat.iter().find(|h| &h.name == n || h.body.as_ref().is_some_and(|b| format!("{b}/{}", h.name) == *n)).ok_or_else(|| {
|
||||
EngineError::Params(format!("no wall `{n}` (box faces x_min … z_max, or obstacle faces by name or body/name)"))
|
||||
})?;
|
||||
Ok(json!({"name": h.name, "body": h.body, "power": h.power}))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
return Ok(json!({"step": r.step, "times": r.history_times, "heat": out}));
|
||||
}
|
||||
let pts = points(p, "points", MAX_PROBE)?;
|
||||
let frame = match opt::<usize>(p, "frame")? {
|
||||
Some(k) if k < r.frames.len() => k,
|
||||
@@ -606,12 +563,8 @@ fn probe_fluid(r: &FluidResult, p: &Value) -> Result<Value> {
|
||||
let out: Vec<Value> = pts
|
||||
.iter()
|
||||
.map(|&pt| match r.probe(frame, pt) {
|
||||
Some((cell, velocity, pressure, temperature)) => {
|
||||
let mut v = json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure});
|
||||
if let (Some(t), Some(obj)) = (temperature, v.as_object_mut()) {
|
||||
obj.insert("temperature".into(), json!(t));
|
||||
}
|
||||
v
|
||||
Some((cell, velocity, pressure)) => {
|
||||
json!({"point": pt, "cell": cell, "solid": r.solid.get(cell), "velocity": velocity, "pressure": pressure})
|
||||
}
|
||||
None => json!({"point": pt, "cell": null}),
|
||||
})
|
||||
@@ -717,7 +670,6 @@ fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value)
|
||||
"reaction": r.reaction.get(i),
|
||||
"reaction_moment": r.reaction_moment.get(i),
|
||||
"stress": r.stress.get(i),
|
||||
"principal": r.stress.get(i).map(fieldcraft_fem::principal),
|
||||
"von_mises": r.von_mises.get(i),
|
||||
"temperature": r.temperature.as_ref().and_then(|t| t.get(i)),
|
||||
});
|
||||
@@ -887,7 +839,6 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use fieldcraft_model::{BoxFace, FluidBoundary, ThermalCondition};
|
||||
|
||||
/// A cantilever 10 × 1 × 1 of ten hex8 elements with incompatible modes, clamped at x = 0,
|
||||
/// a total force of 1 down on the free end; a static and a frequency step.
|
||||
@@ -946,10 +897,6 @@ pub(crate) mod tests {
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
|
||||
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
|
||||
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
|
||||
// The principal stresses of the nodal stress, largest first.
|
||||
let root = &probe["nodes"][1];
|
||||
let stress: [f64; 6] = serde_json::from_value(root["stress"].clone()).unwrap();
|
||||
assert_eq!(root["principal"], json!(fieldcraft_fem::principal(&stress)), "{root}");
|
||||
// By location: the nearest node, with where it is.
|
||||
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
|
||||
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
|
||||
@@ -1157,95 +1104,6 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Couette gap with heat: the sliding top wall held at 1, the bottom at 0, the fluid's
|
||||
/// diffusivity k / (ρ c) = 0.1.
|
||||
fn heated_couette(s: &mut Session) {
|
||||
couette(s);
|
||||
heat_the_couette(s);
|
||||
}
|
||||
|
||||
fn heat_the_couette(s: &mut Session) {
|
||||
s.execute(
|
||||
"model.material.edit",
|
||||
&json!({"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1},
|
||||
"thermal": {"conductivity": 0.1, "specific_heat": 1.0}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"model.fluid_domain.edit",
|
||||
&json!({"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125,
|
||||
"faces": {"x_min": {"type": "periodic"}, "x_max": {"type": "periodic"}, "z_min": {"type": "periodic"}, "z_max": {"type": "periodic"},
|
||||
"y_max": {"type": "moving_wall", "velocity": [0.1, 0, 0]}},
|
||||
"thermal": {"initial_temperature": 0.5, "faces": {"y_min": {"type": "temperature", "value": 0}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": "y_max", "condition": {"type": "temperature", "value": 1}}))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fluid_step_carries_heat_between_its_walls() {
|
||||
let mut s = Session::default();
|
||||
heated_couette(&mut s);
|
||||
let r = s.execute("solve.fluid", &json!({})).unwrap();
|
||||
assert!(r["tau_thermal"].as_f64().unwrap() > 0.5, "{r}");
|
||||
assert!((r["temperature"]["min"].as_f64().unwrap() - 0.0625).abs() < 1e-6, "{r}");
|
||||
assert!((r["temperature"]["max"].as_f64().unwrap() - 0.9375).abs() < 1e-6, "{r}");
|
||||
// Conduction across the gap: k ΔT / H over the wall's area, out of the hot wall and
|
||||
// into the cold one.
|
||||
let expected = 0.1 * 1.0 / 1.0 * 0.125 * 0.125;
|
||||
let heat = r["heat"].as_array().unwrap();
|
||||
let cold = heat.iter().find(|h| h["name"] == "y_min").unwrap();
|
||||
let hot = heat.iter().find(|h| h["name"] == "y_max").unwrap();
|
||||
assert!((cold["mean"].as_f64().unwrap() / expected - 1.0).abs() < 1e-6, "{cold}");
|
||||
assert!((hot["mean"].as_f64().unwrap() / expected + 1.0).abs() < 1e-6, "{hot}");
|
||||
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Shear", "points": [[0.06, 0.5625, 0.06]]})).unwrap();
|
||||
assert!((probe["points"][0]["temperature"].as_f64().unwrap() - 0.5625).abs() < 1e-6, "{probe}");
|
||||
let h = s.execute("solve.probe", &json!({"step": "Shear", "heat": ["y_min", "y_max"]})).unwrap();
|
||||
assert_eq!(h["heat"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(h["times"].as_array().unwrap().len(), h["heat"][0]["power"].as_array().unwrap().len());
|
||||
assert!(s.execute("solve.probe", &json!({"step": "Shear", "heat": ["nope"]})).is_err());
|
||||
|
||||
// The thermal boundary needs the domain's heat settings; the material must keep its
|
||||
// heat properties while a domain carries heat.
|
||||
let isothermal = json!({"name": "Gap", "material": "Oil", "min": [0, 0, 0], "max": [0.125, 1, 0.125], "spacing": 0.125});
|
||||
s.execute("model.fluid_domain.edit", &isothermal).unwrap();
|
||||
assert!(
|
||||
s.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": "y_max", "condition": {"type": "adiabatic"}})).is_err()
|
||||
);
|
||||
let r = s.execute("solve.fluid", &json!({})).unwrap();
|
||||
assert!(r["temperature"].is_null() && r["heat"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(s.execute("solve.probe", &json!({"step": "Shear", "heat": ["y_min"]})).is_err());
|
||||
heat_the_couette(&mut s);
|
||||
assert!(s.execute("model.material.edit", &json!({"name": "Oil", "density": 1.0, "fluid": {"type": "newtonian", "viscosity": 0.1}})).is_err());
|
||||
// JSON has no NaN (`json!(f64::NAN)` is `null`): the model refuses one directly.
|
||||
let nan = ThermalCondition::Temperature { value: f64::NAN };
|
||||
assert!(s.model.set_fluid_thermal_boundary("Gap", FluidBoundary::Box(BoxFace::YMax), nan).is_err());
|
||||
let hostile = [
|
||||
json!({"domain": "Gap", "at": "y_max", "condition": {"type": "temperature", "value": "nan"}}),
|
||||
json!({"domain": "Gap", "at": "top", "condition": {"type": "temperature", "value": 1}}),
|
||||
json!({"domain": "Gap", "at": {"body": "Nope", "face": "F1"}, "condition": {"type": "temperature", "value": 1}}),
|
||||
json!({"domain": "Nope", "at": "y_max", "condition": {"type": "temperature", "value": 1}}),
|
||||
json!({"domain": "Gap", "at": "y_max", "condition": {"type": "oven"}}),
|
||||
json!({"domain": "Gap", "at": "y_max"}),
|
||||
json!(null),
|
||||
];
|
||||
for p in &hostile {
|
||||
let before = s.model.clone();
|
||||
assert!(s.execute("model.fluid_domain.thermal_boundary", p).is_err(), "{p}");
|
||||
assert_eq!(s.model, before, "{p}");
|
||||
}
|
||||
// A finite flux the lattice cannot hold, or one on a periodic face, is refused by the
|
||||
// solve, not by the model.
|
||||
for (at, value) in [("y_max", 1e308), ("x_min", 1.0)] {
|
||||
let mut t = s.clone();
|
||||
t.execute("model.fluid_domain.thermal_boundary", &json!({"domain": "Gap", "at": at, "condition": {"type": "flux", "value": value}}))
|
||||
.unwrap();
|
||||
assert!(t.execute("solve.fluid", &json!({})).is_err(), "{at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fluid_domain_takes_its_obstacles_from_the_open_design() {
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
@@ -1313,17 +1171,10 @@ pub(crate) mod tests {
|
||||
json!({"step": "Shear", "forces": ["nope"]}),
|
||||
json!({"step": "Shear", "forces": vec!["y_min"; 1000]}),
|
||||
json!({"step": "Shear", "forces": "y_min"}),
|
||||
json!({"step": "Shear", "heat": ["y_min"]}),
|
||||
json!({"step": "Shear", "heat": vec!["y_min"; 1000]}),
|
||||
json!({"step": "Shear", "heat": 3}),
|
||||
json!({"step": "Shear", "nodes": [1]}),
|
||||
json!({"step": "Shear", "threads": 0}),
|
||||
json!({"step": "Shear", "threads": 100000}),
|
||||
json!({"step": "Shear", "threads": null}),
|
||||
json!({"step": "Shear", "backend": "tpu"}),
|
||||
json!({"step": "Shear", "backend": 7}),
|
||||
// no device backend is registered in this test binary: an error, not a panic
|
||||
json!({"step": "Shear", "backend": "gpu"}),
|
||||
];
|
||||
for id in ["solve.fluid", "solve.probe", "solve.static", "solve.frequency"] {
|
||||
for p in &hostile {
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
pub const FACES_MERGE: CommandSpec = CommandSpec {
|
||||
id: "geometry.faces.merge",
|
||||
label: "Merge Faces",
|
||||
params: r#"{"name": "Fillet", "faces": [{"body": "Base", "face": "F3:blend#0"}, {"body": "Base", "face": "F3:blend#1"}]} — makes two or more adjacent faces of one body one face for meshing only (virtual topology): the meshers, the checks and the viewport take them as one face `merge:<name>`, without the edges between them (an edge they leave in pieces along a neighbour is one edge), on the surface they share (none when they lie on different surfaces: the projection mesher and node relaxation keep to each B-rep piece's own surface, the other meshers to the tessellation). The design's B-rep is not changed. A face name also selects the pieces a partition cut it into, and may be a face an earlier merge made. The model keeps the merge and applies it again, by persistent face names, whenever the design is opened (after the partitions: on a body they cut, in each cell where its faces, or their pieces, touch); one that no longer applies (a face gone, faces that no longer touch) is listed with the design's errors. The body loses its mesh (`replaced`; remesh it); sets naming the merged faces no longer resolve (`stale_sets`: name `merge:<name>` instead). Faces on several bodies, faces that do not touch along an edge, or a face the body lacks are an error. One undo step"#,
|
||||
params: r#"{"name": "Fillet", "faces": [{"body": "Base", "face": "F3:blend#0"}, {"body": "Base", "face": "F3:blend#1"}]} — makes two or more adjacent faces of one body one face for meshing only (virtual topology): the meshers, the checks and the viewport take them as one face `merge:<name>`, without the edges between them (an edge they leave in pieces along a neighbour is one edge), on the surface they share (none when they lie on different surfaces: the meshers keep to the tessellation). The design's B-rep is not changed. A face name also selects the pieces a partition cut it into, and may be a face an earlier merge made. The model keeps the merge and applies it again, by persistent face names, whenever the design is opened (after the partitions); one that no longer applies (a face gone, faces that no longer touch) is listed with the design's errors. The body loses its mesh (`replaced`; remesh it); sets naming the merged faces no longer resolve (`stale_sets`: name `merge:<name>` instead). Faces on several bodies, faces that do not touch along an edge, or a face the body lacks are an error. One undo step"#,
|
||||
form: Some(super::forms::FACES_MERGE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design],
|
||||
@@ -98,7 +98,7 @@ fn stale_sets(s: &Session, body: &str) -> Vec<String> {
|
||||
/// Apply the model's partitions, merges and splits to the design again, failing with the error of
|
||||
/// one of `features` (`merge <name>`, `split <name>`) when it has one; the meshes of `bodies` go
|
||||
/// (their faces changed).
|
||||
pub(super) fn reapply(s: &mut Session, features: &[String], bodies: &[String], reply: Value) -> Result<Value> {
|
||||
fn reapply(s: &mut Session, features: &[String], bodies: &[String], reply: Value) -> Result<Value> {
|
||||
let mut cleared = super::datum::recut(s)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let errors: Vec<String> =
|
||||
|
||||
@@ -1,813 +0,0 @@
|
||||
//! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the
|
||||
//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed or temperature
|
||||
//! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures
|
||||
//! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn
|
||||
//! (a fluid step does not deform it).
|
||||
//!
|
||||
//! A fluid step is first shown with velocity magnitude on a slice through the middle of the
|
||||
//! domain, normal to the horizontal axis across the flow (the slice holds the flow and the
|
||||
//! vertical: Z is up), on the obstacles, and with streamlines seeded on the inlet face upstream
|
||||
//! of the obstacles.
|
||||
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use fieldcraft_lbm::{FluidResult, Frame};
|
||||
use fieldcraft_render::contour::{Bands, banded, bands_color};
|
||||
use fieldcraft_render::flow::{self, CellGrid, Slice, Streamline};
|
||||
use fieldcraft_render::{Aabb3, Rgb, Scene, SceneLine, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::results::{Field, Kind, Pick, ResultsView, magnitude};
|
||||
use crate::{EngineError, Result, StepResult};
|
||||
|
||||
pub mod wall;
|
||||
|
||||
/// Streamlines traced when no count is given, and the steps (half a cell each) each may take.
|
||||
pub const DEFAULT_STREAMLINES: u32 = 64;
|
||||
pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000;
|
||||
/// Most streamlines and most steps of one, as commands accept them.
|
||||
pub const MAX_STREAMLINES: u32 = flow::MAX_STREAMLINES as u32;
|
||||
pub const MAX_STREAMLINE_STEPS: u32 = flow::MAX_STREAMLINE_STEPS as u32;
|
||||
/// A streamline's width, in points.
|
||||
const STREAMLINE_WIDTH: f64 = 1.5;
|
||||
|
||||
/// A coordinate axis: the normal of a slice.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
pub const ALL: [Axis; 3] = [Axis::X, Axis::Y, Axis::Z];
|
||||
|
||||
/// 0, 1, 2.
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Axis::X => 0,
|
||||
Axis::Y => 1,
|
||||
Axis::Z => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Axis::X => "x",
|
||||
Axis::Y => "y",
|
||||
Axis::Z => "z",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<Axis> {
|
||||
Axis::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A slice through the domain: normal to `axis` at `position` along it (`None`: the domain's
|
||||
/// middle).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SliceView {
|
||||
pub axis: Axis,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<f64>,
|
||||
}
|
||||
|
||||
/// Streamlines: from `seeds`, or (`None`) `count` seeds on the inlet face upstream of the
|
||||
/// obstacles; each at most `max_steps` steps of half a cell.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamlineView {
|
||||
pub count: u32,
|
||||
pub max_steps: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seeds: Option<Vec<[f64; 3]>>,
|
||||
}
|
||||
|
||||
impl Default for StreamlineView {
|
||||
fn default() -> Self {
|
||||
StreamlineView { count: DEFAULT_STREAMLINES, max_steps: DEFAULT_STREAMLINE_STEPS, seeds: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fluid step's display shows besides the field's legend (the view's contour field:
|
||||
/// velocity, pressure, temperature or wall heat flux; `None` draws no colours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FlowView {
|
||||
/// The slice, `None` for none.
|
||||
pub slice: Option<SliceView>,
|
||||
/// The obstacles' surfaces coloured by the field in the fluid beside them.
|
||||
pub surface: bool,
|
||||
pub streamlines: Option<StreamlineView>,
|
||||
}
|
||||
|
||||
impl FlowView {
|
||||
/// A fluid result's first look: a slice through the middle across the flow, the surfaces
|
||||
/// coloured, streamlines from the inlet.
|
||||
pub fn for_result(r: &FluidResult) -> FlowView {
|
||||
let along = r.frames.last().map_or(Axis::X, |f| direction(r, f).0);
|
||||
// The slice holds the flow and the vertical (Z is up); for a flow along y, it is normal to x.
|
||||
let axis = if along == Axis::Y { Axis::X } else { Axis::Y };
|
||||
FlowView { slice: Some(SliceView { axis, position: None }), surface: true, streamlines: Some(StreamlineView::default()) }
|
||||
}
|
||||
|
||||
/// What `view` shows of fluid result `r`: its own settings, or the result's first look.
|
||||
pub fn of<'a>(view: &'a ResultsView, r: &FluidResult) -> std::borrow::Cow<'a, FlowView> {
|
||||
match &view.flow {
|
||||
Some(f) => std::borrow::Cow::Borrowed(f),
|
||||
None => std::borrow::Cow::Owned(FlowView::for_result(r)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result's grid of cells.
|
||||
pub fn grid(r: &FluidResult) -> CellGrid {
|
||||
CellGrid { origin: r.grid.origin, spacing: r.grid.spacing, cells: r.grid.cells }
|
||||
}
|
||||
|
||||
/// The index of the frame of a fluid result `pick` asks for (`None`: the last).
|
||||
pub fn frame_index(r: &FluidResult, pick: Pick) -> Result<usize> {
|
||||
let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no frames", r.step)))?;
|
||||
let k = pick.frame.unwrap_or(last);
|
||||
if k > last {
|
||||
return Err(EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step)));
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
|
||||
/// The frame of a fluid result `pick` asks for (`None`: the last).
|
||||
pub fn frame(r: &FluidResult, pick: Pick) -> Result<&Frame> {
|
||||
let k = frame_index(r, pick)?;
|
||||
r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step)))
|
||||
}
|
||||
|
||||
/// The axis the flow mostly runs along in a frame (by its mean velocity over the fluid cells) and
|
||||
/// whether it runs toward its high end; along +x when the fluid is at rest.
|
||||
pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) {
|
||||
let mut sum = [0.0f64; 3];
|
||||
for (k, v) in f.velocity.iter().enumerate() {
|
||||
if !r.solid.get(k).copied().unwrap_or(false) {
|
||||
for (s, c) in sum.iter_mut().zip(v) {
|
||||
if c.is_finite() {
|
||||
*s += f64::from(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let axis = Axis::ALL.into_iter().fold(Axis::X, |best, a| if sum[a.index()].abs() > sum[best.index()].abs() { a } else { best });
|
||||
(axis, sum[axis.index()] >= 0.0)
|
||||
}
|
||||
|
||||
/// A cell field's value in every cell of a frame: velocity magnitude, pressure or temperature, NaN in the
|
||||
/// solid cells.
|
||||
pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result<Vec<f64>> {
|
||||
let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false);
|
||||
let values: Vec<f64> = match field {
|
||||
Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(),
|
||||
Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(),
|
||||
Field::Temperature if f.temperature.len() == r.grid.len() && !f.temperature.is_empty() => {
|
||||
f.temperature.iter().map(|t| f64::from(*t)).collect()
|
||||
}
|
||||
Field::Temperature => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"temperature is not available for fluid step `{}`: this frame has no complete temperature field (isothermal flow)",
|
||||
r.step
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(EngineError::Params(format!(
|
||||
"{} is not available for fluid step `{}` (velocity, pressure or temperature)",
|
||||
field.label(),
|
||||
r.step
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect())
|
||||
}
|
||||
|
||||
/// The smallest and largest value, with their cells.
|
||||
pub fn cell_range(values: &[f64]) -> Option<((usize, f64), (usize, f64))> {
|
||||
let mut out: Option<((usize, f64), (usize, f64))> = None;
|
||||
for (k, &v) in values.iter().enumerate().filter(|(_, v)| v.is_finite()) {
|
||||
out = Some(match out {
|
||||
None => ((k, v), (k, v)),
|
||||
Some((lo, hi)) => (if v < lo.1 { (k, v) } else { lo }, if v > hi.1 { (k, v) } else { hi }),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The centre of cell `k` of a result's grid.
|
||||
pub fn cell_centre(r: &FluidResult, k: usize) -> [f64; 3] {
|
||||
let g = grid(r);
|
||||
let c = g.centre(g.coords(k));
|
||||
[c.x, c.y, c.z]
|
||||
}
|
||||
|
||||
/// Where a slice lies: its position, or the middle of the domain, held inside it.
|
||||
pub fn slice_position(r: &FluidResult, s: &SliceView) -> f64 {
|
||||
let g = grid(r);
|
||||
let a = s.axis.index();
|
||||
let (lo, hi) = (g.start(a), g.end(a));
|
||||
s.position.filter(|p| p.is_finite()).unwrap_or(0.5 * (lo + hi)).clamp(lo.min(hi), hi.max(lo))
|
||||
}
|
||||
|
||||
/// The seeds streamlines start from: the given ones, or `count` on the inlet face (where the
|
||||
/// flow enters the domain) upstream of the obstacles.
|
||||
pub fn seeds(r: &FluidResult, f: &Frame, s: &StreamlineView) -> Vec<Vec3> {
|
||||
match &s.seeds {
|
||||
Some(points) => points.iter().map(|p| Vec3::from(*p)).collect(),
|
||||
None => {
|
||||
let (axis, toward_high) = direction(r, f);
|
||||
flow::face_seeds(&grid(r), &r.solid, axis.index(), !toward_high, s.count as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The streamlines a view asks for in a frame, and how many seeds they started from.
|
||||
pub fn trace(r: &FluidResult, f: &Frame, s: &StreamlineView) -> (usize, Vec<Streamline>) {
|
||||
let seeds = seeds(r, f, s);
|
||||
(seeds.len(), flow::streamlines(&grid(r), &f.velocity, &r.solid, &seeds, s.max_steps as usize))
|
||||
}
|
||||
|
||||
/// Streamlines traced once and shared: `results.streamlines` traces them for its reply, and the
|
||||
/// plot drawn right after takes them from here instead of tracing them again (at the caps, a
|
||||
/// trace is 2 000 000 points). One entry: the lines of one result, frame and settings, kept
|
||||
/// while that result lives.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TraceCache(Mutex<Option<Traced>>);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Traced {
|
||||
result: Weak<StepResult>,
|
||||
frame: usize,
|
||||
view: StreamlineView,
|
||||
seeds: usize,
|
||||
lines: Arc<Vec<Streamline>>,
|
||||
}
|
||||
|
||||
impl Clone for TraceCache {
|
||||
fn clone(&self) -> Self {
|
||||
TraceCache(Mutex::new(self.lock().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl TraceCache {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Traced>> {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The streamlines `s` asks for in frame `k` of the fluid result `result`, and how many
|
||||
/// seeds they started from: the kept ones when they are those, else traced now and kept.
|
||||
pub fn trace(&self, result: &Arc<StepResult>, k: usize, s: &StreamlineView) -> Result<(usize, Arc<Vec<Streamline>>)> {
|
||||
let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("streamlines are traced in fluid steps".into())) };
|
||||
let f = r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has no frame {k}", r.step)))?;
|
||||
if let Some(t) = self.lock().as_ref()
|
||||
&& t.frame == k
|
||||
&& t.view == *s
|
||||
&& t.result.upgrade().is_some_and(|a| Arc::ptr_eq(&a, result))
|
||||
{
|
||||
return Ok((t.seeds, t.lines.clone()));
|
||||
}
|
||||
let (seeds, lines) = trace(r, f, s);
|
||||
let lines = Arc::new(lines);
|
||||
*self.lock() = Some(Traced { result: Arc::downgrade(result), frame: k, view: s.clone(), seeds, lines: lines.clone() });
|
||||
Ok((seeds, lines))
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell field ready to colour: its values, bands and extremes (with their cells).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CellContour {
|
||||
pub field: Field,
|
||||
pub values: Vec<f64>,
|
||||
pub bands: Bands,
|
||||
pub min: (usize, f64),
|
||||
pub max: (usize, f64),
|
||||
}
|
||||
|
||||
/// A fluid step's display, ready to draw ([`draw`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FlowPlot {
|
||||
/// What it shows: the view's settings, or the result's first look.
|
||||
pub view: FlowView,
|
||||
pub grid: CellGrid,
|
||||
/// The field coloured, when there is one.
|
||||
pub contour: Option<CellContour>,
|
||||
/// The slice (with its normal axis and position), when there is one and a field to colour it.
|
||||
pub slice: Option<(Axis, f64, Slice)>,
|
||||
pub surface: bool,
|
||||
/// Shared with the [`TraceCache`] they came from.
|
||||
pub streamlines: Arc<Vec<Streamline>>,
|
||||
/// The bands streamlines are coloured by speed over: the legend's when it shows velocity;
|
||||
/// `None` draws them in one colour (the legend shows another field).
|
||||
pub speed: Option<Bands>,
|
||||
/// Face-averaged heat values paired with the displayed geometry triangles.
|
||||
pub wall_values: Vec<([Vec3; 3], Option<f64>)>,
|
||||
/// A point on each recorded wall face for the legend's extreme markers.
|
||||
pub wall_positions: Vec<Option<Vec3>>,
|
||||
}
|
||||
|
||||
impl FlowPlot {
|
||||
/// The box around what is drawn of the flow: the domain, with a slice or streamlines.
|
||||
pub fn bounds(&self) -> Aabb3 {
|
||||
if self.slice.is_some() || !self.streamlines.is_empty() { self.grid.bounds() } else { Aabb3::EMPTY }
|
||||
}
|
||||
}
|
||||
|
||||
/// The display of the fluid result `result` the view asks for, its streamlines from `traced`.
|
||||
pub fn plot(view: &ResultsView, result: &Arc<StepResult>, traced: &TraceCache) -> Result<FlowPlot> {
|
||||
let Kind::Fluid(r) = result.kind() else { return Err(EngineError::Params("the flow display shows fluid steps".into())) };
|
||||
let k = frame_index(r, view.pick())?;
|
||||
let f = frame(r, view.pick())?;
|
||||
let fv = FlowView::of(view, r).into_owned();
|
||||
let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure | Field::Temperature)) {
|
||||
Some(field) => {
|
||||
let values = cell_values(r, field, f)?;
|
||||
cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let g = grid(r);
|
||||
let slice = match (&fv.slice, &contour) {
|
||||
(Some(s), Some(c)) => {
|
||||
let at = slice_position(r, s);
|
||||
flow::slice(&g, &c.values, s.axis.index(), at).map(|sl| (s.axis, at, sl))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let streamlines = match &fv.streamlines {
|
||||
Some(s) => traced.trace(result, k, s)?.1,
|
||||
None => Arc::default(),
|
||||
};
|
||||
let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands);
|
||||
Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv, wall_values: Vec::new(), wall_positions: Vec::new() })
|
||||
}
|
||||
|
||||
/// Add the obstacle face mapping shared by the desktop and headless views.
|
||||
pub fn plot_with_surfaces(
|
||||
view: &ResultsView,
|
||||
result: &Arc<StepResult>,
|
||||
traced: &TraceCache,
|
||||
session: &crate::Session,
|
||||
base: &fieldcraft_render::DisplayMesh,
|
||||
bodies: &[&str],
|
||||
) -> Result<FlowPlot> {
|
||||
let mut out = plot(view, result, traced)?;
|
||||
if view.contour == Some(Field::Temperature)
|
||||
&& let Kind::Fluid(r) = result.kind()
|
||||
{
|
||||
// An obstacle face held at a temperature is drawn at it: sampling the fluid beside it
|
||||
// would mix in the cooler cells further out.
|
||||
let held = held_temperatures(session, r, base, bodies);
|
||||
if held.iter().any(Option::is_some) {
|
||||
if let Some(c) = &mut out.contour {
|
||||
// A held face beyond the fluid's range is the extreme: the legend, its Max/Min
|
||||
// lines and the viewport's marker all name it. Its label continues past the
|
||||
// cells, into `wall_positions` (see [`FlowPlot::wall_positions`]).
|
||||
let cells = c.values.len();
|
||||
for (t, v) in base.surface_tris.iter().zip(&held) {
|
||||
let Some(v) = *v else { continue };
|
||||
let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
|
||||
if v > c.max.1 {
|
||||
c.max = (cells + out.wall_positions.len(), v);
|
||||
out.wall_positions.push(Some(at()));
|
||||
} else if v < c.min.1 {
|
||||
c.min = (cells + out.wall_positions.len(), v);
|
||||
out.wall_positions.push(Some(at()));
|
||||
}
|
||||
}
|
||||
c.bands = view.bands(c.min.1, c.max.1);
|
||||
}
|
||||
out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect();
|
||||
}
|
||||
}
|
||||
if view.contour == Some(Field::WallHeatFlux) {
|
||||
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
||||
let faces = wall::faces(session, r, view.pick())?;
|
||||
let positions: std::collections::BTreeMap<_, _> = base
|
||||
.surface_tris
|
||||
.iter()
|
||||
.filter_map(|t| bodies.get(t.body as usize).map(|name| ((*name, t.face), (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0))))
|
||||
.collect();
|
||||
out.wall_positions = faces.iter().map(|f| positions.get(&(f.body.as_str(), f.index)).copied()).collect();
|
||||
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
|
||||
out.contour =
|
||||
cell_range(&values).map(|(min, max)| CellContour { field: Field::WallHeatFlux, bands: view.bands(min.1, max.1), min, max, values });
|
||||
out.wall_values = base.surface_tris.iter().zip(wall::triangle_values(&faces, base, bodies)).map(|(t, v)| (t.p, v)).collect();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's
|
||||
/// domain holds that face at one and the result is of the current model.
|
||||
fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
|
||||
let faces = held_faces(session, r);
|
||||
let held: std::collections::BTreeMap<_, _> = faces.iter().map(|h| ((h.body.as_str(), h.index), h.value)).collect();
|
||||
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
|
||||
}
|
||||
|
||||
/// An obstacle face the step's fluid domain holds at a temperature.
|
||||
pub struct HeldFace {
|
||||
pub body: String,
|
||||
pub face: String,
|
||||
pub index: u32,
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
/// The faces the step's fluid domain holds at a temperature, when the result is of the current
|
||||
/// model (a stale result was solved with other conditions).
|
||||
pub fn held_faces(session: &crate::Session, r: &FluidResult) -> Vec<HeldFace> {
|
||||
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(thermal) = session
|
||||
.model
|
||||
.step(&r.step)
|
||||
.and_then(|st| st.procedure.fluid_domain())
|
||||
.and_then(|d| session.model.fluid_domain(d))
|
||||
.and_then(|d| d.thermal.as_ref())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let design = session.design.as_deref();
|
||||
let mut out = Vec::new();
|
||||
for sb in &thermal.surfaces {
|
||||
let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face));
|
||||
if let Some(index) = index {
|
||||
out.push(HeldFace { body: sb.face.body.clone(), face: face.clone(), index, value: *value });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The colours a flow plot is drawn in.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FlowColors<'a> {
|
||||
/// The contour scale, low to high, and values above a clamped legend's maximum.
|
||||
pub stops: &'a [Rgb],
|
||||
pub over: Rgb,
|
||||
/// Streamlines when the legend shows neither velocity nor temperature.
|
||||
pub plain: Rgb,
|
||||
}
|
||||
|
||||
/// Draw `plot` into `scene`, which holds the model as drawn: its faces beside the fluid
|
||||
/// coloured by the field (when the surface is shown), then the slice and the streamlines.
|
||||
pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
|
||||
if let Some(c) = &plot.contour {
|
||||
let color = |band: Option<u32>| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k));
|
||||
if plot.surface && c.field == Field::WallHeatFlux {
|
||||
// Only tagged geometry triangles can receive wall heat. A structural mesh or
|
||||
// hidden geometry must not accidentally inherit a face's value by index.
|
||||
if scene.tris.len() == plot.wall_values.len() {
|
||||
for (tri, (points, value)) in scene.tris.iter_mut().zip(&plot.wall_values) {
|
||||
if tri.p == *points
|
||||
&& let Some(v) = value
|
||||
{
|
||||
tri.color = color(c.bands.band(*v));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if plot.surface {
|
||||
let model = std::mem::take(&mut scene.tris);
|
||||
// Faces held at a temperature (`wall_values`, matched by position as for wall heat).
|
||||
let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values);
|
||||
for (k, t) in model.into_iter().enumerate() {
|
||||
if let Some((points, Some(v))) = held.and_then(|h| h.get(k))
|
||||
&& t.p == *points
|
||||
{
|
||||
banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris);
|
||||
continue;
|
||||
}
|
||||
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
|
||||
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
|
||||
if known == 0 {
|
||||
scene.tris.push(t);
|
||||
continue;
|
||||
}
|
||||
let mean = sum / f64::from(known);
|
||||
banded(t.p, v.map(|x| if x.is_nan() { mean } else { x }), t.normal, &c.bands, &color, &mut scene.tris);
|
||||
}
|
||||
}
|
||||
if let Some((_, _, s)) = &plot.slice {
|
||||
for t in &s.tris {
|
||||
banded(t.p, t.v, s.normal, &c.bands, &color, &mut scene.tris);
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in plot.streamlines.iter() {
|
||||
for (k, w) in l.points.windows(2).enumerate() {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0));
|
||||
let temperature = plot.contour.as_ref().filter(|c| c.field == Field::Temperature);
|
||||
let color = if let Some(c) = temperature {
|
||||
let value = flow::sample(&plot.grid, &c.values, (a + b) * 0.5);
|
||||
c.bands.band(value).map(|k| bands_color(colors.stops, colors.over, &c.bands, k))
|
||||
} else {
|
||||
plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k)))
|
||||
}
|
||||
.unwrap_or(colors.plain);
|
||||
scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fluid step's display shows, for the `results.*` replies: the field's range with the
|
||||
/// cells (and their centres) of its extremes, the slice and the streamlines' settings.
|
||||
pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::UnitSystem) -> Value {
|
||||
let mut out = json!({});
|
||||
let Ok(f) = frame(r, view.pick()) else { return out };
|
||||
out["time"] = json!(f.time);
|
||||
if let Some(field) = view.contour
|
||||
&& let Ok(values) = cell_values(r, field, f)
|
||||
{
|
||||
let end = |(k, v): (usize, f64)| json!({"cell": k, "at": cell_centre(r, k), "value": v});
|
||||
let range = cell_range(&values).map(|(lo, hi)| json!({"min": end(lo), "max": end(hi)}));
|
||||
let unit = field.quantity().unit(units);
|
||||
out["contour"] = json!({"field": field, "label": field.label(), "unit": unit, "range": range});
|
||||
if view.range_min.is_some() || view.range_max.is_some() {
|
||||
out["contour"]["legend"] = json!({"min": view.range_min, "max": view.range_max});
|
||||
}
|
||||
}
|
||||
let fv = FlowView::of(view, r);
|
||||
out["slice"] = match fv.slice.as_ref().filter(|_| view.contour != Some(Field::WallHeatFlux)) {
|
||||
Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}),
|
||||
None => Value::Null,
|
||||
};
|
||||
out["surface"] = json!(fv.surface);
|
||||
out["streamlines"] = match &fv.streamlines {
|
||||
Some(s) => {
|
||||
let mut v = json!({"count": s.count, "max_steps": s.max_steps});
|
||||
match &s.seeds {
|
||||
Some(points) => v["seeds"] = json!(points.len()),
|
||||
None => {
|
||||
let (axis, high) = direction(r, f);
|
||||
v["inlet"] = json!(format!("{}_{}", axis.id(), if high { "min" } else { "max" }));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
None => Value::Null,
|
||||
};
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use fieldcraft_lbm::{FluidStats, Grid};
|
||||
|
||||
/// A synthetic fluid result: a 10 × 4 × 4 grid of unit cells, a solid 2 × 2 × 2 block at
|
||||
/// x, y, z in [5, 7] × [1, 3] × [1, 3], the fluid moving along +x at the speed of its x and
|
||||
/// the pressure falling along x.
|
||||
pub(crate) fn channel() -> FluidResult {
|
||||
let grid = Grid { origin: [0.0; 3], spacing: 1.0, cells: [10, 4, 4] };
|
||||
let n = grid.len();
|
||||
let solid: Vec<bool> = (0..n).map(|c| matches!(grid.coords(c), [5 | 6, 1 | 2, 1 | 2])).collect();
|
||||
let velocity: Vec<[f32; 3]> = (0..n).map(|c| if solid[c] { [0.0; 3] } else { [grid.coords(c)[0] as f32 + 1.0, 0.0, 0.0] }).collect();
|
||||
let pressure: Vec<f32> = (0..n).map(|c| if solid[c] { 0.0 } else { 10.0 - grid.coords(c)[0] as f32 }).collect();
|
||||
let at_rest = Frame { time: 0.0, step: 0, velocity: vec![[0.0; 3]; n], pressure: vec![0.0; n], temperature: Vec::new(), mass: 1.0 };
|
||||
let last = Frame { time: 1.0, step: 10, velocity, pressure, temperature: Vec::new(), mass: 1.0 };
|
||||
let stats = FluidStats {
|
||||
cells: n,
|
||||
fluid_cells: n - 8,
|
||||
steps: 10,
|
||||
time_step: 0.1,
|
||||
end_time: 1.0,
|
||||
tau_plus: 1.0,
|
||||
tau_minus: 1.0,
|
||||
magic: 0.25,
|
||||
velocity: 1.0,
|
||||
lattice_velocity: 0.1,
|
||||
mach: 0.1,
|
||||
cell_updates: 0,
|
||||
backend: "cpu".into(),
|
||||
tau_thermal: None,
|
||||
};
|
||||
FluidResult {
|
||||
step: "Flow".into(),
|
||||
grid,
|
||||
solid,
|
||||
frames: vec![at_rest, last],
|
||||
history_times: Vec::new(),
|
||||
forces: Vec::new(),
|
||||
heat: Vec::new(),
|
||||
stats,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 × 30 × 10 mm plate (the geometry sample) in a tank 20 mm larger on every side, of
|
||||
/// 5 mm cells, a liquid flowing along x past it: solved, so its result is shown.
|
||||
pub(crate) fn plate_in_a_tank() -> crate::Session {
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-flow-view-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plate.solidcraft");
|
||||
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let script = json!([
|
||||
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
|
||||
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
|
||||
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
|
||||
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s.execute("solve.fluid", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A face held at a temperature is drawn at that temperature, and the legend reaches it,
|
||||
/// rather than at the mean of the fluid cells around it (which mixes in the cooler ones
|
||||
/// further out: an 80 °C bracket looked 50 °C).
|
||||
#[test]
|
||||
fn a_face_held_at_a_temperature_is_drawn_at_it() {
|
||||
let mut s = plate_in_a_tank();
|
||||
let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone();
|
||||
s.execute(
|
||||
"model.material.edit",
|
||||
&json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"model.fluid_domain.edit",
|
||||
&json!({"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
|
||||
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}},
|
||||
"thermal": {"initial_temperature": 20}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"model.fluid_domain.thermal_boundary",
|
||||
&json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("solve.fluid", &json!({})).unwrap();
|
||||
s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap();
|
||||
let (result, current) = s.latest_result("Flow").unwrap();
|
||||
assert!(current);
|
||||
let view = s.results_view.clone().unwrap();
|
||||
let base = crate::view::display(&s).0;
|
||||
let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap();
|
||||
let c = fp.contour.as_ref().unwrap();
|
||||
// The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the
|
||||
// held face (a label past the cells, at a point on that face).
|
||||
assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0));
|
||||
assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands);
|
||||
let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten());
|
||||
assert!(at.is_some(), "{:?}", c.max);
|
||||
let held: Vec<f64> = fp.wall_values.iter().filter_map(|(_, v)| *v).collect();
|
||||
assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}");
|
||||
// A script reads the same maximum as the app shows: the held face.
|
||||
let reply = s.results_report();
|
||||
assert_eq!(reply["contour"]["range"]["max"], json!({"body": "Body1", "face": face, "value": 80.0}), "{reply}");
|
||||
// The headless picture draws the held face in the top band's colour.
|
||||
let p = crate::view::Palette::LIGHT;
|
||||
let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap());
|
||||
let (scene, ..) = crate::view::scene(&s, &p);
|
||||
assert!(scene.tris.iter().any(|t| t.color == top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fluid_result_is_first_shown_across_the_flow() {
|
||||
let r = channel();
|
||||
assert_eq!(direction(&r, &r.frames[1]), (Axis::X, true));
|
||||
let v = FlowView::for_result(&r);
|
||||
assert_eq!(v.slice, Some(SliceView { axis: Axis::Y, position: None }));
|
||||
assert!(v.surface && v.streamlines.is_some());
|
||||
assert_eq!(slice_position(&r, v.slice.as_ref().unwrap()), 2.0);
|
||||
assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(99.0) }), 4.0);
|
||||
assert_eq!(slice_position(&r, &SliceView { axis: Axis::Z, position: Some(f64::NAN) }), 2.0);
|
||||
// A flow along -y is sliced normal to x and seeded on the y_max face.
|
||||
let mut r = channel();
|
||||
for v in &mut r.frames[1].velocity {
|
||||
*v = [0.0, -v[0], 0.0];
|
||||
}
|
||||
assert_eq!(direction(&r, &r.frames[1]), (Axis::Y, false));
|
||||
assert_eq!(FlowView::for_result(&r).slice.unwrap().axis, Axis::X);
|
||||
let s = seeds(&r, &r.frames[1], &StreamlineView { count: 4, ..StreamlineView::default() });
|
||||
assert!(s.iter().all(|p| p.y == 3.5), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_colours_cells_and_streamlines_and_masks_solids() {
|
||||
let mut r = channel();
|
||||
for f in &mut r.frames {
|
||||
f.temperature = (0..r.grid.len()).map(|k| 20.0 + 2.0 * r.grid.coords(k)[0] as f32).collect();
|
||||
}
|
||||
let values = cell_values(&r, Field::Temperature, &r.frames[1]).unwrap();
|
||||
assert_eq!(values.iter().filter(|v| v.is_nan()).count(), 8);
|
||||
assert_eq!(cell_range(&values).map(|(a, b)| (a.1, b.1)), Some((20.0, 38.0)));
|
||||
let result = Arc::new(crate::StepResult::Fluid(r));
|
||||
let mut view = ResultsView::for_result(&result);
|
||||
view.contour = Some(Field::Temperature);
|
||||
let p = plot(&view, &result, &TraceCache::default()).unwrap();
|
||||
let mut scene = Scene::default();
|
||||
let colors = FlowColors { stops: &[Rgb(0, 0, 255), Rgb(255, 0, 0)], over: Rgb(255, 255, 255), plain: Rgb(7, 7, 7) };
|
||||
draw(&p, &mut scene, &colors);
|
||||
assert!(!scene.tris.is_empty());
|
||||
assert!(!scene.lines.is_empty());
|
||||
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
|
||||
assert!(scene.lines.windows(2).any(|l| l[0].color != l[1].color));
|
||||
let cold = channel();
|
||||
assert!(cell_values(&cold, Field::Temperature, &cold.frames[1]).unwrap_err().to_string().contains("isothermal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_fields_leave_the_solid_out() {
|
||||
let r = channel();
|
||||
let f = &r.frames[1];
|
||||
let speed = cell_values(&r, Field::Velocity, f).unwrap();
|
||||
assert_eq!(speed.iter().filter(|v| v.is_nan()).count(), 8);
|
||||
let ((lo, a), (hi, b)) = cell_range(&speed).unwrap();
|
||||
assert_eq!((a, b, r.grid.coords(lo)[0], r.grid.coords(hi)[0]), (1.0, 10.0, 0, 9));
|
||||
assert_eq!(cell_centre(&r, hi), [9.5, 0.5, 0.5]);
|
||||
let p = cell_values(&r, Field::Pressure, f).unwrap();
|
||||
assert_eq!(cell_range(&p).map(|(lo, hi)| (lo.1, hi.1)), Some((1.0, 10.0)));
|
||||
assert!(cell_values(&r, Field::VonMises, f).is_err());
|
||||
assert_eq!(cell_range(&[f64::NAN]), None);
|
||||
assert!(frame(&r, Pick { mode: 1, frame: Some(2) }).is_err());
|
||||
assert_eq!(frame(&r, Pick::default()).unwrap().time, 1.0);
|
||||
}
|
||||
|
||||
/// A trace is kept for its result, frame and settings: the reply and the plot share one;
|
||||
/// another frame, other settings or another result trace anew.
|
||||
#[test]
|
||||
fn a_trace_is_shared_until_its_result_frame_or_settings_change() {
|
||||
let result = Arc::new(crate::StepResult::Fluid(channel()));
|
||||
let view = ResultsView::for_result(&result);
|
||||
let traced = TraceCache::default();
|
||||
let s = StreamlineView::default();
|
||||
let (seeds, a) = traced.trace(&result, 1, &s).unwrap();
|
||||
assert!(seeds > 0 && !a.is_empty());
|
||||
let (_, b) = traced.trace(&result, 1, &s).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b), "the same trace again");
|
||||
let p = plot(&view, &result, &traced).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &p.streamlines), "the plot draws the kept trace");
|
||||
let (_, c) = traced.trace(&result, 0, &s).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &c), "another frame: another trace");
|
||||
let (_, d) = traced.trace(&result, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &d) && d.len() < a.len(), "other settings: another trace ({} lines, then {})", a.len(), d.len());
|
||||
let other = Arc::new(crate::StepResult::Fluid(channel()));
|
||||
let (_, e) = traced.trace(&other, 1, &StreamlineView { count: 3, ..s.clone() }).unwrap();
|
||||
assert!(!Arc::ptr_eq(&d, &e), "another result");
|
||||
assert!(traced.trace(&result, 2, &s).is_err(), "no frame 2");
|
||||
let cloned = traced.clone();
|
||||
assert!(Arc::ptr_eq(&cloned.trace(&other, 1, &StreamlineView { count: 3, ..s }).unwrap().1, &e), "a clone keeps the trace");
|
||||
let solid = Arc::new(crate::StepResult::Fluid(channel()));
|
||||
let mut v = view.clone();
|
||||
v.step = "Other".into();
|
||||
assert!(plot(&v, &solid, &traced).is_ok());
|
||||
}
|
||||
|
||||
/// The plot colours the slice, the faces beside the fluid and the streamlines by speed.
|
||||
#[test]
|
||||
fn the_plot_draws_the_slice_surface_and_streamlines() {
|
||||
let r = channel();
|
||||
let result = Arc::new(crate::StepResult::Fluid(r.clone()));
|
||||
let mut view = ResultsView::for_result(&result);
|
||||
assert_eq!(view.contour, Some(Field::Velocity));
|
||||
let traced = TraceCache::default();
|
||||
let p = plot(&view, &result, &traced).unwrap();
|
||||
let (axis, at, slice) = p.slice.as_ref().unwrap();
|
||||
assert_eq!((*axis, *at), (Axis::Y, 2.0));
|
||||
assert!(!slice.tris.is_empty());
|
||||
assert!(!p.streamlines.is_empty() && p.speed.is_some());
|
||||
assert_eq!(p.bounds(), p.grid.bounds());
|
||||
// A face of the block, at x = 5, and one far off the grid.
|
||||
let tri = |x: f64| fieldcraft_render::SceneTri {
|
||||
p: [Vec3::new(x, 1.0, 1.0), Vec3::new(x, 3.0, 1.0), Vec3::new(x, 1.0, 3.0)],
|
||||
normal: Vec3::X,
|
||||
color: Rgb(1, 2, 3),
|
||||
};
|
||||
let mut scene = Scene { tris: vec![tri(5.0), tri(500.0)], ..Scene::default() };
|
||||
let stops = [Rgb(0, 0, 255), Rgb(255, 0, 0)];
|
||||
let colors = FlowColors { stops: &stops, over: Rgb(9, 9, 9), plain: Rgb(7, 7, 7) };
|
||||
draw(&p, &mut scene, &colors);
|
||||
assert!(scene.tris.iter().any(|t| t.color == Rgb(1, 2, 3)), "the face off the grid keeps its colour");
|
||||
let coloured = scene.tris.iter().filter(|t| t.color != Rgb(1, 2, 3)).count();
|
||||
assert!(coloured > slice.tris.len(), "the slice and the block's face are coloured");
|
||||
assert_eq!(scene.lines.len(), p.streamlines.iter().map(|l| l.points.len() - 1).sum::<usize>());
|
||||
assert!(scene.lines.iter().all(|l| l.color != colors.plain));
|
||||
// Pressure: streamlines in the plain colour; no field: no slice, no colours.
|
||||
view.contour = Some(Field::Pressure);
|
||||
let p = plot(&view, &result, &traced).unwrap();
|
||||
let mut scene = Scene::default();
|
||||
draw(&p, &mut scene, &colors);
|
||||
assert!(scene.lines.iter().all(|l| l.color == colors.plain));
|
||||
view.contour = None;
|
||||
let p = plot(&view, &result, &traced).unwrap();
|
||||
assert!(p.slice.is_none() && p.contour.is_none() && !p.streamlines.is_empty());
|
||||
view.flow = Some(FlowView { slice: None, surface: false, streamlines: None });
|
||||
view.contour = Some(Field::Velocity);
|
||||
let p = plot(&view, &result, &traced).unwrap();
|
||||
assert!(p.slice.is_none() && p.streamlines.is_empty() && p.bounds().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Face-averaged heat received by obstacle walls, in the model's consistent units.
|
||||
//! The history is interpolated only between recorded samples: frame zero predates it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_lbm::FluidResult;
|
||||
use fieldcraft_render::{DisplayMesh, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::results::Pick;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct FaceHeat {
|
||||
pub body: String,
|
||||
pub face: String,
|
||||
pub area: f64,
|
||||
/// Positive into the wall. None before the first recorded history sample.
|
||||
pub power: Option<f64>,
|
||||
pub flux: Option<f64>,
|
||||
#[serde(skip)]
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
fn interpolate(times: &[f64], values: &[f64], time: f64) -> Option<f64> {
|
||||
if !time.is_finite()
|
||||
|| times.len() != values.len()
|
||||
|| times.is_empty()
|
||||
|| times.iter().chain(values).any(|v| !v.is_finite())
|
||||
|| times.windows(2).any(|p| p[0] >= p[1])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let k = times.partition_point(|t| *t < time);
|
||||
if times.get(k) == Some(&time) {
|
||||
return values.get(k).copied();
|
||||
}
|
||||
let lo = k.checked_sub(1)?;
|
||||
let (a, b) = (*times.get(lo)?, *times.get(k)?);
|
||||
let weight = (time - a) / (b - a);
|
||||
let value = (1.0 - weight) * values.get(lo)? + weight * values.get(k)?;
|
||||
value.is_finite().then_some(value)
|
||||
}
|
||||
|
||||
/// All recorded obstacle faces, using their complete tessellated geometric area (not the
|
||||
/// visible triangle area or a lattice-link count). Missing geometry is an error, never zero.
|
||||
pub fn faces(s: &Session, r: &FluidResult, pick: Pick) -> Result<Vec<FaceHeat>> {
|
||||
if s.latest_result(&r.step).is_some_and(|(_, current)| !current) {
|
||||
return Err(EngineError::Params("wall heat requires current geometry: solve the edited model again before dividing by face area".into()));
|
||||
}
|
||||
let time = super::frame(r, pick)?.time;
|
||||
let mut areas = BTreeMap::new();
|
||||
let mut output = Vec::new();
|
||||
for h in r.heat.iter().filter(|h| h.body.is_some()) {
|
||||
let Some(name) = h.body.as_deref() else { continue };
|
||||
let body = s
|
||||
.design
|
||||
.iter()
|
||||
.flat_map(|d| &d.bodies)
|
||||
.find(|b| b.name == name)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: obstacle `{name}` is no longer in the design")))?;
|
||||
let index = body
|
||||
.face_index(&h.name)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` is no longer in the design", h.name)))?;
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = areas.entry(name.to_owned()) {
|
||||
let mesh = body.tessellate((body.size()? * 0.001).max(1e-4))?;
|
||||
let mut by_face = BTreeMap::<u32, f64>::new();
|
||||
for (tri, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let point = |k: u32| mesh.positions.get(k as usize).copied().map(Vec3::from);
|
||||
let (Some(a), Some(b), Some(c)) = (point(tri[0]), point(tri[1]), point(tri[2])) else { continue };
|
||||
*by_face.entry(*face).or_default() += 0.5 * (b - a).cross(c - a).len();
|
||||
}
|
||||
entry.insert(by_face);
|
||||
}
|
||||
let area = areas
|
||||
.get(name)
|
||||
.and_then(|a| a.get(&index))
|
||||
.copied()
|
||||
.filter(|a| a.is_finite() && *a > 0.0)
|
||||
.ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` has no finite positive area", h.name)))?;
|
||||
let power = interpolate(&r.history_times, &h.power, time);
|
||||
let flux = power.map(|q| q / area).filter(|q| q.is_finite());
|
||||
output.push(FaceHeat { body: name.into(), face: h.name.clone(), index, area, power, flux });
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// One optional value per displayed surface triangle. Imported/unrecorded faces remain plain.
|
||||
pub fn triangle_values(faces: &[FaceHeat], base: &DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
|
||||
let by_face: BTreeMap<_, _> = faces.iter().map(|f| ((f.body.as_str(), f.index), f.flux)).collect();
|
||||
base.surface_tris
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let body = bodies.get(t.body as usize)?;
|
||||
by_face.get(&(*body, t.face)).copied().flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn wall_heat_uses_face_identity_area_sign_and_frame_in_both_views() {
|
||||
let mut session = crate::flow::tests::plate_in_a_tank();
|
||||
let crate::StepResult::Fluid(mut r) = session.latest_result("Flow").unwrap().0.as_ref().clone() else { panic!() };
|
||||
let names: Vec<String> = session.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
|
||||
let end = r.frames.last().unwrap().time;
|
||||
r.history_times = vec![end * 0.5, end];
|
||||
r.heat = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, name)| fieldcraft_lbm::HeatHistory {
|
||||
name: name.clone(),
|
||||
body: Some("Body1".into()),
|
||||
power: vec![0.0, if k == 0 { -120.0 } else { 0.0 }],
|
||||
})
|
||||
.collect();
|
||||
for f in &mut r.frames {
|
||||
f.temperature = vec![20.0; r.grid.len()];
|
||||
}
|
||||
let face_values = faces(&session, &r, Pick::default()).unwrap();
|
||||
assert!(face_values.iter().all(|f| f.area > 0.0));
|
||||
assert!(face_values.iter().any(|f| (f.area - 400.0).abs() < 1e-8), "40 by 10 mm outer face");
|
||||
assert!(face_values.iter().any(|f| (f.area - 300.0).abs() < 1e-8), "30 by 10 mm outer face");
|
||||
assert!(face_values[0].flux.unwrap() < 0.0);
|
||||
assert!((face_values.iter().map(|f| f.flux.unwrap() * f.area).sum::<f64>() + 120.0).abs() < 1e-10);
|
||||
assert!(face_values[1..].iter().all(|f| f.flux == Some(0.0)), "adiabatic faces are zero");
|
||||
session.store_result(crate::StepResult::Fluid(r));
|
||||
let report = session.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||
assert_eq!(report["wall_heat"]["positive"], "into wall");
|
||||
assert_eq!(report["wall_heat"]["faces"][0]["power"], -120.0);
|
||||
assert!(report["slice"].is_null(), "wall heat is a surface-only field");
|
||||
session.execute("results.streamlines", &json!({"show": false})).unwrap();
|
||||
let (scene, _, shown) = crate::view::scene(&session, &crate::view::Palette::LIGHT);
|
||||
assert!(shown.contour.as_ref().is_some_and(|c| c.0 == "wall_heat_flux"));
|
||||
assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color));
|
||||
let report = session.execute("results.frame", &json!({"frame": 0})).unwrap();
|
||||
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().all(|f| f["flux"].is_null()));
|
||||
assert!(session.execute("results.slice", &json!({"field": "wall_heat_flux"})).is_err());
|
||||
assert!(session.execute("results.streamlines", &json!({"field": "wall_heat_flux"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histories_interpolate_without_fabricating_initial_heat() {
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 2.0), Some(2.0));
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 1.0), Some(-4.0));
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 0.0), None);
|
||||
assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 4.0), None);
|
||||
assert_eq!(interpolate(&[1.0, 1.0], &[0.0, 1.0], 1.0), None);
|
||||
assert_eq!(interpolate(&[1.0], &[f64::NAN], 1.0), None);
|
||||
assert_eq!(interpolate(&[1.0], &[], 1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ pub mod bug_report;
|
||||
pub mod catalogue;
|
||||
pub mod cmd;
|
||||
pub mod crash;
|
||||
pub mod flow;
|
||||
pub mod logfile;
|
||||
|
||||
pub use cmd::analytic_surfaces;
|
||||
@@ -87,7 +86,6 @@ impl EngineError {
|
||||
matches!(
|
||||
self,
|
||||
EngineError::Model(Cancelled)
|
||||
| EngineError::Geometry(fieldcraft_geometry::Error::Model(Cancelled))
|
||||
| EngineError::Mesh(fieldcraft_mesh::Error::Model(Cancelled))
|
||||
| EngineError::Solver(fieldcraft_solver::Error::Model(Cancelled))
|
||||
| EngineError::Explicit(fieldcraft_explicit::Error::Model(Cancelled))
|
||||
@@ -300,9 +298,6 @@ pub struct Session {
|
||||
/// What the last `geometry.check` found, drawn as markers while the design it checked is
|
||||
/// open ([`view::geometry_marks`]). Not an edit: neither journalled, saved nor undone.
|
||||
pub geometry_marks: Option<Arc<view::GeometryMarks>>,
|
||||
/// The streamlines last traced for a fluid step's display ([`flow::TraceCache`]): the
|
||||
/// `results.streamlines` reply and the plot share them. Not an edit either.
|
||||
pub traced: flow::TraceCache,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
@@ -38,12 +38,8 @@ pub enum Requirement {
|
||||
Stitch,
|
||||
/// A face split.
|
||||
FaceSplit,
|
||||
/// A defeature.
|
||||
Defeature,
|
||||
/// An edge seed.
|
||||
Seed,
|
||||
/// A size control.
|
||||
SizeControl,
|
||||
/// At least one mesh node.
|
||||
Nodes,
|
||||
/// At least one element.
|
||||
@@ -92,9 +88,7 @@ impl Requirement {
|
||||
Requirement::FaceMerge => !m.face_merges().is_empty(),
|
||||
Requirement::Stitch => !m.stitches().is_empty(),
|
||||
Requirement::FaceSplit => !m.face_splits().is_empty(),
|
||||
Requirement::Defeature => !m.defeatures().is_empty(),
|
||||
Requirement::Seed => !m.seeds().is_empty(),
|
||||
Requirement::SizeControl => !m.size_controls().is_empty(),
|
||||
Requirement::Nodes => !m.nodes().is_empty(),
|
||||
Requirement::Mesh => has_elements,
|
||||
Requirement::Analysable => {
|
||||
@@ -137,9 +131,7 @@ impl Requirement {
|
||||
Requirement::FaceMerge => "a face merge",
|
||||
Requirement::Stitch => "a stitch",
|
||||
Requirement::FaceSplit => "a face split",
|
||||
Requirement::Defeature => "a defeature",
|
||||
Requirement::Seed => "an edge seed",
|
||||
Requirement::SizeControl => "a local size",
|
||||
Requirement::Nodes => "mesh nodes",
|
||||
Requirement::Mesh | Requirement::Analysable => "a mesh",
|
||||
Requirement::ElementMaterial | Requirement::Material => "a material",
|
||||
@@ -172,9 +164,7 @@ impl Requirement {
|
||||
Requirement::FaceMerge => "geometry.faces.merge",
|
||||
Requirement::Stitch => "geometry.stitch",
|
||||
Requirement::FaceSplit => "geometry.face.split",
|
||||
Requirement::Defeature => "geometry.defeature",
|
||||
Requirement::Seed => "mesh.seed.edge",
|
||||
Requirement::SizeControl => "mesh.size.set",
|
||||
Requirement::Nodes => "mesh.node.add",
|
||||
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
|
||||
Requirement::ElementMaterial | Requirement::Material => "model.material.create",
|
||||
@@ -234,8 +224,6 @@ impl crate::schema::Collection {
|
||||
C::FaceMerge => Requirement::FaceMerge,
|
||||
C::Stitch => Requirement::Stitch,
|
||||
C::FaceSplit => Requirement::FaceSplit,
|
||||
C::SizeControl => Requirement::SizeControl,
|
||||
C::Defeature => Requirement::Defeature,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +367,6 @@ mod tests {
|
||||
C::FaceMerge,
|
||||
C::Stitch,
|
||||
C::FaceSplit,
|
||||
C::Defeature,
|
||||
];
|
||||
let empty = Session::default();
|
||||
for c in all {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! What the results display shows ([`ResultsView`], set by the `results.*` commands) and the
|
||||
//! nodal values it draws: displacements, mode shapes, explicit field frames and stress fields,
|
||||
//! read from a solved step's [`StepResult`]; a fluid step's cell fields are drawn by
|
||||
//! [`crate::flow`]. An explicit frame's one-point element stresses are
|
||||
//! read from a solved step's [`StepResult`]. An explicit frame's one-point element stresses are
|
||||
//! averaged to the nodes (unweighted, like the implicit solver's nodal stresses).
|
||||
//!
|
||||
//! The view is session state, like the camera: changing it is not an edit, so it is neither
|
||||
@@ -22,7 +21,7 @@ pub enum Kind<'a> {
|
||||
Static(&'a StaticResult),
|
||||
Frequency(&'a FrequencyResult),
|
||||
Explicit(&'a ExplicitResult),
|
||||
/// Per cell, not per node: drawn by [`crate::flow`].
|
||||
/// Per cell, not per node: no nodal fields to display yet.
|
||||
Fluid(&'a fieldcraft_lbm::FluidResult),
|
||||
Heat(&'a HeatResult),
|
||||
}
|
||||
@@ -73,10 +72,8 @@ pub enum Field {
|
||||
DisplacementX,
|
||||
DisplacementY,
|
||||
DisplacementZ,
|
||||
/// Velocity magnitude (explicit steps; per cell in fluid steps).
|
||||
/// Velocity magnitude (explicit steps).
|
||||
Velocity,
|
||||
/// Gauge pressure (per cell, fluid steps).
|
||||
Pressure,
|
||||
VonMises,
|
||||
MaxPrincipal,
|
||||
MidPrincipal,
|
||||
@@ -91,8 +88,6 @@ pub enum Field {
|
||||
Temperature,
|
||||
/// Heat flux magnitude (heat transfer steps, at the end of the step).
|
||||
HeatFlux,
|
||||
/// Signed fluid heat received by an obstacle face per geometric area.
|
||||
WallHeatFlux,
|
||||
/// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary
|
||||
/// surfaces).
|
||||
ContactPressure,
|
||||
@@ -108,13 +103,12 @@ pub enum Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub const ALL: [Field; 24] = [
|
||||
pub const ALL: [Field; 22] = [
|
||||
Field::Displacement,
|
||||
Field::DisplacementX,
|
||||
Field::DisplacementY,
|
||||
Field::DisplacementZ,
|
||||
Field::Velocity,
|
||||
Field::Pressure,
|
||||
Field::VonMises,
|
||||
Field::MaxPrincipal,
|
||||
Field::MidPrincipal,
|
||||
@@ -127,7 +121,6 @@ impl Field {
|
||||
Field::StressYz,
|
||||
Field::Temperature,
|
||||
Field::HeatFlux,
|
||||
Field::WallHeatFlux,
|
||||
Field::ContactPressure,
|
||||
Field::ContactGap,
|
||||
Field::ContactStatus,
|
||||
@@ -143,7 +136,6 @@ impl Field {
|
||||
Field::DisplacementY => "displacement_y",
|
||||
Field::DisplacementZ => "displacement_z",
|
||||
Field::Velocity => "velocity",
|
||||
Field::Pressure => "pressure",
|
||||
Field::VonMises => "von_mises",
|
||||
Field::MaxPrincipal => "max_principal",
|
||||
Field::MidPrincipal => "mid_principal",
|
||||
@@ -156,7 +148,6 @@ impl Field {
|
||||
Field::StressYz => "stress_yz",
|
||||
Field::Temperature => "temperature",
|
||||
Field::HeatFlux => "heat_flux",
|
||||
Field::WallHeatFlux => "wall_heat_flux",
|
||||
Field::ContactPressure => "contact_pressure",
|
||||
Field::ContactGap => "contact_gap",
|
||||
Field::ContactStatus => "contact_status",
|
||||
@@ -172,7 +163,6 @@ impl Field {
|
||||
Field::DisplacementY => "Displacement Y",
|
||||
Field::DisplacementZ => "Displacement Z",
|
||||
Field::Velocity => "Velocity magnitude",
|
||||
Field::Pressure => "Pressure (gauge)",
|
||||
Field::VonMises => "Von Mises stress",
|
||||
Field::MaxPrincipal => "Max. principal stress",
|
||||
Field::MidPrincipal => "Mid. principal stress",
|
||||
@@ -185,7 +175,6 @@ impl Field {
|
||||
Field::StressYz => "Stress YZ",
|
||||
Field::Temperature => "Temperature",
|
||||
Field::HeatFlux => "Heat flux magnitude",
|
||||
Field::WallHeatFlux => "Wall heat flux (into wall)",
|
||||
Field::ContactPressure => "Contact pressure",
|
||||
Field::ContactGap => "Contact gap",
|
||||
Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)",
|
||||
@@ -199,9 +188,8 @@ impl Field {
|
||||
match self {
|
||||
Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length,
|
||||
Field::Velocity => Quantity::Velocity,
|
||||
Field::Pressure => Quantity::Stress,
|
||||
Field::Temperature => Quantity::Temperature,
|
||||
Field::HeatFlux | Field::WallHeatFlux => Quantity::HeatFlux,
|
||||
Field::HeatFlux => Quantity::HeatFlux,
|
||||
Field::ContactGap | Field::ContactStatus => Quantity::None,
|
||||
Field::ContactPressure
|
||||
| Field::ContactShear
|
||||
@@ -251,10 +239,8 @@ impl Field {
|
||||
| Field::DisplacementY
|
||||
| Field::DisplacementZ
|
||||
| Field::Velocity
|
||||
| Field::Pressure
|
||||
| Field::Temperature
|
||||
| Field::HeatFlux
|
||||
| Field::WallHeatFlux
|
||||
) && !self.is_contact()
|
||||
}
|
||||
|
||||
@@ -278,25 +264,14 @@ impl Field {
|
||||
Kind::Explicit(e) => {
|
||||
e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false))
|
||||
}
|
||||
// Cell fields, drawn by `crate::flow`.
|
||||
Kind::Fluid(f) => {
|
||||
let mut fields = vec![Field::Velocity, Field::Pressure];
|
||||
if !f.frames.is_empty() && f.frames.iter().all(|frame| !frame.temperature.is_empty() && frame.temperature.len() == f.grid.len()) {
|
||||
fields.push(Field::Temperature);
|
||||
if f.heat.iter().any(|h| h.body.is_some()) {
|
||||
fields.push(Field::WallHeatFlux);
|
||||
}
|
||||
}
|
||||
return fields.into_iter();
|
||||
}
|
||||
// Cell fields have their own display (M12 streamlines); no nodal field.
|
||||
Kind::Fluid(_) => return Vec::new().into_iter(),
|
||||
Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::<Vec<_>>().into_iter(),
|
||||
};
|
||||
Field::ALL
|
||||
.into_iter()
|
||||
.filter(move |f| match f {
|
||||
Field::Velocity => velocity,
|
||||
Field::Pressure => false,
|
||||
Field::WallHeatFlux => false,
|
||||
Field::Temperature => temperature,
|
||||
Field::HeatFlux => flux,
|
||||
f if f.is_contact() => contact,
|
||||
@@ -410,9 +385,6 @@ pub struct ResultsView {
|
||||
pub frame: Option<usize>,
|
||||
/// Animate the deformation (a mode swings through ±, a static step grows from 0).
|
||||
pub animate: bool,
|
||||
/// A fluid step's slice, surface colours and streamlines.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow: Option<crate::flow::FlowView>,
|
||||
}
|
||||
|
||||
impl ResultsView {
|
||||
@@ -421,18 +393,11 @@ impl ResultsView {
|
||||
/// shape, at the automatic scale); a frequency step's first mode deformed with displacement
|
||||
/// contours (a mode shape is a deformation); an explicit step at its last frame and a
|
||||
/// nonlinear static step at its last increment, deformed to true scale ([`true_scale`];
|
||||
/// with von Mises contours when they kept stresses); a heat step undeformed; a fluid step's
|
||||
/// velocity magnitude on a slice, the obstacles and streamlines ([`crate::flow::FlowView`]).
|
||||
/// with von Mises contours when they kept stresses); a heat step undeformed.
|
||||
pub fn for_result(r: &StepResult) -> ResultsView {
|
||||
let heat = matches!(r.kind(), Kind::Heat(_));
|
||||
let flow = match r.kind() {
|
||||
Kind::Fluid(f) => Some(crate::flow::FlowView::for_result(f)),
|
||||
_ => None,
|
||||
};
|
||||
let contour = if heat {
|
||||
Field::Temperature
|
||||
} else if flow.is_some() {
|
||||
Field::Velocity
|
||||
} else if Field::available(r).any(|f| f == Field::VonMises) {
|
||||
Field::VonMises
|
||||
} else {
|
||||
@@ -450,7 +415,6 @@ impl ResultsView {
|
||||
mode: 1,
|
||||
frame: None,
|
||||
animate: false,
|
||||
flow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,8 +515,6 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) ->
|
||||
}
|
||||
Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()),
|
||||
},
|
||||
// Per cell (crate::flow::cell_values), not per node.
|
||||
Field::Pressure | Field::WallHeatFlux => Err(unavailable()),
|
||||
Field::Temperature => match r.kind() {
|
||||
Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()),
|
||||
Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()),
|
||||
@@ -717,35 +679,6 @@ impl Session {
|
||||
}
|
||||
Kind::Fluid(f) => {
|
||||
out["frames"] = f.frames.iter().map(|f| f.time).collect();
|
||||
if let Value::Object(flow) = crate::flow::report(v, f, self.model.units()) {
|
||||
for (k, x) in flow {
|
||||
out[k.as_str()] = x;
|
||||
}
|
||||
}
|
||||
// A face held beyond the fluid's range is the extreme, as the legend shows it.
|
||||
if v.contour == Some(Field::Temperature) && out["contour"]["range"].is_object() {
|
||||
for h in crate::flow::held_faces(self, f) {
|
||||
let end = json!({"body": h.body, "face": h.face, "value": h.value});
|
||||
let range = &mut out["contour"]["range"];
|
||||
if range["max"]["value"].as_f64().is_some_and(|m| h.value > m) {
|
||||
range["max"] = end;
|
||||
} else if range["min"]["value"].as_f64().is_some_and(|m| h.value < m) {
|
||||
range["min"] = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.contour == Some(Field::WallHeatFlux)
|
||||
&& let Ok(faces) = crate::flow::wall::faces(self, f, v.pick())
|
||||
{
|
||||
let values: Vec<f64> = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect();
|
||||
let end = |(k, value): (usize, f64)| json!({"body": faces.get(k).map(|f| &f.body), "face": faces.get(k).map(|f| &f.face), "value": value});
|
||||
let range = crate::flow::cell_range(&values).map(|(a, b)| json!({"min": end(a), "max": end(b)}));
|
||||
out["contour"] = json!({"field": Field::WallHeatFlux, "label": Field::WallHeatFlux.label(), "unit": Quantity::HeatFlux.unit(self.model.units()), "range": range});
|
||||
if v.range_min.is_some() || v.range_max.is_some() {
|
||||
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
|
||||
}
|
||||
out["wall_heat"] = json!({"faces": faces, "positive": "into wall", "history": "linear interpolation between recorded samples; no extrapolation", "unrecorded": "uncoloured"});
|
||||
}
|
||||
}
|
||||
Kind::Heat(h) => {
|
||||
out["heat_flows"] = heat_flows(h);
|
||||
@@ -813,7 +746,7 @@ mod tests {
|
||||
}
|
||||
let (modes, _) = s.latest_result("Modes").unwrap();
|
||||
assert_eq!(Field::available(modes).count(), 4);
|
||||
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 10, "all but velocity, pressure, the thermal and the contact fields");
|
||||
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 8, "all but velocity, the thermal and the contact fields");
|
||||
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
|
||||
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
|
||||
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
use fieldcraft_geometry::Shape;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod heated_flow;
|
||||
|
||||
/// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`).
|
||||
pub struct Sample {
|
||||
pub name: &'static str,
|
||||
@@ -94,14 +92,6 @@ pub const SAMPLES: &[Sample] = &[
|
||||
prepare: None,
|
||||
script: |_| flow_past(),
|
||||
},
|
||||
Sample {
|
||||
name: "heated-flow",
|
||||
label: "Heated Flow Past a Bracket",
|
||||
about: "Cool air passing an 80 °C bracket: temperature slices and signed wall heat, a small transient demonstration",
|
||||
design: Some(UPRIGHT_BRACKET),
|
||||
prepare: None,
|
||||
script: heated_flow::script,
|
||||
},
|
||||
Sample {
|
||||
name: "heated-bracket",
|
||||
label: "Heated Bracket",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! A small transient heated-flow demonstration using the original upright bracket.
|
||||
//! Air at 20 °C passes an 80 °C bracket; no buoyancy or conjugate solid conduction.
|
||||
|
||||
use fieldcraft_geometry::Shape;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) fn script(bodies: &[Shape]) -> Value {
|
||||
let walls: Vec<Value> = bodies
|
||||
.iter()
|
||||
.filter(|b| b.name == "Bracket")
|
||||
.flat_map(|b| {
|
||||
b.faces().iter().map(move |f| {
|
||||
json!({
|
||||
"face": {"body": b.name, "face": f.name},
|
||||
"condition": {"type": "temperature", "value": 80.0}
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let [density, viscosity] = super::AIR;
|
||||
json!([
|
||||
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
|
||||
{"command": "model.material.create", "params": {"name": "Air", "density": density,
|
||||
"fluid": {"type": "newtonian", "viscosity": viscosity},
|
||||
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9}}},
|
||||
{"command": "model.fluid_domain.create", "params": {
|
||||
"name": "WarmAir", "material": "Air", "min": [-30, -20, 0], "max": [100, 60, 70], "spacing": 5,
|
||||
"faces": {"x_min": {"type": "velocity", "velocity": [1, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
|
||||
"y_min": {"type": "slip"}, "y_max": {"type": "slip"}, "z_min": {"type": "wall"}, "z_max": {"type": "slip"}},
|
||||
"obstacles": ["Bracket"], "initial_velocity": [1, 0, 0],
|
||||
"thermal": {"initial_temperature": 20, "surfaces": walls,
|
||||
"faces": {"x_min": {"type": "temperature", "value": 20}}}
|
||||
}},
|
||||
{"command": "model.step.create", "params": {"name": "HeatedFlow", "procedure": {
|
||||
"type": "fluid", "domain": "WarmAir", "steps": 180, "frames": 6
|
||||
}}}
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heated_flow_sample_solves_and_exposes_temperature_and_wall_heat() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("file.sample", &json!({"name": "heated-flow"})).unwrap();
|
||||
s.execute("solve.fluid", &json!({"backend": "cpu"})).unwrap();
|
||||
let report = s.execute("results.slice", &json!({"field": "temperature"})).unwrap();
|
||||
let max = report["contour"]["range"]["max"]["value"].as_f64().unwrap();
|
||||
let min = report["contour"]["range"]["min"]["value"].as_f64().unwrap();
|
||||
assert!(max > 20.1 && max <= 80.01 && min >= 19.99, "{report}");
|
||||
let report = s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||
assert_eq!(report["contour"]["unit"], "mW/mm²");
|
||||
assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().any(|f| f["power"].as_f64().unwrap_or(0.0) < 0.0));
|
||||
assert_eq!(report["frames"].as_array().unwrap().len(), 7);
|
||||
}
|
||||
}
|
||||
@@ -223,10 +223,6 @@ pub enum Collection {
|
||||
Stitch,
|
||||
/// A face split (`geometry.face.split`).
|
||||
FaceSplit,
|
||||
/// A size control (`mesh.size.set`).
|
||||
SizeControl,
|
||||
/// A defeature (`geometry.defeature`).
|
||||
Defeature,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
@@ -255,8 +251,6 @@ impl Collection {
|
||||
Collection::FaceMerge => "face merge",
|
||||
Collection::Stitch => "stitch",
|
||||
Collection::FaceSplit => "face split",
|
||||
Collection::SizeControl => "size control",
|
||||
Collection::Defeature => "defeature",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,8 +283,6 @@ impl Session {
|
||||
Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())),
|
||||
Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())),
|
||||
Collection::FaceSplit => Box::new(m.face_splits().iter().map(|f| f.name.as_str())),
|
||||
Collection::SizeControl => Box::new(m.size_controls().keys().map(String::as_str)),
|
||||
Collection::Defeature => Box::new(m.defeatures().iter().map(|f| f.name.as_str())),
|
||||
};
|
||||
names.take(MAX_NAMES).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
+13
-57
@@ -5,20 +5,19 @@
|
||||
//! The picture follows the desktop viewport's conventions: geometry draws as faces with their
|
||||
//! boundaries while the model has no element faces, then as boundaries only; elements in their
|
||||
//! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the
|
||||
//! deformed shape (automatic or chosen scale) with its contour bands, or a fluid step's slice,
|
||||
//! coloured obstacles and streamlines over the undeformed part ([`crate::flow`]). Animation is not drawn: a
|
||||
//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a
|
||||
//! picture shows the full deformation (a mode at its positive shape). The analysis setup's
|
||||
//! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them,
|
||||
//! muted over a results plot (and only those its step used), at their default size on screen
|
||||
//! once the camera is known.
|
||||
|
||||
use fieldcraft_model::ElementKind;
|
||||
use fieldcraft_render::contour::{banded, bands_color};
|
||||
use fieldcraft_render::contour::{bands_color, split_triangle};
|
||||
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, ScenePoint, SceneTri, Vec3};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::results::{Kind, auto_scale, displacements, field_values, range};
|
||||
use crate::results::{auto_scale, displacements, field_values, range};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The colours of a picture.
|
||||
@@ -33,9 +32,6 @@ pub struct Palette {
|
||||
pub shell: Rgb,
|
||||
pub line_element: Rgb,
|
||||
pub mesh_edge: Rgb,
|
||||
/// Streamlines when the legend shows another field than velocity: stands out from the
|
||||
/// background.
|
||||
pub streamline: Rgb,
|
||||
/// Nodes, when they are shown.
|
||||
pub node: Rgb,
|
||||
pub geometry: Rgb,
|
||||
@@ -70,7 +66,6 @@ impl Palette {
|
||||
shell: Rgb(0xc0, 0x8c, 0x78),
|
||||
line_element: Rgb(0xc8, 0x8c, 0x1e),
|
||||
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
|
||||
streamline: Rgb(0x28, 0x2e, 0x38),
|
||||
node: Rgb(0xb4, 0x3c, 0x8c),
|
||||
geometry: Rgb(176, 186, 198),
|
||||
geometry_edge: Rgb(40, 44, 52),
|
||||
@@ -308,8 +303,7 @@ pub struct Shown {
|
||||
pub step: Option<String>,
|
||||
/// The deformation scale factor drawn (0: undeformed).
|
||||
pub scale: f64,
|
||||
/// The contour field drawn, with its smallest and largest value and their nodes (a fluid
|
||||
/// step's: their cells).
|
||||
/// The contour field drawn, with its smallest and largest value and their nodes.
|
||||
pub contour: Option<(String, (u32, f64), (u32, f64))>,
|
||||
/// Bodies that could not be tessellated for display.
|
||||
pub failed: Vec<String>,
|
||||
@@ -506,22 +500,6 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
|
||||
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums, bodies };
|
||||
};
|
||||
shown.step = Some(view.step.clone());
|
||||
let colors = p.glyphs.over_results(p.background.1);
|
||||
if matches!(result.kind(), Kind::Fluid(_)) {
|
||||
// The part as it is, with the flow drawn through and around it.
|
||||
let mut scene = model_scene(&base, p);
|
||||
let mut bounds = base.bounds;
|
||||
let names: Vec<&str> = bodies.iter().map(String::as_str).collect();
|
||||
if let Ok(fp) = crate::flow::plot_with_surfaces(view, result, &s.traced, s, &base, &names) {
|
||||
if let Some(c) = &fp.contour {
|
||||
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
|
||||
shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max)));
|
||||
}
|
||||
crate::flow::draw(&fp, &mut scene, &crate::flow::FlowColors { stops: &p.contour, over: p.contour_over, plain: p.streamline });
|
||||
bounds.union(fp.bounds());
|
||||
}
|
||||
return Picture { scene, drawn: base, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies };
|
||||
}
|
||||
let pick = view.pick();
|
||||
let nodes = result.nodes();
|
||||
let u = displacements(result, pick).unwrap_or(&[]);
|
||||
@@ -541,15 +519,21 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
|
||||
shown.contour = Some((field.id().to_string(), min, max));
|
||||
let bands = view.bands(min.1, max.1);
|
||||
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
|
||||
let color = |band: Option<u32>| band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
|
||||
scene.tris.clear();
|
||||
for tri in &drawn.tris {
|
||||
banded(tri.p, tri.nodes.map(at), tri.normal, &bands, &color, &mut scene.tris);
|
||||
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
|
||||
let color = band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
|
||||
for k in 1..piece.len().saturating_sub(1) {
|
||||
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
|
||||
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut bounds = base.bounds;
|
||||
bounds.union(drawn.bounds);
|
||||
Picture { scene, drawn, bounds, shown, glyphs, colors, datums: Aabb3::EMPTY, bodies }
|
||||
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY, bodies }
|
||||
}
|
||||
|
||||
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
|
||||
@@ -1086,34 +1070,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fluid step draws the part with its faces coloured by the flow beside them, a coloured
|
||||
/// slice through the domain and streamlines (#193: the part vanished and nothing was drawn).
|
||||
#[test]
|
||||
fn a_fluid_step_draws_its_flow_around_the_part() {
|
||||
let mut s = crate::flow::tests::plate_in_a_tank();
|
||||
let p = Palette::LIGHT;
|
||||
let (base, ..) = display(&s);
|
||||
assert!(!base.surface_tris.is_empty());
|
||||
let part: Vec<Rgb> = model_scene(&base, &p).tris.iter().map(|t| t.color).collect();
|
||||
let (scene, bounds, shown) = super::scene(&s, &p);
|
||||
let (field, min, max) = shown.contour.clone().unwrap();
|
||||
assert!(field == "velocity" && max.1 > min.1, "{shown:?}");
|
||||
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
|
||||
assert!(!scene.lines.is_empty(), "streamlines are drawn");
|
||||
let tank = Aabb3 { min: Vec3::new(-20.0, -20.0, -20.0), max: Vec3::new(60.0, 50.0, 30.0) };
|
||||
assert_eq!(bounds, tank, "the picture frames the tank");
|
||||
// The part is coloured by the flow; in its own colours without the surface colours.
|
||||
let in_part_colour = |scene: &Scene| scene.tris.iter().filter(|t| part.contains(&t.color)).count();
|
||||
assert!(in_part_colour(&scene) < base.surface_tris.len());
|
||||
s.execute("results.surface", &json!({"show": false})).unwrap();
|
||||
s.execute("results.slice", &json!({"axis": "none"})).unwrap();
|
||||
s.execute("results.streamlines", &json!({"show": false})).unwrap();
|
||||
let (scene, ..) = super::scene(&s, &p);
|
||||
assert_eq!((in_part_colour(&scene), scene.tris.len()), (base.surface_tris.len(), base.surface_tris.len()), "the part alone");
|
||||
let (png, _) = render_png(&s, &Orientation::of(StandardView::Iso), 160, 120, &p).unwrap();
|
||||
assert!(count_color(&png, p.background.0) < 160 * 120, "the part is in the picture");
|
||||
}
|
||||
|
||||
/// Pixels of colour `c` (within a few levels) in a PNG.
|
||||
fn count_color(png: &[u8], c: Rgb) -> usize {
|
||||
let d = png::Decoder::new(std::io::Cursor::new(png));
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
//! Defeaturing for meshing: holes, fillets, chamfers and small bosses taken out of a design so they
|
||||
//! do not drive the mesh.
|
||||
//!
|
||||
//! A design opened from its timeline knows its features ([`Design::features`]), so a hole is
|
||||
//! removed by rebuilding the design with the hole's feature suppressed, exactly as SolidCraft
|
||||
//! would build it without it: the hole is filled, a fillet or chamfer goes back to its sharp edge,
|
||||
//! a boss is gone. The faces the other features made keep their persistent names, so sets, seeds
|
||||
//! and loads on them still resolve.
|
||||
//!
|
||||
//! A feature that cannot be suppressed on its own (a later feature needs it: a fillet along the
|
||||
//! hole's edge, a pattern of it), or whose suppression would take a body away, is kept in the
|
||||
//! B-rep and defeatured for meshing only instead, as virtual topology
|
||||
//! ([`crate::virtual_faces`]): its faces are merged into the neighbour they share the longest
|
||||
//! edges with, so the meshers see no edges around it (the material stays). Such a feature is
|
||||
//! listed with `virtual` in [`Design::defeat_report`].
|
||||
//!
|
||||
//! Defeatures live in the FieldCraft model by feature id (the `F<id>` of persistent names, stable
|
||||
//! across edits of the design) and are applied whenever the design is opened, before the
|
||||
//! partitions. [`Design::defeatured`] keeps the design as opened, so switching a feature back on
|
||||
//! rebuilds from it.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use solidcraft_doc::FeatureKind;
|
||||
|
||||
use crate::features::FeatureNode;
|
||||
use crate::virtual_faces::Merge;
|
||||
use crate::{Design, Error, FeatureError, Result};
|
||||
|
||||
/// Most features all of a design's defeatures name together.
|
||||
pub const MAX_DEFEATURED: usize = 1024;
|
||||
/// Most rebuilds spent finding which features suppress on their own when suppressing them all
|
||||
/// at once breaks the design; the features past it are defeatured virtually.
|
||||
const MAX_TRIALS: usize = 64;
|
||||
|
||||
/// Features of the design's timeline suppressed for meshing (`geometry.defeature`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Defeature {
|
||||
pub name: String,
|
||||
/// Feature ids (`F<id>`).
|
||||
pub features: Vec<u64>,
|
||||
}
|
||||
|
||||
/// How a defeatured feature was taken out.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Defeated {
|
||||
/// The defeature that names it.
|
||||
pub defeature: String,
|
||||
pub feature: u64,
|
||||
/// `suppressed` (the design rebuilt without it) or `virtual` (its faces merged into a
|
||||
/// neighbour for meshing; the B-rep keeps it).
|
||||
pub method: &'static str,
|
||||
/// Why it could not be suppressed, for a virtual one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// The defeatures applied to a design, and the design as opened.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Applied {
|
||||
/// The design as opened, before any defeature.
|
||||
original: Design,
|
||||
defeatures: Vec<Defeature>,
|
||||
report: Vec<Defeated>,
|
||||
/// The virtual defeatures, as merges applied with the model's own.
|
||||
merges: Vec<Merge>,
|
||||
}
|
||||
|
||||
/// A feature a mesh-prep pass may take out, and its size.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Candidate {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The feature's definition type (`hole`, `extrude`, `fillet`, …).
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
/// `hole`, `fillet`, `chamfer` or `boss`.
|
||||
pub category: &'static str,
|
||||
/// The size compared: a hole's diameter, a fillet's radius, a chamfer's distance, a boss's
|
||||
/// height (mm).
|
||||
pub size: f64,
|
||||
/// The body's faces it made.
|
||||
pub faces: Vec<String>,
|
||||
}
|
||||
|
||||
/// Whether the feature `node` is a hole, fillet, chamfer or boss, and its size.
|
||||
pub fn candidate(node: &FeatureNode) -> Option<Candidate> {
|
||||
let v = |k: &str| node.values.get(k).copied();
|
||||
let op = node.operation.as_deref();
|
||||
// A circular profile's diameter (an extrude of a circle).
|
||||
let circle = || {
|
||||
let p = node.sketch.as_ref()?.profiles.first()?;
|
||||
match p.outer.as_slice() {
|
||||
[crate::Curve::Circle { radius, .. }] if p.holes.is_empty() => Some(2.0 * radius),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let (category, size) = match (node.kind.as_str(), op) {
|
||||
("hole", _) => ("hole", v("diameter")?),
|
||||
("fillet", _) => ("fillet", v("radius")?),
|
||||
("chamfer", _) => ("chamfer", v("distance")?),
|
||||
("cylinder", Some("cut")) => ("hole", 2.0 * v("radius")?),
|
||||
("extrude", Some("cut")) => ("hole", circle()?),
|
||||
("extrude", Some("join")) => ("boss", v("distance")?.abs()),
|
||||
("cylinder" | "box", Some("join")) => ("boss", v("height")?.abs()),
|
||||
_ => return None,
|
||||
};
|
||||
(size.is_finite() && size > 0.0).then(|| Candidate {
|
||||
id: node.id,
|
||||
name: node.name.clone(),
|
||||
kind: node.kind.clone(),
|
||||
category,
|
||||
size,
|
||||
faces: node.faces.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Design {
|
||||
/// The design as opened, before its defeatures.
|
||||
pub fn undefeatured(&self) -> &Design {
|
||||
self.defeat.as_deref().map_or(self, |a| &a.original)
|
||||
}
|
||||
|
||||
/// How each defeatured feature was taken out, in the defeatures' order.
|
||||
pub fn defeat_report(&self) -> &[Defeated] {
|
||||
self.defeat.as_deref().map_or(&[], |a| a.report.as_slice())
|
||||
}
|
||||
|
||||
/// The design with `defeatures` applied, from the design as opened, and the merges that
|
||||
/// defeature its features virtually (apply them with the model's own, [`Design::merged`]).
|
||||
/// A defeature naming a feature the timeline lacks, or one that builds a body or a sketch, is
|
||||
/// listed in [`Design::errors`] (`defeature <name>`) and the rest still apply. A design
|
||||
/// without a timeline defeatures nothing (each is an error).
|
||||
pub fn defeatured(&self, defeatures: &[Defeature]) -> Result<(Design, Vec<Merge>)> {
|
||||
let base = self.undefeatured();
|
||||
if defeatures.iter().all(|d| d.features.is_empty()) {
|
||||
return Ok((base.clone(), Vec::new()));
|
||||
}
|
||||
if let Some(a) = self.defeat.as_deref().filter(|a| a.defeatures == defeatures) {
|
||||
return Ok((self.clone(), a.merges.clone()));
|
||||
}
|
||||
if defeatures.iter().map(|d| d.features.len()).sum::<usize>() > MAX_DEFEATURED {
|
||||
return Err(Error::Invalid(format!("a design takes at most {MAX_DEFEATURED} defeatured features")));
|
||||
}
|
||||
let mut errors = Vec::new();
|
||||
let fail = |d: &Defeature, message: String| FeatureError { feature: format!("defeature {}", d.name), message, bodies: Vec::new() };
|
||||
// The features to take out, each with the first defeature naming it.
|
||||
let mut wanted: BTreeMap<u64, &str> = BTreeMap::new();
|
||||
let mut order = Vec::new();
|
||||
for d in defeatures {
|
||||
for &id in &d.features {
|
||||
let refused = match base.history.as_deref().map(|h| h.doc.feature(id)) {
|
||||
None => Some("the design has no feature timeline".to_string()),
|
||||
Some(None) => Some(format!("the design has no feature F{id}")),
|
||||
Some(Some(f)) if !suppressible(&f.kind) => {
|
||||
Some(format!("F{id} ({}) builds a body or a sketch; it is not a feature to defeature", f.name))
|
||||
}
|
||||
Some(Some(_)) => None,
|
||||
};
|
||||
match refused {
|
||||
Some(m) => errors.push(fail(d, m)),
|
||||
None if !wanted.contains_key(&id) => {
|
||||
wanted.insert(id, &d.name);
|
||||
order.push(id);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress them all at once; when that breaks the design, one at a time, keeping those
|
||||
// that rebuild cleanly.
|
||||
let mut kept: BTreeSet<u64> = order.iter().copied().collect();
|
||||
let mut rebuilt = rebuild_without(base, &kept)?.ok();
|
||||
let mut reasons: BTreeMap<u64, String> = BTreeMap::new();
|
||||
if rebuilt.is_none() {
|
||||
kept.clear();
|
||||
for (k, &id) in order.iter().enumerate() {
|
||||
if k >= MAX_TRIALS {
|
||||
reasons.insert(id, format!("more than {MAX_TRIALS} features to try one at a time"));
|
||||
continue;
|
||||
}
|
||||
let mut next = kept.clone();
|
||||
next.insert(id);
|
||||
match rebuild_without(base, &next)? {
|
||||
Ok(d) => {
|
||||
kept = next;
|
||||
rebuilt = Some(d);
|
||||
}
|
||||
Err(why) => {
|
||||
reasons.insert(id, why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut design = match rebuilt {
|
||||
Some(d) if !kept.is_empty() => d,
|
||||
_ => base.clone(),
|
||||
};
|
||||
// The rest: their faces merged into a neighbour, body by body.
|
||||
let mut merges = Vec::new();
|
||||
let mut report = Vec::new();
|
||||
let graph = if reasons.is_empty() { Vec::new() } else { base.features(None)? };
|
||||
for &id in &order {
|
||||
let defeature = wanted.get(&id).map_or_else(String::new, |n| n.to_string());
|
||||
if kept.contains(&id) {
|
||||
report.push(Defeated { defeature, feature: id, method: "suppressed", reason: None });
|
||||
continue;
|
||||
}
|
||||
for b in &graph {
|
||||
let Some(node) = b.features.iter().find(|f| f.id == id).filter(|f| !f.faces.is_empty()) else { continue };
|
||||
match absorb(base, &b.body, &node.faces) {
|
||||
Ok(Some(into)) => {
|
||||
let mut faces = node.faces.clone();
|
||||
faces.push(into.clone());
|
||||
merges.push(Merge { name: format!("{defeature}-F{id}"), body: b.body.clone(), faces, into: Some(into) });
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => errors.push(FeatureError {
|
||||
feature: format!("defeature {defeature}"),
|
||||
message: format!("F{id} on `{}`: {e}", b.body),
|
||||
bodies: vec![b.body.clone()],
|
||||
}),
|
||||
}
|
||||
}
|
||||
report.push(Defeated { defeature, feature: id, method: "virtual", reason: reasons.get(&id).cloned() });
|
||||
}
|
||||
design.errors.extend(errors);
|
||||
design.defeat = Some(Arc::new(Applied { original: base.clone(), defeatures: defeatures.to_vec(), report, merges: merges.clone() }));
|
||||
Ok((design, merges))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a feature of this kind may be taken out without taking a body or a sketch with it.
|
||||
fn suppressible(kind: &FeatureKind) -> bool {
|
||||
use solidcraft_doc::Operation::NewBody;
|
||||
match kind {
|
||||
FeatureKind::Sketch { .. }
|
||||
| FeatureKind::ConstructionPlane { .. }
|
||||
| FeatureKind::ConstructionAxis { .. }
|
||||
| FeatureKind::ConstructionPoint { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Split { .. } => false,
|
||||
FeatureKind::Extrude { operation, .. }
|
||||
| FeatureKind::Revolve { operation, .. }
|
||||
| FeatureKind::Sweep { operation, .. }
|
||||
| FeatureKind::Loft { operation, .. }
|
||||
| FeatureKind::Box { operation, .. }
|
||||
| FeatureKind::Cylinder { operation, .. }
|
||||
| FeatureKind::Sphere { operation, .. }
|
||||
| FeatureKind::Torus { operation, .. } => *operation != NewBody,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` rebuilt with `ids` suppressed: `Err(why)` when that fails a feature that built in
|
||||
/// `base` or loses one of its bodies.
|
||||
fn rebuild_without(base: &Design, ids: &BTreeSet<u64>) -> Result<std::result::Result<Design, String>> {
|
||||
let history = base.history.as_deref().ok_or_else(|| Error::Invalid("the design has no feature timeline".into()))?;
|
||||
let mut doc = history.doc.clone();
|
||||
for &id in ids {
|
||||
if let Some(f) = doc.feature_mut(id) {
|
||||
f.suppressed = true;
|
||||
}
|
||||
}
|
||||
let design = crate::design::rebuild(&doc)?;
|
||||
let failed: Vec<String> = design
|
||||
.errors
|
||||
.iter()
|
||||
.filter(|e| !base.errors.iter().any(|b| b.feature == e.feature))
|
||||
.map(|e| format!("{} no longer builds: {}", e.feature, e.message))
|
||||
.collect();
|
||||
if !failed.is_empty() {
|
||||
return Ok(Err(failed.join("; ")));
|
||||
}
|
||||
let lost: Vec<&str> = base.source.iter().map(|b| b.name.as_str()).filter(|n| design.source.iter().all(|b| b.name != *n)).collect();
|
||||
if !lost.is_empty() {
|
||||
return Ok(Err(format!("body {} would be gone", lost.join(", "))));
|
||||
}
|
||||
Ok(Ok(design))
|
||||
}
|
||||
|
||||
/// The face of `body` (in `base` as opened) outside `faces` that `faces` share the longest edges
|
||||
/// with; `None` when the body has none of them.
|
||||
fn absorb(base: &Design, body: &str, faces: &[String]) -> Result<Option<String>> {
|
||||
let Some(shape) = base.source_body(body) else { return Ok(None) };
|
||||
for f in faces {
|
||||
if shape.faces_named(f).is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A face of the feature may border only the feature's own faces (the middle of a
|
||||
// fillet chain): the next one finds the outside.
|
||||
let mut probe = shape.clone();
|
||||
let others: Vec<String> = faces.iter().filter(|g| *g != f && !shape.faces_named(g).is_empty()).cloned().collect();
|
||||
if !others.is_empty() {
|
||||
let mut all = vec![f.clone()];
|
||||
all.extend(others);
|
||||
match shape.merge_faces(f, &all) {
|
||||
Ok(m) => probe = m,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
if let Ok(n) = probe.absorbing_neighbour(f) {
|
||||
return Ok(Some(n));
|
||||
}
|
||||
}
|
||||
Err(Error::Invalid("its faces have no neighbour to merge into".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sample::{Plate, plate_design};
|
||||
|
||||
fn plate() -> Design {
|
||||
crate::open_design(&plate_design(&Plate::default()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn hole(d: &Design) -> u64 {
|
||||
d.features(None).unwrap()[0].features.iter().filter_map(candidate).find(|c| c.category == "hole").unwrap().id
|
||||
}
|
||||
|
||||
/// Suppressing the plate's hole fills it (the body is a solid block, its faces keep their
|
||||
/// names); an empty list brings it back.
|
||||
#[test]
|
||||
fn the_plate_without_its_hole_is_a_block() {
|
||||
let d = plate();
|
||||
let id = hole(&d);
|
||||
let c = d.features(None).unwrap()[0].features.iter().filter_map(candidate).next().unwrap();
|
||||
assert_eq!((c.category, c.size), ("hole", Plate::default().hole_diameter));
|
||||
let def = [Defeature { name: "Holes".into(), features: vec![id] }];
|
||||
let (filled, merges) = d.defeatured(&def).unwrap();
|
||||
assert!(merges.is_empty());
|
||||
let p = Plate::default();
|
||||
let v = filled.bodies[0].measure().unwrap().volume;
|
||||
assert!((v - p.width * p.depth * p.thickness).abs() < 1e-6 * v, "{v}");
|
||||
assert_eq!(filled.bodies[0].faces().len(), 6);
|
||||
assert!(filled.bodies[0].faces().iter().all(|f| d.bodies[0].faces().iter().any(|g| g.name == f.name)));
|
||||
assert_eq!(filled.defeat_report(), [Defeated { defeature: "Holes".into(), feature: id, method: "suppressed", reason: None }]);
|
||||
// The feature graph of the defeatured design leaves the hole out; the design as opened keeps it.
|
||||
assert_eq!(filled.features(None).unwrap()[0].features.len(), 1);
|
||||
assert_eq!(filled.undefeatured().features(None).unwrap()[0].features.len(), 2);
|
||||
// Applied again with the same list: the same design, no rebuild; with none: as opened.
|
||||
let (again, _) = filled.defeatured(&def).unwrap();
|
||||
assert_eq!(again.bodies[0].faces().len(), 6);
|
||||
let (back, _) = filled.defeatured(&[]).unwrap();
|
||||
assert_eq!(back.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||||
assert!(back.defeat_report().is_empty());
|
||||
}
|
||||
|
||||
/// A feature the timeline lacks, or the base extrude, is an error of the defeature, not of
|
||||
/// the call.
|
||||
#[test]
|
||||
fn unknown_and_body_features_are_listed_errors() {
|
||||
let d = plate();
|
||||
let base = d.features(None).unwrap()[0].features[0].id;
|
||||
let (out, _) = d.defeatured(&[Defeature { name: "Bad".into(), features: vec![base, 999] }]).unwrap();
|
||||
let errors: Vec<&str> = out.errors.iter().filter(|e| e.feature == "defeature Bad").map(|e| e.message.as_str()).collect();
|
||||
assert_eq!(errors.len(), 2, "{errors:?}");
|
||||
assert!(errors[0].contains("builds a body") && errors[1].contains("no feature F999"), "{errors:?}");
|
||||
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||||
}
|
||||
|
||||
/// The bracket's fillet is suppressed (back to a sharp corner) and listed as one.
|
||||
#[cfg(feature = "scripts")]
|
||||
#[test]
|
||||
fn a_bracket_fillet_goes_back_to_its_sharp_edge() {
|
||||
let script = r#"[
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Profile"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [60, 0], [60, 8], [8, 8], [8, 50], [0, 50]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 40, "body_name": "Bracket"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[8, 8, 20]], "radius": 6}},
|
||||
{"command": "solid.hole", "params": {"position": [40, 8, 20], "diameter": 8}}
|
||||
]"#;
|
||||
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
|
||||
let cands: Vec<Candidate> = d.features(None).unwrap()[0].features.iter().filter_map(candidate).collect();
|
||||
assert_eq!(cands.iter().map(|c| (c.category, c.size)).collect::<Vec<_>>(), [("fillet", 6.0), ("hole", 8.0)]);
|
||||
let fillet = cands[0].id;
|
||||
let (sharp, merges) = d.defeatured(&[Defeature { name: "Small".into(), features: vec![fillet] }]).unwrap();
|
||||
assert!(merges.is_empty());
|
||||
let (before, after) = (d.bodies[0].measure().unwrap().volume, sharp.bodies[0].measure().unwrap().volume);
|
||||
// The fillet's material comes out: (1 − π/4) r² × 40 in the inside corner.
|
||||
let fill = (1.0 - std::f64::consts::FRAC_PI_4) * 36.0 * 40.0;
|
||||
assert!((before - after - fill).abs() < 1e-3 * fill, "{before} {after}");
|
||||
assert!(sharp.bodies[0].faces().iter().all(|f| !f.name.starts_with(&format!("F{fillet}:"))));
|
||||
}
|
||||
|
||||
/// A hole whose rim a later fillet rounds cannot be suppressed alone (the fillet would lose
|
||||
/// its edge): it is defeatured virtually, its faces merged into a neighbour, the B-rep kept.
|
||||
#[cfg(feature = "scripts")]
|
||||
#[test]
|
||||
fn a_hole_a_fillet_needs_is_defeatured_virtually() {
|
||||
let script = r#"[
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 10, "body_name": "Block"}},
|
||||
{"command": "solid.hole", "params": {"position": [20, 20, 10], "diameter": 10}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[25, 20, 10]], "radius": 1}}
|
||||
]"#;
|
||||
let d = crate::open_design(&crate::script_design(script).unwrap()).unwrap();
|
||||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||||
let graph = d.features(None).unwrap();
|
||||
let hole = graph[0].features.iter().find(|f| f.kind == "hole").unwrap();
|
||||
let (out, merges) = d.defeatured(&[Defeature { name: "H".into(), features: vec![hole.id] }]).unwrap();
|
||||
let report = out.defeat_report();
|
||||
assert_eq!((report.len(), report[0].method), (1, "virtual"), "{report:?}");
|
||||
assert!(report[0].reason.as_deref().is_some_and(|r| r.contains("no longer builds")), "{report:?}");
|
||||
assert_eq!(merges.len(), 1, "{merges:?}");
|
||||
let m = &merges[0];
|
||||
assert!(m.into.as_ref().is_some_and(|i| !hole.faces.contains(i)) && m.faces.len() == hole.faces.len() + 1, "{m:?}");
|
||||
// The B-rep is the design as opened; the merge applies.
|
||||
assert_eq!(out.bodies[0].faces().len(), d.bodies[0].faces().len());
|
||||
let merged = out.merged(&merges).unwrap();
|
||||
assert!(merged.errors.is_empty(), "{:?}", merged.errors);
|
||||
assert_eq!(merged.bodies[0].faces().len(), d.bodies[0].faces().len() - hole.faces.len());
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,6 @@ pub struct Design {
|
||||
pub(crate) opened: std::sync::Arc<Vec<Shape>>,
|
||||
/// The timeline the design was rebuilt from ([`Design::features`]).
|
||||
pub(crate) history: Option<std::sync::Arc<crate::features::History>>,
|
||||
/// The defeatures applied ([`Design::defeatured`]) and the design as opened before them.
|
||||
pub(crate) defeat: Option<std::sync::Arc<crate::defeature::Applied>>,
|
||||
}
|
||||
|
||||
/// A feature of the design that failed to rebuild.
|
||||
@@ -166,7 +164,6 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
errors,
|
||||
cuts: Vec::new(),
|
||||
history: Some(std::sync::Arc::new(history)),
|
||||
defeat: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod check;
|
||||
pub mod defeature;
|
||||
mod design;
|
||||
mod edges;
|
||||
pub mod face_split;
|
||||
@@ -46,9 +45,6 @@ pub enum Error {
|
||||
Invalid(String),
|
||||
#[error("the geometry operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// From the model layer: a cancelled job ([`fieldcraft_model::progress::check`]).
|
||||
#[error(transparent)]
|
||||
Model(#[from] fieldcraft_model::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -210,8 +206,8 @@ impl Shape {
|
||||
self.virtual_surfaces(brep)
|
||||
}
|
||||
|
||||
/// [`Shape::face_surfaces`] by B-rep face index (the pieces of [`Shape::tessellate_pieces`]).
|
||||
pub fn brep_face_surfaces(&self) -> Result<Vec<Option<FaceSurface>>> {
|
||||
/// [`Shape::face_surfaces`] by B-rep face index.
|
||||
pub(crate) fn brep_face_surfaces(&self) -> Result<Vec<Option<FaceSurface>>> {
|
||||
guard("face surfaces", || {
|
||||
Ok((0..self.body.face_count())
|
||||
.map(|f| {
|
||||
@@ -261,37 +257,18 @@ impl Shape {
|
||||
/// by face index ([`Shape::faces`]): merged faces are one face, without the edges between
|
||||
/// their B-rep faces.
|
||||
pub fn tessellate(&self, tolerance: f64) -> Result<SurfaceMesh> {
|
||||
Ok(self.virtual_mesh(self.tessellate_brep(tolerance)?))
|
||||
}
|
||||
|
||||
/// [`Shape::tessellate`], and per triangle the B-rep face it lies on: its piece of a merged
|
||||
/// face, whose surface is in [`Shape::brep_face_surfaces`] (the same as its tag when no face
|
||||
/// is merged).
|
||||
pub fn tessellate_pieces(&self, tolerance: f64) -> Result<(SurfaceMesh, Vec<u32>)> {
|
||||
let brep = self.tessellate_brep(tolerance)?;
|
||||
let pieces = brep.tri_face.clone();
|
||||
Ok((self.virtual_mesh(brep), pieces))
|
||||
}
|
||||
|
||||
/// The B-rep's own tessellation, tagged by B-rep face.
|
||||
fn tessellate_brep(&self, tolerance: f64) -> Result<SurfaceMesh> {
|
||||
if !(tolerance > 0.0 && tolerance.is_finite()) {
|
||||
return Err(Error::Invalid(format!("tessellation tolerance must be a positive number, got {tolerance}")));
|
||||
}
|
||||
let m = guard("tessellate", || self.body.tessellate(tolerance))?;
|
||||
Ok(SurfaceMesh {
|
||||
Ok(self.virtual_mesh(SurfaceMesh {
|
||||
positions: m.positions.into_iter().map(xyz).collect(),
|
||||
triangles: m.triangles,
|
||||
tri_face: m.tri_face,
|
||||
weld_distance: 2.0 * LINEAR_TOLERANCE,
|
||||
edges: m.edges.into_iter().map(|e| e.into_iter().map(xyz).collect()).collect(),
|
||||
edge_faces: m.edge_faces,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether some face is made of several B-rep faces ([`virtual_faces`]).
|
||||
pub fn has_merged_faces(&self) -> bool {
|
||||
!self.face_map.is_empty()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,12 +103,7 @@ impl Design {
|
||||
let mut bodies: Vec<Shape> = self.source.as_ref().clone();
|
||||
let mut errors: Vec<FeatureError> = self.errors.iter().filter(|e| !crate::virtual_faces::derived(&e.feature)).cloned().collect();
|
||||
let mut reports = Vec::with_capacity(cuts.len());
|
||||
// Each cut is a kernel boolean per body it reaches, seconds each on a large part: the
|
||||
// job's progress counts them, and a cancelled job stops between them.
|
||||
fieldcraft_model::progress::stage("Partition cuts", Some(cuts.len() as f64), "cuts");
|
||||
for (k, (cut, (plane, n))) in cuts.iter().zip(&planes).enumerate() {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
fieldcraft_model::progress::check()?;
|
||||
for (cut, (plane, n)) in cuts.iter().zip(&planes) {
|
||||
let feature = format!("partition {}", cut.name);
|
||||
// A listed name is the body of that name, or the cells earlier partitions cut it into.
|
||||
let names = |name: &str, b: &str| name == b || name.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'));
|
||||
@@ -127,7 +122,6 @@ impl Design {
|
||||
next.push(body);
|
||||
continue;
|
||||
}
|
||||
fieldcraft_model::progress::check()?;
|
||||
match split(&body, plane, *n, cut)? {
|
||||
Split::Cells(cells, faces, warnings) => {
|
||||
for &i in &listed {
|
||||
@@ -184,7 +178,6 @@ impl Design {
|
||||
source: std::sync::Arc::clone(&self.source),
|
||||
opened: std::sync::Arc::clone(&self.source),
|
||||
history: self.history.clone(),
|
||||
defeat: self.defeat.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,12 @@
|
||||
//! the body ([`Shape::tessellate`], [`Shape::faces`], [`Shape::face_surfaces`], edge and vertex
|
||||
//! names): the triangles of its B-rep faces carry one tag, the edges between them are gone (an
|
||||
//! edge they leave split in pieces along a neighbour is joined into one), and its surface is the
|
||||
//! one its B-rep faces share (none when they lie on different surfaces: the projection mesher and
|
||||
//! node relaxation then keep to each B-rep piece's own surface, [`Shape::tessellate_pieces`],
|
||||
//! the other meshers to the tessellation). The B-rep itself is unchanged.
|
||||
//! one its B-rep faces share (none when they lie on different surfaces: meshers then keep to the
|
||||
//! tessellation). The B-rep itself is unchanged.
|
||||
//!
|
||||
//! Merges live in the FieldCraft model, by persistent face names, and are applied again after the
|
||||
//! partitions whenever the design is opened, so an edit of the design followed by a re-open
|
||||
//! replays them. On a body the partitions cut (a mesh plan's symmetry and corner cuts), a merge
|
||||
//! applies to each cell: the pieces of its faces that touch there are one face of the cell. A
|
||||
//! merge whose faces are gone or no longer touch is listed in [`Design::errors`]
|
||||
//! replays them. A merge whose faces are gone or no longer touch is listed in [`Design::errors`]
|
||||
//! (feature `merge <name>`), as a feature that failed to rebuild; the others still apply.
|
||||
//!
|
||||
//! The merged face is named `merge:<name>`. A face name in a merge selects the face of that name,
|
||||
@@ -57,128 +54,39 @@ pub(crate) fn derived(feature: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Design {
|
||||
/// The design with `merges` applied in order, on top of its partitions. A merge on a body
|
||||
/// the partitions cut applies to each of its cells: the faces it names that a cell has
|
||||
/// (their pieces included) and that touch there are made one face of that cell ([`cell_merges`]).
|
||||
/// A merge that fails on this design (a body or face that is not there, faces that do not
|
||||
/// touch, in any cell) is listed in [`Design::errors`] (feature `merge <name>`) and changes
|
||||
/// nothing; the rest still apply. The bodies as opened are merged too
|
||||
/// ([`Design::source_body`], the feature graph), whether partitions cut them or not.
|
||||
/// The design with `merges` applied in order, on top of its partitions. A merge that fails on
|
||||
/// this design (a body or face that is not there, faces that do not touch) is listed in
|
||||
/// [`Design::errors`] (feature `merge <name>`) and changes nothing; the rest still apply. A
|
||||
/// body no partition cut is also merged as opened ([`Design::source_body`], the feature graph).
|
||||
pub fn merged(mut self, merges: &[Merge]) -> Result<Design> {
|
||||
if merges.len() > MAX_MERGES {
|
||||
return Err(Error::Invalid(format!("a design takes at most {MAX_MERGES} face merges")));
|
||||
}
|
||||
let mut any = false;
|
||||
for m in merges {
|
||||
let feature = format!("merge {}", m.name);
|
||||
let fail = |message: String| FeatureError { feature: feature.clone(), message, bodies: vec![m.body.clone()] };
|
||||
if let Some(i) = m.into.as_ref().filter(|i| !m.faces.contains(i)) {
|
||||
self.errors.push(fail(format!("the face it keeps, `{i}`, is not one it merges")));
|
||||
let Some(body) = self.bodies.iter_mut().find(|b| b.name == m.body) else {
|
||||
self.errors.push(FeatureError { feature, message: format!("no body `{}`", m.body), bodies: vec![m.body.clone()] });
|
||||
continue;
|
||||
};
|
||||
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
|
||||
if m.into.as_ref().is_some_and(|i| !m.faces.contains(i)) {
|
||||
let message = format!("the face it keeps, `{name}`, is not one it merges");
|
||||
self.errors.push(FeatureError { feature, message, bodies: vec![m.body.clone()] });
|
||||
continue;
|
||||
}
|
||||
if let Some(body) = self.bodies.iter_mut().find(|b| b.name == m.body) {
|
||||
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
|
||||
match body.merge_faces(&name, &m.faces) {
|
||||
Ok(merged) => {
|
||||
*body = merged;
|
||||
any = true;
|
||||
}
|
||||
Err(e) => self.errors.push(fail(e.to_string())),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let prefix = format!("{}/", m.body);
|
||||
let cells: Vec<usize> = (0..self.bodies.len()).filter(|&i| self.bodies.get(i).is_some_and(|b| b.name.starts_with(&prefix))).collect();
|
||||
if cells.is_empty() {
|
||||
self.errors.push(fail(format!("no body `{}`", m.body)));
|
||||
continue;
|
||||
}
|
||||
// Each cell merged on its own; the merge fails as a whole when no cell has two of its
|
||||
// faces touching, or one of them refuses it.
|
||||
let mut next = Vec::with_capacity(cells.len());
|
||||
let mut error = None;
|
||||
for &i in &cells {
|
||||
let Some(cell) = self.bodies.get(i) else { continue };
|
||||
match cell_merges(cell, m) {
|
||||
Ok(Some(merged)) => next.push((i, merged)),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error = Some(format!("cell `{}`: {e}", cell.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match (error, next.is_empty()) {
|
||||
(Some(e), _) => self.errors.push(fail(e)),
|
||||
(None, true) => self.errors.push(fail(format!("no cell of `{}` has two of its faces touching", m.body))),
|
||||
(None, false) => {
|
||||
for (i, merged) in next {
|
||||
if let Some(slot) = self.bodies.get_mut(i) {
|
||||
*slot = merged;
|
||||
}
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
match body.merge_faces(&name, &m.faces) {
|
||||
Ok(merged) => *body = merged,
|
||||
Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![m.body.clone()] }),
|
||||
}
|
||||
}
|
||||
if any {
|
||||
let opened = self
|
||||
.source
|
||||
.iter()
|
||||
.map(|s| match self.bodies.iter().find(|b| b.name == s.name) {
|
||||
Some(b) => b.clone(),
|
||||
// Cut into cells: the body as opened with its own merges (each one that
|
||||
// applies; the cells reported the rest).
|
||||
None => merges.iter().filter(|m| m.body == s.name).fold(s.clone(), |shape, m| {
|
||||
let name = m.into.clone().unwrap_or_else(|| merged_face_name(&m.name));
|
||||
shape.merge_faces(&name, &m.faces).unwrap_or(shape)
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
if self.bodies.iter().any(|b| !b.face_map.is_empty()) {
|
||||
let opened = self.source.iter().map(|s| self.bodies.iter().find(|b| b.name == s.name).unwrap_or(s).clone()).collect();
|
||||
self.opened = std::sync::Arc::new(opened);
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge `m` on a cell partitions cut from its body: the faces it names that the cell has (or
|
||||
/// pieces of them), in the groups that touch there, each group of two or more made one face —
|
||||
/// named after the face it keeps (`into`; that face's piece in the cell), else `merge:<name>`
|
||||
/// (`merge:<name>#0`, `#1`, … when the cell has several groups, as a partition names pieces).
|
||||
/// `None` when the cell has no two of them touching.
|
||||
fn cell_merges(cell: &Shape, m: &Merge) -> Result<Option<Shape>> {
|
||||
let mut members = BTreeSet::new();
|
||||
for f in &m.faces {
|
||||
members.extend(cell.faces_named(f));
|
||||
}
|
||||
let groups: Vec<BTreeSet<u32>> = cell.components(&members)?.into_iter().filter(|g| g.len() > 1).collect();
|
||||
if groups.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let kept: BTreeSet<u32> = m.into.as_deref().map(|i| cell.faces_named(i).into_iter().collect()).unwrap_or_default();
|
||||
// Names first (indices change as faces merge), then the merges by those names.
|
||||
let unnamed = groups.iter().filter(|g| g.is_disjoint(&kept)).count();
|
||||
let mut k = 0;
|
||||
let mut plan = Vec::with_capacity(groups.len());
|
||||
for g in &groups {
|
||||
let names: Vec<String> = g.iter().map(|&f| cell.face_label(f)).collect();
|
||||
let name = match g.intersection(&kept).next() {
|
||||
Some(&f) => cell.face_label(f),
|
||||
None if unnamed == 1 => merged_face_name(&m.name),
|
||||
None => {
|
||||
k += 1;
|
||||
format!("{}#{}", merged_face_name(&m.name), k - 1)
|
||||
}
|
||||
};
|
||||
plan.push((name, names));
|
||||
}
|
||||
let mut shape = cell.clone();
|
||||
for (name, faces) in plan {
|
||||
shape = shape.merge_faces(&name, &faces)?;
|
||||
}
|
||||
Ok(Some(shape))
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// The faces with this name: the face itself, else the pieces a partition cut it into
|
||||
/// (`<name>#k`).
|
||||
@@ -284,8 +192,18 @@ impl Shape {
|
||||
|
||||
/// An error unless `members` touch one another along edges, all in one piece.
|
||||
fn connected(&self, members: &BTreeSet<u32>) -> Result<()> {
|
||||
let groups = self.components(members)?;
|
||||
match groups.get(1).and_then(|g| g.first()) {
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
let mut reached = BTreeSet::new();
|
||||
let mut todo: Vec<u32> = members.first().copied().into_iter().collect();
|
||||
while let Some(f) = todo.pop() {
|
||||
if !reached.insert(f) {
|
||||
continue;
|
||||
}
|
||||
for faces in mesh.edge_faces.iter().filter(|fs| fs.contains(&f)) {
|
||||
todo.extend(faces.iter().filter(|g| members.contains(g) && !reached.contains(g)));
|
||||
}
|
||||
}
|
||||
match members.iter().find(|f| !reached.contains(f)) {
|
||||
None => Ok(()),
|
||||
Some(&f) => Err(Error::Invalid(format!(
|
||||
"face `{}` of body `{}` does not touch the others along an edge: a merge makes adjacent faces one",
|
||||
@@ -295,32 +213,6 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// `members` in groups that touch along edges (the group of the first member first).
|
||||
fn components(&self, members: &BTreeSet<u32>) -> Result<Vec<BTreeSet<u32>>> {
|
||||
if members.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
let mut groups: Vec<BTreeSet<u32>> = Vec::new();
|
||||
for &start in members {
|
||||
if groups.iter().any(|g| g.contains(&start)) {
|
||||
continue;
|
||||
}
|
||||
let mut reached = BTreeSet::new();
|
||||
let mut todo = vec![start];
|
||||
while let Some(f) = todo.pop() {
|
||||
if !reached.insert(f) {
|
||||
continue;
|
||||
}
|
||||
for faces in mesh.edge_faces.iter().filter(|fs| fs.contains(&f)) {
|
||||
todo.extend(faces.iter().filter(|g| members.contains(g) && !reached.contains(g)));
|
||||
}
|
||||
}
|
||||
groups.push(reached);
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// The surfaces of the B-rep faces face `face` is made of (one for a face not merged); `None`
|
||||
/// for a free-form one.
|
||||
pub fn member_surfaces(&self, face: u32) -> Result<Vec<Option<FaceSurface>>> {
|
||||
@@ -580,41 +472,6 @@ mod tests {
|
||||
assert_eq!(b.face_surfaces().unwrap()[f as usize], None);
|
||||
}
|
||||
|
||||
/// A merge on a body the partitions cut applies to each cell: the plate cut across its hole
|
||||
/// leaves a piece of each half in each cell, and each cell's two pieces are one face there,
|
||||
/// named after the face kept; the body as opened keeps the merge too.
|
||||
#[test]
|
||||
fn a_merge_applies_to_the_cells_of_a_partitioned_body() {
|
||||
let d = plate();
|
||||
let body = d.bodies[0].name.clone();
|
||||
let hole = halves(&d);
|
||||
let cut = crate::partition::Cut { name: "Mid".into(), point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0], bodies: Vec::new() };
|
||||
let absorb = Merge { name: "A".into(), body: body.clone(), faces: hole.clone(), into: Some(hole[1].clone()) };
|
||||
let d = d.partitioned(&[cut]).unwrap().merged(&[absorb]).unwrap();
|
||||
assert!(d.errors.is_empty(), "{:?}", d.errors);
|
||||
assert_eq!(d.bodies.len(), 2);
|
||||
for cell in &d.bodies {
|
||||
let [(f, 2)] = cell.merged_faces()[..] else { panic!("{}: {:?}", cell.name, cell.merged_faces()) };
|
||||
assert_eq!(cell.faces()[f as usize].name, hole[1], "{}", cell.name);
|
||||
assert!(cell.faces_named(&hole[0]).is_empty(), "{}", cell.name);
|
||||
}
|
||||
let opened = d.source_body(&body).unwrap();
|
||||
assert_eq!(opened.merged_faces().len(), 1);
|
||||
// A merge whose faces touch in no cell is listed.
|
||||
let names: Vec<String> = plate().bodies[0].faces().iter().map(|f| f.name.clone()).collect();
|
||||
let cut = crate::partition::Cut { name: "Mid".into(), point: [0.0, 0.0, 5.0], normal: [0.0, 0.0, 1.0], bodies: Vec::new() };
|
||||
let s = plate().bodies[0].face_surfaces().unwrap();
|
||||
let caps: Vec<String> = names
|
||||
.iter()
|
||||
.zip(&s)
|
||||
.filter(|(_, s)| matches!(s, Some(FaceSurface::Plane { normal, .. }) if normal[2].abs() > 0.5))
|
||||
.map(|(n, _)| n.clone())
|
||||
.collect();
|
||||
let apart = Merge { name: "Apart".into(), body, faces: caps, into: None };
|
||||
let d = plate().partitioned(&[cut]).unwrap().merged(&[apart]).unwrap();
|
||||
assert_eq!(d.errors.iter().map(|e| e.feature.as_str()).collect::<Vec<_>>(), ["merge Apart"]);
|
||||
}
|
||||
|
||||
/// The neighbour a half of the hole is best absorbed into is the other half (two 10 mm seams,
|
||||
/// against a 15.7 mm rim arc with each cap); absorbing it keeps the other half's name.
|
||||
#[test]
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
//! Backends are Metal on macOS, DX12 and Vulkan on Windows, Vulkan and GL elsewhere;
|
||||
//! `WGPU_BACKEND` narrows them. A discrete GPU outranks an integrated one, then a virtual one,
|
||||
//! then any other; a CPU device is the software tier.
|
||||
//!
|
||||
//! wgpu's backend options follow its environment variables: on DX12, `WGPU_DX12_COMPILER`
|
||||
//! (`fxc` or `dxc`; unset, DXC when `dxcompiler.dll` is on the DLL search path, else FXC, see
|
||||
//! #561). `staticdxc` is refused: it needs wgpu's `static-dxc` feature, which FieldCraft does not
|
||||
//! build, and wgpu would panic on it.
|
||||
|
||||
use crate::{GpuError, Result};
|
||||
|
||||
@@ -105,11 +100,7 @@ pub fn adapter(policy: Policy) -> Result<Option<(wgpu::Adapter, Tier)>> {
|
||||
if policy == Policy::Cpu {
|
||||
return Ok(None);
|
||||
}
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: backends(),
|
||||
backend_options: backend_options(wgpu::BackendOptions::from_env_or_default())?,
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: backends(), ..wgpu::InstanceDescriptor::new_without_display_handle() });
|
||||
let mut adapters = pollster::block_on(instance.enumerate_adapters(backends()));
|
||||
let types: Vec<_> = adapters.iter().map(|a| a.get_info().device_type).collect();
|
||||
let Some(i) = pick(&types, policy)? else { return Ok(None) };
|
||||
@@ -117,32 +108,12 @@ pub fn adapter(policy: Policy) -> Result<Option<(wgpu::Adapter, Tier)>> {
|
||||
Ok((i < adapters.len()).then(|| (adapters.swap_remove(i), tier)))
|
||||
}
|
||||
|
||||
/// `options`, unless they ask for a DX12 compiler this build does not have.
|
||||
fn backend_options(options: wgpu::BackendOptions) -> Result<wgpu::BackendOptions> {
|
||||
if matches!(options.dx12.shader_compiler, wgpu::Dx12Compiler::StaticDxc) {
|
||||
return Err(GpuError::Device("WGPU_DX12_COMPILER=staticdxc is not built in: use dxc with dxcompiler.dll, or fxc".into()));
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use wgpu::DeviceType::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_dx12_compiler_that_is_not_built_in_is_refused() {
|
||||
let with = |shader_compiler| {
|
||||
let mut o = wgpu::BackendOptions::default();
|
||||
o.dx12.shader_compiler = shader_compiler;
|
||||
backend_options(o)
|
||||
};
|
||||
assert!(with(wgpu::Dx12Compiler::Fxc).is_ok());
|
||||
assert!(with(wgpu::Dx12Compiler::Auto).is_ok());
|
||||
assert!(with(wgpu::Dx12Compiler::StaticDxc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policies_walk_the_tiers() {
|
||||
assert_eq!(pick(&[Cpu, IntegratedGpu, DiscreteGpu, VirtualGpu], Policy::Auto), Ok(Some(2)));
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "fieldcraft-lbm-gpu"
|
||||
description = "FieldCraft's lattice Boltzmann solver on the GPU: the CPU scheme's step as wgpu compute shaders, driven through fieldcraft-lbm's Backend trait and checked against it"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-lbm = { workspace = true }
|
||||
fieldcraft-model = { workspace = true }
|
||||
fieldcraft-gpu = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
fieldcraft-mesh = { workspace = true }
|
||||
@@ -1,654 +0,0 @@
|
||||
// The lattice Boltzmann step of fieldcraft_lbm::flow on the GPU: the same D3Q19 two-relaxation-
|
||||
// time collision with Guo forcing, pull streaming with the per-link rules the CPU prepared
|
||||
// (bounce-back with Ladd's moving-wall term, specular reflection, open placeholders), the Zou–He
|
||||
// open boundaries, and the momentum exchange per force group. Populations are stored direction-
|
||||
// major. The default AA pattern uses one population buffer (Bailey et al., ICPP 2009;
|
||||
// Wittmann et al., Comput. Math. Appl. 65 (2013), 924–935). Even steps read a cell and
|
||||
// write its opposite directions; odd steps read opposite directions at the upstream
|
||||
// neighbours and write each outgoing direction at the downstream neighbour. Each thread
|
||||
// writes exactly the addresses it read, so distinct cells never race. Physical coordinates
|
||||
// wrap; the existing per-link rules still supply walls, reflection and open placeholders.
|
||||
// A gather pass saves those boundary values before a scatter pass overrides their targets:
|
||||
// overwriting in the gather itself could destroy another link's post-collision source.
|
||||
// The optional A–B diagnostic path retains the original two-buffer collide/pull scheme.
|
||||
//
|
||||
// Single precision, with every population stored as its deviation from the weight, h_q = f_q − w_q
|
||||
// (Krüger et al. 2017, §13.3.4: the deviations are small, so f32 keeps their digits instead of
|
||||
// the weight's): ρ = 1 + Σ h, momentum Σ h c (Σ w c = 0), and every rule below is written for h.
|
||||
// The CPU scheme (double precision, fixed sums) is the reference; see the crate's tests for the
|
||||
// tolerances.
|
||||
//
|
||||
// With heat, a D3Q7 scalar lattice (the rest direction and the six axes of D3Q19, weights ¼ and
|
||||
// ⅛) rides on the flow in the buffers t and t2, stored as deviations from w_q T₀ (T₀ the initial
|
||||
// temperature) so that f32 keeps the digits of the differences: θ = T − T₀ = Σ t. It collides
|
||||
// (BGK) with the velocity the momentum collision left in `moments`, streams with the links' rules
|
||||
// read through the group's thermal condition (anti-bounce-back for a held temperature, bounce-
|
||||
// back for an adiabatic face, a flux added to the bounced population), takes its inlet
|
||||
// temperatures and outlet neighbours, and gives each group the heat its links exchanged (in
|
||||
// contrib.w and forces.w). The temperature feeds back as the buoyant acceleration −β g θ.
|
||||
|
||||
override AA_MODE: bool = true;
|
||||
|
||||
const Q: u32 = 19u;
|
||||
const QT: u32 = 7u;
|
||||
const WG: u32 = 256u;
|
||||
const KIND_PULL: u32 = 0u;
|
||||
const KIND_BOUNCE: u32 = 1u;
|
||||
const KIND_REFLECT: u32 = 2u;
|
||||
const KIND_OPEN: u32 = 3u;
|
||||
|
||||
struct Params {
|
||||
n: u32,
|
||||
nx: u32,
|
||||
ny: u32,
|
||||
nz: u32,
|
||||
groups_x: u32,
|
||||
links: u32,
|
||||
open: u32,
|
||||
groups: u32,
|
||||
omega_plus: f32,
|
||||
omega_minus: f32,
|
||||
s_plus: f32,
|
||||
s_minus: f32,
|
||||
// The body acceleration (xyz) and whether there is one (w).
|
||||
acc: vec4<f32>,
|
||||
// The buoyancy −β g in lattice units (xyz) and whether there is any (w).
|
||||
buoy: vec4<f32>,
|
||||
// The scalar lattice: its relaxation rate (x), T₀ (y), whether there is heat (z).
|
||||
therm: vec4<f32>,
|
||||
// Current AA orientation (x: 0 normal, 1 opposite directions at upstream neighbours).
|
||||
phase: vec4<u32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> fluid: array<u32>;
|
||||
@group(0) @binding(2) var<storage, read_write> f: array<f32>;
|
||||
@group(0) @binding(3) var<storage, read_write> g: array<f32>;
|
||||
// Per link: (q | kind << 8 | q2 << 16, from, group, 0) and the moving-wall term.
|
||||
@group(0) @binding(5) var<storage, read> link_u: array<vec4<u32>>;
|
||||
@group(0) @binding(6) var<storage, read> link_f: array<f32>;
|
||||
// Per open cell: (cell, axis, kind: 0 velocity / 1 density, 0) and (target xyz or density in x, inward sign in w).
|
||||
@group(0) @binding(7) var<storage, read> open_u: array<vec4<u32>>;
|
||||
@group(0) @binding(8) var<storage, read> open_f: array<vec4<f32>>;
|
||||
// Per cell: velocity (with the half force) and density, from the moments kernel.
|
||||
@group(0) @binding(9) var<storage, read_write> moments: array<vec4<f32>>;
|
||||
// [0]: the largest squared lattice speed of the chunk (as f32 bits); [1]: a density was not positive.
|
||||
@group(0) @binding(10) var<storage, read_write> stats: array<atomic<u32>>;
|
||||
// Per link, the momentum it transferred to its group in the last step.
|
||||
@group(0) @binding(11) var<storage, read_write> contrib: array<vec4<f32>>;
|
||||
// Link indices ordered by group, and each group's range in that order (groups + 1 entries).
|
||||
@group(0) @binding(12) var<storage, read> by_group: array<u32>;
|
||||
@group(0) @binding(13) var<storage, read> group_start: array<u32>;
|
||||
@group(0) @binding(14) var<storage, read_write> forces: array<vec4<f32>>;
|
||||
// Per workgroup, the mass of its cells.
|
||||
@group(0) @binding(15) var<storage, read_write> partials: array<f32>;
|
||||
// The scalar populations remain A–B (t[q n + cell]; see the notes above).
|
||||
@group(0) @binding(16) var<storage, read_write> t: array<f32>;
|
||||
@group(0) @binding(17) var<storage, read_write> t2: array<f32>;
|
||||
// Per force group: its thermal condition (x: 0 adiabatic, 1 temperature, 2 flux) and the value
|
||||
// (y: the held temperature − T₀, or the flux as a temperature per step).
|
||||
@group(0) @binding(18) var<storage, read> group_t: array<vec4<f32>>;
|
||||
// Per open cell: whether it holds a temperature (x) and that temperature − T₀ (y).
|
||||
@group(0) @binding(19) var<storage, read> open_t: array<vec4<f32>>;
|
||||
|
||||
var<workgroup> red: array<vec4<f32>, 256>;
|
||||
// The largest squared speed of a workgroup's cells (collide): one atomic per workgroup, not
|
||||
// per cell, which serialise on a single address.
|
||||
var<workgroup> speed: array<f32, 256>;
|
||||
|
||||
fn c_of(q: u32) -> vec3<i32> {
|
||||
switch q {
|
||||
case 0u: { return vec3<i32>(0, 0, 0); }
|
||||
case 1u: { return vec3<i32>(1, 0, 0); }
|
||||
case 2u: { return vec3<i32>(-1, 0, 0); }
|
||||
case 3u: { return vec3<i32>(0, 1, 0); }
|
||||
case 4u: { return vec3<i32>(0, -1, 0); }
|
||||
case 5u: { return vec3<i32>(0, 0, 1); }
|
||||
case 6u: { return vec3<i32>(0, 0, -1); }
|
||||
case 7u: { return vec3<i32>(1, 1, 0); }
|
||||
case 8u: { return vec3<i32>(-1, -1, 0); }
|
||||
case 9u: { return vec3<i32>(1, -1, 0); }
|
||||
case 10u: { return vec3<i32>(-1, 1, 0); }
|
||||
case 11u: { return vec3<i32>(1, 0, 1); }
|
||||
case 12u: { return vec3<i32>(-1, 0, -1); }
|
||||
case 13u: { return vec3<i32>(1, 0, -1); }
|
||||
case 14u: { return vec3<i32>(-1, 0, 1); }
|
||||
case 15u: { return vec3<i32>(0, 1, 1); }
|
||||
case 16u: { return vec3<i32>(0, -1, -1); }
|
||||
case 17u: { return vec3<i32>(0, 1, -1); }
|
||||
default: { return vec3<i32>(0, -1, 1); }
|
||||
}
|
||||
}
|
||||
|
||||
fn opp(q: u32) -> u32 {
|
||||
if q == 0u { return 0u; }
|
||||
if (q & 1u) == 1u { return q + 1u; }
|
||||
return q - 1u;
|
||||
}
|
||||
|
||||
fn w_of(q: u32) -> f32 {
|
||||
if q == 0u { return 1.0 / 3.0; }
|
||||
if q < 7u { return 1.0 / 18.0; }
|
||||
return 1.0 / 36.0;
|
||||
}
|
||||
|
||||
fn wt_of(q: u32) -> f32 {
|
||||
if q == 0u { return 0.25; }
|
||||
return 0.125;
|
||||
}
|
||||
|
||||
fn has_heat() -> bool {
|
||||
return params.therm.z > 0.5;
|
||||
}
|
||||
|
||||
// The temperature of a cell above T₀: the sum of its scalar populations.
|
||||
fn theta_of(cell: u32) -> f32 {
|
||||
var s = 0.0;
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
s = s + t[q * params.n + cell];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// The body acceleration of a cell: the constant one plus the buoyant one.
|
||||
fn acc_of(cell: u32) -> vec3<f32> {
|
||||
var a = params.acc.xyz;
|
||||
if params.buoy.w > 0.5 { a = a + params.buoy.xyz * theta_of(cell); }
|
||||
return a;
|
||||
}
|
||||
|
||||
fn forced() -> bool {
|
||||
return params.acc.w > 0.5 || params.buoy.w > 0.5;
|
||||
}
|
||||
|
||||
// The scalar equilibrium w_q T (1 + 4 c_q·u), shifted by w_q T₀ like the populations.
|
||||
fn eq_t(q: u32, theta: f32, u: vec3<f32>) -> f32 {
|
||||
let cu = 4.0 * dot(vec3<f32>(c_of(q)), u);
|
||||
return wt_of(q) * (theta * (1.0 + cu) + params.therm.y * cu);
|
||||
}
|
||||
|
||||
fn cell_of(lid: u32, wg: vec3<u32>) -> u32 {
|
||||
return (wg.y * params.groups_x + wg.x) * WG + lid;
|
||||
}
|
||||
|
||||
fn is_fluid(cell: u32) -> bool {
|
||||
return fluid[cell] != 0u;
|
||||
}
|
||||
|
||||
// A cell's grid coordinates (integer divisions once per cell, not once per direction).
|
||||
fn coords(cell: u32) -> vec3<i32> {
|
||||
let plane = params.nx * params.ny;
|
||||
return vec3<i32>(i32(cell % params.nx), i32((cell % plane) / params.nx), i32(cell / plane));
|
||||
}
|
||||
|
||||
// The cell at `p`, wrapped periodically. A lattice step moves at most one cell per axis, so a
|
||||
// compare and an add per axis replace the remainder.
|
||||
fn wrapped(p: vec3<i32>) -> u32 {
|
||||
let size = vec3<i32>(i32(params.nx), i32(params.ny), i32(params.nz));
|
||||
var w = select(p, p + size, p < vec3<i32>(0));
|
||||
w = select(w, w - size, w >= size);
|
||||
return u32((w.z * size.y + w.y) * size.x + w.x);
|
||||
}
|
||||
|
||||
// Where population q of the cell at `xyz` is stored in the given AA orientation: in place when
|
||||
// even, reversed at the upstream neighbour when odd.
|
||||
fn population_index(q: u32, cell: u32, xyz: vec3<i32>, phase: u32) -> u32 {
|
||||
if phase == 0u { return q * params.n + cell; }
|
||||
return opp(q) * params.n + wrapped(xyz - c_of(q));
|
||||
}
|
||||
|
||||
fn population(q: u32, cell: u32, xyz: vec3<i32>) -> f32 {
|
||||
if AA_MODE { return f[population_index(q, cell, xyz, params.phase.x)]; }
|
||||
return f[q * params.n + cell];
|
||||
}
|
||||
|
||||
// Collision's output at its source cell, before the boundary rules.
|
||||
fn post_index(q: u32, cell: u32, xyz: vec3<i32>) -> u32 {
|
||||
if !AA_MODE { return q * params.n + cell; }
|
||||
if params.phase.x == 0u { return opp(q) * params.n + cell; }
|
||||
return q * params.n + wrapped(xyz + c_of(q));
|
||||
}
|
||||
|
||||
fn post(q: u32, cell: u32) -> f32 {
|
||||
return f[post_index(q, cell, aa_coords(cell))];
|
||||
}
|
||||
|
||||
fn streamed(q: u32, cell: u32, xyz: vec3<i32>) -> f32 {
|
||||
if AA_MODE { return f[population_index(q, cell, xyz, params.phase.x ^ 1u)]; }
|
||||
return g[q * params.n + cell];
|
||||
}
|
||||
|
||||
fn store_streamed(q: u32, cell: u32, xyz: vec3<i32>, v: f32) {
|
||||
if AA_MODE { f[population_index(q, cell, xyz, params.phase.x ^ 1u)] = v; }
|
||||
else { g[q * params.n + cell] = v; }
|
||||
}
|
||||
|
||||
// The coordinates the AA addressing needs; none in the two-buffer layout.
|
||||
fn aa_coords(cell: u32) -> vec3<i32> {
|
||||
if AA_MODE { return coords(cell); }
|
||||
return vec3<i32>(0);
|
||||
}
|
||||
|
||||
// Two-relaxation-time collision with Guo forcing, in place in f (Ginzburg et al. 2008; Guo et
|
||||
// al. 2002; Krüger et al. 2017 ch. 10), and the chunk's largest speed.
|
||||
@compute @workgroup_size(256)
|
||||
fn collide(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
speed[lid] = 0.0;
|
||||
if cell < params.n && is_fluid(cell) {
|
||||
collide_cell(cell, lid);
|
||||
}
|
||||
workgroupBarrier();
|
||||
for (var k = 128u; k > 0u; k = k >> 1u) {
|
||||
if lid < k { speed[lid] = max(speed[lid], speed[lid + k]); }
|
||||
workgroupBarrier();
|
||||
}
|
||||
if lid == 0u { atomicMax(&stats[0], bitcast<u32>(speed[0])); }
|
||||
}
|
||||
|
||||
fn collide_cell(cell: u32, lid: u32) {
|
||||
let n = params.n;
|
||||
// Only the odd AA orientation addresses neighbours.
|
||||
var xyz = vec3<i32>(0);
|
||||
if AA_MODE && params.phase.x == 1u { xyz = coords(cell); }
|
||||
var fq: array<f32, 19>;
|
||||
var drho = 0.0;
|
||||
var j = vec3<f32>(0.0);
|
||||
for (var q = 0u; q < Q; q = q + 1u) {
|
||||
let v = population(q, cell, xyz);
|
||||
fq[q] = v;
|
||||
drho = drho + v;
|
||||
j = j + vec3<f32>(c_of(q)) * v;
|
||||
}
|
||||
let rho = 1.0 + drho;
|
||||
if !(rho > 0.0) || rho != rho || abs(rho) > 1e30 {
|
||||
atomicStore(&stats[1], 1u);
|
||||
return;
|
||||
}
|
||||
let a = acc_of(cell);
|
||||
let force = rho * a;
|
||||
let u = (j + 0.5 * force) / rho;
|
||||
let uu = dot(u, u);
|
||||
speed[lid] = uu;
|
||||
if has_heat() { moments[cell] = vec4<f32>(u, rho); }
|
||||
let uf = dot(u, force);
|
||||
let forced = forced();
|
||||
let wp = params.omega_plus;
|
||||
let wm = params.omega_minus;
|
||||
let sp = params.s_plus;
|
||||
let sm = params.s_minus;
|
||||
// Rest population: symmetric only. Equilibria are shifted by the weight like the populations.
|
||||
let eq0 = w_of(0u) * (drho - 1.5 * rho * uu);
|
||||
var out0 = fq[0] - wp * (fq[0] - eq0);
|
||||
if forced { out0 = out0 + sp * w_of(0u) * (-3.0 * uf); }
|
||||
f[post_index(0u, cell, xyz)] = out0;
|
||||
for (var q = 1u; q < Q; q = q + 2u) {
|
||||
let o = q + 1u;
|
||||
let cv = vec3<f32>(c_of(q));
|
||||
let cu = dot(cv, u);
|
||||
let w = w_of(q);
|
||||
let eq_plus = w * (drho + rho * (4.5 * cu * cu - 1.5 * uu));
|
||||
let eq_minus = w * rho * 3.0 * cu;
|
||||
let plus = 0.5 * (fq[q] + fq[o]);
|
||||
let minus = 0.5 * (fq[q] - fq[o]);
|
||||
let relax_plus = wp * (plus - eq_plus);
|
||||
let relax_minus = wm * (minus - eq_minus);
|
||||
var s_plus = 0.0;
|
||||
var s_minus = 0.0;
|
||||
if forced {
|
||||
let cf = dot(cv, force);
|
||||
s_plus = sp * w * (9.0 * cu * cf - 3.0 * uf);
|
||||
s_minus = sm * w * 3.0 * cf;
|
||||
}
|
||||
f[post_index(q, cell, xyz)] = fq[q] - relax_plus - relax_minus + s_plus + s_minus;
|
||||
f[post_index(o, cell, xyz)] = fq[o] - relax_plus + relax_minus + s_plus - s_minus;
|
||||
}
|
||||
}
|
||||
|
||||
// Pull streaming of f into g, first pass: every population from the neighbour at −c_q
|
||||
// (wrapped; a wall or an open face overrides it in the second pass).
|
||||
@compute @workgroup_size(256)
|
||||
fn stream(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
if cell >= params.n { return; }
|
||||
if !is_fluid(cell) { return; }
|
||||
let n = params.n;
|
||||
let nx = i32(params.nx);
|
||||
let ny = i32(params.ny);
|
||||
let nz = i32(params.nz);
|
||||
let i = i32(cell % params.nx);
|
||||
let jj = i32((cell / params.nx) % params.ny);
|
||||
let k = i32(cell / (params.nx * params.ny));
|
||||
for (var q = 0u; q < Q; q = q + 1u) {
|
||||
let c = c_of(q);
|
||||
let si = (i - c.x + nx) % nx;
|
||||
let sj = (jj - c.y + ny) % ny;
|
||||
let sk = (k - c.z + nz) % nz;
|
||||
let src = u32((sk * ny + sj) * nx + si);
|
||||
g[q * n + cell] = f[q * n + src];
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass, one thread per link: the populations with a rule (a periodic image, a wall, a
|
||||
// slip face, an open face). Every (cell, direction) has at most one link, so no two threads
|
||||
// write the same population.
|
||||
@compute @workgroup_size(256)
|
||||
fn stream_links(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
gather_link(cell_of(lid, wg), false);
|
||||
}
|
||||
|
||||
// The same on the step whose wall forces are read back (a chunk's last): the AA layout
|
||||
// overwrites the post-collision sources in the scatter, so the momentum exchange is taken here.
|
||||
@compute @workgroup_size(256)
|
||||
fn stream_links_exchange(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
gather_link(cell_of(lid, wg), true);
|
||||
}
|
||||
|
||||
fn gather_link(l: u32, exchange: bool) {
|
||||
if l >= params.links { return; }
|
||||
let n = params.n;
|
||||
let lu = link_u[l];
|
||||
let q = lu.x & 0xffu;
|
||||
let kind = (lu.x >> 8u) & 0xffu;
|
||||
let q2 = (lu.x >> 16u) & 0xffu;
|
||||
let cell = lu.w;
|
||||
var v = 0.0;
|
||||
if kind == KIND_PULL {
|
||||
v = post(q, lu.y);
|
||||
} else if kind == KIND_BOUNCE {
|
||||
v = post(opp(q), cell) + link_f[l];
|
||||
} else if kind == KIND_REFLECT {
|
||||
// The mirrored direction has the same weight, so h reflects like f.
|
||||
v = post(q2, lu.y);
|
||||
} else {
|
||||
v = post(opp(q), cell);
|
||||
}
|
||||
if AA_MODE {
|
||||
g[l] = v;
|
||||
if exchange { contrib[l] = vec4<f32>(momentum_exchange(l, lu, q, kind, q2), 0.0); }
|
||||
} else {
|
||||
g[q * n + cell] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// The gather above must complete before any AA boundary population is overwritten.
|
||||
@compute @workgroup_size(256)
|
||||
fn scatter_links(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let l = cell_of(lid, wg);
|
||||
if l >= params.links { return; }
|
||||
let lu = link_u[l];
|
||||
store_streamed(lu.x & 0xffu, lu.w, aa_coords(lu.w), g[l]);
|
||||
}
|
||||
|
||||
// Zou–He velocity and pressure boundaries on the streamed populations in g (Zou & He 1997;
|
||||
// Hecht & Harting 2010 for the D3Q19 transverse correction).
|
||||
@compute @workgroup_size(256)
|
||||
fn open_boundaries(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let o = cell_of(lid, wg);
|
||||
if o >= params.open { return; }
|
||||
let n = params.n;
|
||||
let ou = open_u[o];
|
||||
let ot = open_f[o];
|
||||
let cell = ou.x;
|
||||
let axis = ou.y;
|
||||
let sign = ot.w;
|
||||
let a_half = 0.5 * acc_of(cell);
|
||||
let xyz = aa_coords(cell);
|
||||
// The sums of h over the tangential (c_n = 0) and outgoing (c_n < 0) directions. The sums of
|
||||
// f add the weights' (2/3 and 1/6 for D3Q19, so tangential + 2 outgoing gains exactly 1): the
|
||||
// weight is added after the deviations are summed, not before, which in single precision would
|
||||
// round away the deviations' last digits at low speed.
|
||||
var tangential = 0.0;
|
||||
var outgoing = 0.0;
|
||||
for (var q = 0u; q < Q; q = q + 1u) {
|
||||
let v = streamed(q, cell, xyz);
|
||||
let cn = f32(c_of(q)[axis]) * sign;
|
||||
if cn == 0.0 {
|
||||
tangential = tangential + v;
|
||||
} else if cn < 0.0 {
|
||||
outgoing = outgoing + v;
|
||||
}
|
||||
}
|
||||
var rho = 0.0;
|
||||
var bare = vec3<f32>(0.0);
|
||||
if ou.z == 0u {
|
||||
bare = ot.xyz - a_half;
|
||||
let un = sign * bare[axis];
|
||||
rho = (1.0 + (tangential + 2.0 * outgoing)) / (1.0 - un);
|
||||
} else {
|
||||
rho = ot.x;
|
||||
let un = ((rho - 1.0) - (tangential + 2.0 * outgoing)) / rho;
|
||||
// Not `bare[axis] = …`: DX12's shader compiler (FXC) refuses a write through a
|
||||
// dynamic index into a local vector (X3500).
|
||||
bare = select(-a_half, vec3<f32>(sign * un), vec3<u32>(0u, 1u, 2u) == vec3<u32>(axis));
|
||||
}
|
||||
for (var q = 1u; q < Q; q = q + 1u) {
|
||||
let c = c_of(q);
|
||||
if f32(c[axis]) * sign > 0.0 {
|
||||
let cu = dot(vec3<f32>(c), bare);
|
||||
store_streamed(q, cell, xyz, streamed(opp(q), cell, xyz) + 6.0 * w_of(q) * rho * cu);
|
||||
}
|
||||
}
|
||||
for (var t = 0u; t < 3u; t = t + 1u) {
|
||||
if t == axis { continue; }
|
||||
var m = -rho * bare[t];
|
||||
for (var q = 0u; q < Q; q = q + 1u) {
|
||||
m = m + streamed(q, cell, xyz) * f32(c_of(q)[t]);
|
||||
}
|
||||
for (var q = 1u; q < Q; q = q + 1u) {
|
||||
let c = c_of(q);
|
||||
if f32(c[axis]) * sign > 0.0 && c[t] != 0 {
|
||||
store_streamed(q, cell, xyz, streamed(q, cell, xyz) - 0.5 * m * f32(c[t]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn momentum_exchange(l: u32, lu: vec4<u32>, q: u32, kind: u32, q2: u32) -> vec3<f32> {
|
||||
var v = vec3<f32>(0.0);
|
||||
if kind == KIND_BOUNCE {
|
||||
let out = post(opp(q), lu.w);
|
||||
let back = out + link_f[l];
|
||||
v = -vec3<f32>(c_of(q)) * (out + back);
|
||||
} else if kind == KIND_REFLECT {
|
||||
let out = post(q2, lu.y);
|
||||
v = vec3<f32>(c_of(q2)) * out - vec3<f32>(c_of(q)) * out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// The momentum each link transferred to its group in the last step, from the post-collision
|
||||
// populations in f (Ladd 1994; Mei et al. 2002): c_out f* − c_in f'.
|
||||
@compute @workgroup_size(256)
|
||||
fn link_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let l = cell_of(lid, wg);
|
||||
if l >= params.links { return; }
|
||||
let n = params.n;
|
||||
let lu = link_u[l];
|
||||
let q = lu.x & 0xffu;
|
||||
let kind = (lu.x >> 8u) & 0xffu;
|
||||
let q2 = (lu.x >> 16u) & 0xffu;
|
||||
let cell = lu.w;
|
||||
// The deviations only: the weights' share of the exchange (2 w_q c_q per bounced link, which
|
||||
// cancels between a wall's links in exact arithmetic but is 10⁴ times the shear they carry)
|
||||
// is added on the host in double precision, so the sum keeps the deviations' digits.
|
||||
var v = contrib[l].xyz;
|
||||
if !AA_MODE { v = momentum_exchange(l, lu, q, kind, q2); }
|
||||
var heat = 0.0;
|
||||
// The heat the link gave its wall: what left the fluid minus what came back.
|
||||
if has_heat() && q < QT && (kind == KIND_BOUNCE || kind == KIND_REFLECT) {
|
||||
heat = t[opp(q) * n + cell] - thermal_rule(lu, q, kind, cell);
|
||||
}
|
||||
contrib[l] = vec4<f32>(v, heat);
|
||||
}
|
||||
|
||||
// ---- The scalar lattice ----
|
||||
|
||||
// BGK collision of the scalar lattice, in place in t, with the velocity of the momentum
|
||||
// collision.
|
||||
@compute @workgroup_size(256)
|
||||
fn collide_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
if cell >= params.n { return; }
|
||||
if !is_fluid(cell) { return; }
|
||||
let n = params.n;
|
||||
var tq: array<f32, 7>;
|
||||
var theta = 0.0;
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
let v = t[q * n + cell];
|
||||
tq[q] = v;
|
||||
theta = theta + v;
|
||||
}
|
||||
let u = moments[cell].xyz;
|
||||
let omega = params.therm.x;
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
t[q * n + cell] = tq[q] - omega * (tq[q] - eq_t(q, theta, u));
|
||||
}
|
||||
}
|
||||
|
||||
// Pull streaming of t into t2, first pass (as `stream`).
|
||||
@compute @workgroup_size(256)
|
||||
fn stream_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
if cell >= params.n { return; }
|
||||
if !is_fluid(cell) { return; }
|
||||
let n = params.n;
|
||||
let nx = i32(params.nx);
|
||||
let ny = i32(params.ny);
|
||||
let nz = i32(params.nz);
|
||||
let i = i32(cell % params.nx);
|
||||
let jj = i32((cell / params.nx) % params.ny);
|
||||
let k = i32(cell / (params.nx * params.ny));
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
let c = c_of(q);
|
||||
let si = (i - c.x + nx) % nx;
|
||||
let sj = (jj - c.y + ny) % ny;
|
||||
let sk = (k - c.z + nz) % nz;
|
||||
let src = u32((sk * ny + sj) * nx + si);
|
||||
t2[q * n + cell] = t[q * n + src];
|
||||
}
|
||||
}
|
||||
|
||||
// The scalar population a link's rule gives, from t: a pull as the flow's; at a wall or a slip
|
||||
// face the group's thermal condition on the bounced population; an open placeholder.
|
||||
fn thermal_rule(lu: vec4<u32>, q: u32, kind: u32, cell: u32) -> f32 {
|
||||
let n = params.n;
|
||||
if kind == KIND_PULL { return t[q * n + lu.y]; }
|
||||
let back = t[opp(q) * n + cell];
|
||||
if kind == KIND_OPEN { return back; }
|
||||
let cond = group_t[lu.z];
|
||||
let k = u32(cond.x);
|
||||
if k == 1u { return -back + 2.0 * wt_of(q) * cond.y; }
|
||||
if k == 2u { return back + cond.y; }
|
||||
return back;
|
||||
}
|
||||
|
||||
// Second pass, one thread per link of the first seven directions.
|
||||
@compute @workgroup_size(256)
|
||||
fn stream_links_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let l = cell_of(lid, wg);
|
||||
if l >= params.links { return; }
|
||||
let lu = link_u[l];
|
||||
let q = lu.x & 0xffu;
|
||||
if q >= QT { return; }
|
||||
let kind = (lu.x >> 8u) & 0xffu;
|
||||
let cell = lu.w;
|
||||
t2[q * params.n + cell] = thermal_rule(lu, q, kind, cell);
|
||||
}
|
||||
|
||||
// Inlets hold their temperature (the equilibrium at the cell's velocity); outlets take the
|
||||
// streamed populations of the neighbour one cell inward.
|
||||
@compute @workgroup_size(256)
|
||||
fn open_thermal(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let o = cell_of(lid, wg);
|
||||
if o >= params.open { return; }
|
||||
let n = params.n;
|
||||
let ou = open_u[o];
|
||||
let cell = ou.x;
|
||||
let held = open_t[o];
|
||||
if held.x > 0.5 {
|
||||
let u = moments[cell].xyz;
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
t2[q * n + cell] = eq_t(q, held.y, u);
|
||||
}
|
||||
} else {
|
||||
var step = 1;
|
||||
if ou.y == 1u { step = i32(params.nx); }
|
||||
if ou.y == 2u { step = i32(params.nx * params.ny); }
|
||||
let src = u32(i32(cell) + i32(open_f[o].w) * step);
|
||||
for (var q = 0u; q < QT; q = q + 1u) {
|
||||
t2[q * n + cell] = t2[q * n + src];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flags a temperature that stopped being a number.
|
||||
@compute @workgroup_size(256)
|
||||
fn thermal_check(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
if cell >= params.n { return; }
|
||||
if !is_fluid(cell) { return; }
|
||||
let theta = theta_of(cell);
|
||||
if theta != theta || abs(theta) > 1e30 { atomicStore(&stats[2], 1u); }
|
||||
}
|
||||
|
||||
// One workgroup per group: the sum of its links' contributions.
|
||||
@compute @workgroup_size(256)
|
||||
fn group_forces(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let group = wg.y * params.groups_x + wg.x;
|
||||
if group >= params.groups { return; }
|
||||
let lo = group_start[group];
|
||||
let hi = group_start[group + 1u];
|
||||
var s = vec4<f32>(0.0);
|
||||
for (var i = lo + lid; i < hi; i = i + WG) {
|
||||
s = s + contrib[by_group[i]];
|
||||
}
|
||||
red[lid] = s;
|
||||
workgroupBarrier();
|
||||
for (var k = 128u; k > 0u; k = k >> 1u) {
|
||||
if lid < k { red[lid] = red[lid] + red[lid + k]; }
|
||||
workgroupBarrier();
|
||||
}
|
||||
if lid == 0u { forces[group] = red[0]; }
|
||||
}
|
||||
|
||||
// The density and velocity (with the half force) of every cell from f; zero in solid cells.
|
||||
@compute @workgroup_size(256)
|
||||
fn cell_moments(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let cell = cell_of(lid, wg);
|
||||
if cell >= params.n { return; }
|
||||
if !is_fluid(cell) {
|
||||
moments[cell] = vec4<f32>(0.0);
|
||||
return;
|
||||
}
|
||||
let n = params.n;
|
||||
var drho = 0.0;
|
||||
var j = vec3<f32>(0.0);
|
||||
let xyz = aa_coords(cell);
|
||||
for (var q = 0u; q < Q; q = q + 1u) {
|
||||
let v = population(q, cell, xyz);
|
||||
drho = drho + v;
|
||||
j = j + vec3<f32>(c_of(q)) * v;
|
||||
}
|
||||
let rho = 1.0 + drho;
|
||||
let u = (j + 0.5 * rho * acc_of(cell)) / rho;
|
||||
moments[cell] = vec4<f32>(u, rho);
|
||||
}
|
||||
|
||||
// Per workgroup, the sum of its cells' densities (the mass, since every cell has unit volume).
|
||||
@compute @workgroup_size(256)
|
||||
fn mass(@builtin(local_invocation_index) lid: u32, @builtin(workgroup_id) wg: vec3<u32>) {
|
||||
let group = wg.y * params.groups_x + wg.x;
|
||||
let cell = group * WG + lid;
|
||||
var v = 0.0;
|
||||
if cell < params.n && is_fluid(cell) {
|
||||
v = moments[cell].w;
|
||||
}
|
||||
red[lid] = vec4<f32>(v, 0.0, 0.0, 0.0);
|
||||
workgroupBarrier();
|
||||
for (var k = 128u; k > 0u; k = k >> 1u) {
|
||||
if lid < k { red[lid] = red[lid] + red[lid + k]; }
|
||||
workgroupBarrier();
|
||||
}
|
||||
if lid == 0u { partials[group] = red[0].x; }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! The lattice Boltzmann solver on the GPU: [`GpuFlow`] steps a flow that
|
||||
//! [`fieldcraft_lbm::Flow`] prepared (grid, links, open cells, initial state) with the compute
|
||||
//! shaders of `lbm.wgsl`, which are the CPU scheme's collision, streaming rules, open boundaries
|
||||
//! and momentum exchange, and with heat its temperature lattice, written for the device; `fieldcraft_lbm::run` drives it through the
|
||||
//! [`Backend`] trait in chunks of steps, so populations stay on the device and only frames,
|
||||
//! force histories and the stability check come back.
|
||||
//!
|
||||
//! Single precision: the CPU scheme is the reference, and this backend is deterministic on its
|
||||
//! device and agrees with it within the tolerances its tests state (`tests/verification.rs`).
|
||||
//! The device is the one `fieldcraft_gpu`'s adapter policy picks (`FIELDCRAFT_GPU`).
|
||||
//!
|
||||
//! Sources: the same as `fieldcraft_lbm` (Krüger et al. 2017; Ginzburg et al. 2008; Guo et al.
|
||||
//! 2002; Ladd 1994; Mei et al. 2002; Zou & He 1997; Hecht & Harting 2010). No code of any
|
||||
//! lattice Boltzmann package was read.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::{GpuFlow, KernelTime, Options, Streaming, register};
|
||||
|
||||
/// In the browser there is no device backend yet (WebGPU later): registering is a no-op and
|
||||
/// `solve.fluid` with `backend: "gpu"` reports that none is available.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn register() {}
|
||||
@@ -1,827 +0,0 @@
|
||||
//! The device backend (native targets).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use fieldcraft_gpu::select::{self, Policy};
|
||||
use fieldcraft_lbm::backend::{Backend, Fields};
|
||||
use fieldcraft_lbm::lattice::{C, Q, W};
|
||||
use fieldcraft_lbm::setup::MAX_LATTICE_SPEED;
|
||||
use fieldcraft_lbm::{Error, Flow, Group, Result, Rule, Target};
|
||||
use fieldcraft_model::ThermalCondition;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const SHADER: &str = include_str!("lbm.wgsl");
|
||||
const WG: u32 = 256;
|
||||
const MAX_GROUPS_X: u32 = 32768;
|
||||
const KIND_PULL: u32 = 0;
|
||||
const KIND_BOUNCE: u32 = 1;
|
||||
const KIND_REFLECT: u32 = 2;
|
||||
const KIND_OPEN: u32 = 3;
|
||||
/// The scalar lattice's directions (the first seven of D3Q19) and weights.
|
||||
const QT: usize = 7;
|
||||
const WT: [f64; QT] = [0.25, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125];
|
||||
|
||||
fn gpu_error(e: impl std::fmt::Display) -> Error {
|
||||
Error::Invalid(format!("GPU: {e}"))
|
||||
}
|
||||
|
||||
/// Steps per submission: the stability check and a cancel take effect between submissions.
|
||||
const MAX_CHUNK: u64 = 64;
|
||||
|
||||
/// How long one wait for the device may take before the solve gives up on it.
|
||||
const DEVICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// A bounded chunk's dispatches, shared by the normal and timestamped paths.
|
||||
type Dispatch = (usize, &'static str, (u32, u32));
|
||||
|
||||
/// A vector of `len` copies of `value`, or [`Error::Limit`] when memory runs out (the host
|
||||
/// arrays are sized by the grid: 16 M cells is over a gigabyte of populations).
|
||||
fn filled<T: Clone>(len: usize, value: T, what: &str) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| Error::Limit(format!("not enough memory for the {what} ({len} entries)")))?;
|
||||
v.resize(len, value);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// An empty vector with room for `len`, or [`Error::Limit`].
|
||||
fn reserved<T>(len: usize, what: &str) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| Error::Limit(format!("not enough memory for the {what} ({len} entries)")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Workgroup counts (x, y) covering `count` items.
|
||||
fn groups(count: usize) -> Result<(u32, u32)> {
|
||||
let total = u32::try_from(count.div_ceil(WG as usize).max(1)).map_err(|_| Error::Limit("too many cells for one dispatch".into()))?;
|
||||
let gx = total.min(MAX_GROUPS_X);
|
||||
let gy = total.div_ceil(gx);
|
||||
if gy > 65535 {
|
||||
return Err(Error::Limit("too many cells for one dispatch".into()));
|
||||
}
|
||||
Ok((gx, gy))
|
||||
}
|
||||
|
||||
fn bytes_u32(v: &[u32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn bytes_f32(v: &[f32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Register this backend with `fieldcraft_lbm` (an application does this once at start), so
|
||||
/// `solve.fluid` with `backend: "gpu"` steps on the device `FIELDCRAFT_GPU` picks.
|
||||
pub fn register() {
|
||||
fieldcraft_lbm::register_backend(Box::new(|flow, _setup| GpuFlow::from_env(flow).map(|g| Box::new(g) as Box<dyn Backend>)));
|
||||
}
|
||||
|
||||
/// How the momentum populations stream (see `lbm.wgsl`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Streaming {
|
||||
/// In place: measured faster on every device tried (RTX 4090, GB10, M4 Max; #563).
|
||||
#[default]
|
||||
Auto,
|
||||
/// One population buffer, collided and streamed in place (the AA pattern).
|
||||
InPlace,
|
||||
/// Two population buffers: collide in place, then pull into the other (A–B).
|
||||
TwoBuffers,
|
||||
}
|
||||
|
||||
/// How a [`GpuFlow`] runs; the defaults are what a solve uses.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Options {
|
||||
pub streaming: Streaming,
|
||||
/// Time every kernel with timestamp queries ([`GpuFlow::kernel_times`]). Each dispatch then
|
||||
/// gets its own pass and every chunk waits for its timestamps: for measurements, not solves.
|
||||
pub profile: bool,
|
||||
}
|
||||
|
||||
/// GPU time per kernel, summed over the dispatches since the flow was created (with
|
||||
/// [`Options::profile`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct KernelTime {
|
||||
pub dispatches: u64,
|
||||
pub seconds: f64,
|
||||
}
|
||||
|
||||
/// A prepared flow on the device.
|
||||
pub struct GpuFlow {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
/// With [`Options::profile`]: the GPU time of each kernel so far.
|
||||
kernel_times: Option<std::cell::RefCell<std::collections::BTreeMap<&'static str, KernelTime>>>,
|
||||
/// How long creating the compute pipelines (compiling the shader) took.
|
||||
pipeline_seconds: f64,
|
||||
aa: bool,
|
||||
adapter: String,
|
||||
pipelines: HashMap<&'static str, wgpu::ComputePipeline>,
|
||||
/// AB swaps populations; AA keeps one population buffer and changes its orientation.
|
||||
/// Both modes swap the separate scalar lattice buffers.
|
||||
bind: [wgpu::BindGroup; 2],
|
||||
/// Which bind group's `f` holds the state.
|
||||
current: usize,
|
||||
n: usize,
|
||||
cells: (u32, u32),
|
||||
link_count: usize,
|
||||
links_groups: (u32, u32),
|
||||
open_count: usize,
|
||||
open_groups: (u32, u32),
|
||||
group_count: usize,
|
||||
force_groups: (u32, u32),
|
||||
stats: wgpu::Buffer,
|
||||
moments: wgpu::Buffer,
|
||||
forces: wgpu::Buffer,
|
||||
partials: wgpu::Buffer,
|
||||
partial_count: usize,
|
||||
/// The scalar populations remain A–B with heat; and T₀ they are shifted by.
|
||||
thermal: Option<[wgpu::Buffer; 2]>,
|
||||
initial_temperature: f64,
|
||||
fluid: Vec<bool>,
|
||||
fluid_cells: usize,
|
||||
groups: Vec<Group>,
|
||||
empty_obstacles: Vec<String>,
|
||||
steps_done: u64,
|
||||
/// The momentum (xyz) and heat (w) each group received in the last step, once read back
|
||||
/// (zero before the first step).
|
||||
exchange_cache: Option<Vec<[f64; 4]>>,
|
||||
/// Each group's share of the momentum from the populations' weights, which the kernel leaves
|
||||
/// out (`link_forces` sums the deviations): added to what it reads back.
|
||||
weight_forces: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl GpuFlow {
|
||||
/// `flow` on the device `FIELDCRAFT_GPU` picks.
|
||||
pub fn from_env(flow: &Flow) -> Result<GpuFlow> {
|
||||
let policy = Policy::from_env().map_err(gpu_error)?;
|
||||
GpuFlow::new(flow, policy)
|
||||
}
|
||||
|
||||
/// `flow` (its grid, rules and initial state: everything the step needs) on the device
|
||||
/// `policy` picks.
|
||||
pub fn new(flow: &Flow, policy: Policy) -> Result<GpuFlow> {
|
||||
GpuFlow::with_options(flow, policy, Options::default())
|
||||
}
|
||||
|
||||
/// [`GpuFlow::new`] with `options`.
|
||||
pub fn with_options(flow: &Flow, policy: Policy, options: Options) -> Result<GpuFlow> {
|
||||
let Some((adapter, tier)) = select::adapter(policy).map_err(gpu_error)? else {
|
||||
return Err(Error::Invalid(format!("{}=cpu: no GPU device; solve with `backend: \"cpu\"`", select::ENV)));
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
let profile = options.profile;
|
||||
let features = if profile { wgpu::Features::TIMESTAMP_QUERY } else { wgpu::Features::empty() };
|
||||
if !adapter.features().contains(features) {
|
||||
return Err(gpu_error("profiling needs timestamp queries, which this adapter does not offer"));
|
||||
}
|
||||
let have = adapter.limits();
|
||||
let mut limits = wgpu::Limits::default().using_resolution(have.clone());
|
||||
limits.max_buffer_size = have.max_buffer_size;
|
||||
limits.max_storage_buffer_binding_size = have.max_storage_buffer_binding_size;
|
||||
limits.max_storage_buffers_per_shader_stage = have.max_storage_buffers_per_shader_stage.max(limits.max_storage_buffers_per_shader_stage);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("fieldcraft lbm"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(gpu_error)?;
|
||||
let adapter_name = format!("{}{}", info.name, if tier == select::Tier::Software { " (software)" } else { "" });
|
||||
|
||||
let aa = match options.streaming {
|
||||
Streaming::Auto | Streaming::InPlace => true,
|
||||
Streaming::TwoBuffers => false,
|
||||
};
|
||||
let n = flow.grid().len();
|
||||
let elem = 4u64;
|
||||
let pop_bytes = elem * (Q * n) as u64;
|
||||
let cap = device.limits().max_storage_buffer_binding_size.min(device.limits().max_buffer_size);
|
||||
if pop_bytes > cap {
|
||||
return Err(Error::Limit(format!(
|
||||
"{n} cells need {} MB of populations per buffer; this GPU allows {} MB per buffer: coarsen the spacing",
|
||||
pop_bytes >> 20,
|
||||
cap >> 20
|
||||
)));
|
||||
}
|
||||
|
||||
// The state and the flags.
|
||||
// Populations as deviations from their weights (see lbm.wgsl); solid cells stay zero.
|
||||
let mut f = filled(Q * n, 0.0f32, "populations")?;
|
||||
for q in 0..Q {
|
||||
let w = fieldcraft_lbm::lattice::W.get(q).copied().unwrap_or(0.0);
|
||||
for cell in 0..n {
|
||||
if let (Some(slot), true) = (f.get_mut(q * n + cell), flow.is_fluid(cell)) {
|
||||
*slot = (flow.population(q, cell) - w) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The scalar populations as deviations from w_q T₀ (see lbm.wgsl).
|
||||
let thermal = flow.thermal();
|
||||
let t0 = thermal.map_or(0.0, |t| t.initial());
|
||||
let mut tpop = filled(if thermal.is_some() { QT * n } else { 0 }, 0.0f32, "temperature populations")?;
|
||||
if thermal.is_some() {
|
||||
for q in 0..QT {
|
||||
let w = WT.get(q).copied().unwrap_or(0.0) * t0;
|
||||
for cell in 0..n {
|
||||
if let (Some(slot), true) = (tpop.get_mut(q * n + cell), flow.is_fluid(cell)) {
|
||||
*slot = (flow.thermal_population(q, cell) - w) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut fluid = filled(n, false, "cell flags")?;
|
||||
let mut fluid_u32 = filled(n, 0u32, "cell flags")?;
|
||||
for cell in 0..n {
|
||||
if let (Some(b), Some(u)) = (fluid.get_mut(cell), fluid_u32.get_mut(cell)) {
|
||||
*b = flow.is_fluid(cell);
|
||||
*u = u32::from(*b);
|
||||
}
|
||||
}
|
||||
|
||||
// The links, by cell, and the ones with a force by group.
|
||||
let links = flow.links();
|
||||
let mut link_u = reserved(4 * links.len(), "links")?;
|
||||
let mut link_f = reserved(links.len(), "links")?;
|
||||
let mut by_group: Vec<(u32, u32)> = reserved(links.len(), "links by group")?;
|
||||
let group_count = flow.groups().len();
|
||||
let mut weight_forces = filled(group_count, [0.0f64; 3], "force groups")?;
|
||||
let c = |q: u32| C.get(q as usize).copied().unwrap_or([0; 3]).map(f64::from);
|
||||
let w = |q: u32| W.get(q as usize).copied().unwrap_or(0.0);
|
||||
for (l, link) in links.iter().enumerate() {
|
||||
let (kind, from, group, q2, du) = match link.rule {
|
||||
Rule::Pull { from } => (KIND_PULL, from, 0, 0, 0.0),
|
||||
Rule::Bounce { group, du } => (KIND_BOUNCE, 0, group, 0, du),
|
||||
Rule::Reflect { from, q, group } => (KIND_REFLECT, from, group, u32::from(q), 0.0),
|
||||
Rule::Open => (KIND_OPEN, 0, 0, 0, 0.0),
|
||||
};
|
||||
// What the weights add to the link's exchange (see `link_forces`).
|
||||
let weight = match link.rule {
|
||||
Rule::Bounce { .. } => c(u32::from(link.q)).map(|x| -2.0 * w(u32::from(link.q)) * x),
|
||||
Rule::Reflect { .. } => std::array::from_fn(|a| (c(q2)[a] - c(u32::from(link.q))[a]) * w(q2)),
|
||||
_ => [0.0; 3],
|
||||
};
|
||||
if matches!(link.rule, Rule::Bounce { .. } | Rule::Reflect { .. }) {
|
||||
by_group.push((group, l as u32));
|
||||
if let Some(g) = weight_forces.get_mut(group as usize) {
|
||||
for (s, x) in g.iter_mut().zip(weight) {
|
||||
*s += x;
|
||||
}
|
||||
}
|
||||
}
|
||||
link_u.extend_from_slice(&[u32::from(link.q) | (kind << 8) | (q2 << 16), from, group, link.cell]);
|
||||
link_f.push(du as f32);
|
||||
}
|
||||
by_group.sort();
|
||||
let mut group_start = filled(group_count + 1, 0u32, "force groups")?;
|
||||
for (group, _) in &by_group {
|
||||
if let Some(next) = group_start.get_mut(*group as usize + 1) {
|
||||
*next += 1;
|
||||
}
|
||||
}
|
||||
for g in 0..group_count {
|
||||
let prev = group_start.get(g).copied().unwrap_or(0);
|
||||
if let Some(next) = group_start.get_mut(g + 1) {
|
||||
*next += prev;
|
||||
}
|
||||
}
|
||||
let by_group: Vec<u32> = by_group.iter().map(|(_, l)| *l).collect();
|
||||
// Each group's thermal condition (held temperatures shifted by T₀).
|
||||
let mut group_t = reserved(4 * group_count, "thermal conditions")?;
|
||||
for g in 0..group_count {
|
||||
let (kind, value) = match thermal.and_then(|t| t.groups().get(g)).copied().unwrap_or_default() {
|
||||
ThermalCondition::Adiabatic {} => (0.0, 0.0),
|
||||
ThermalCondition::Temperature { value } => (1.0, (value - t0) as f32),
|
||||
ThermalCondition::Flux { value } => (2.0, value as f32),
|
||||
};
|
||||
group_t.extend_from_slice(&[kind, value, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
// The open cells.
|
||||
let open = flow.open_cells();
|
||||
let mut open_u = reserved(4 * open.len(), "open cells")?;
|
||||
let mut open_f = reserved(4 * open.len(), "open cells")?;
|
||||
for oc in open {
|
||||
let (kind, target) = match oc.target {
|
||||
Target::Velocity(u) => (0u32, [u[0] as f32, u[1] as f32, u[2] as f32]),
|
||||
Target::Density(rho) => (1u32, [rho as f32, 0.0, 0.0]),
|
||||
};
|
||||
open_u.extend_from_slice(&[oc.cell, oc.axis as u32, kind, 0]);
|
||||
open_f.extend_from_slice(&[target[0], target[1], target[2], oc.inward as f32]);
|
||||
}
|
||||
let mut open_t = reserved(4 * open.len(), "open cell temperatures")?;
|
||||
for k in 0..open.len() {
|
||||
match thermal.and_then(|t| t.open().get(k)).copied().flatten() {
|
||||
Some(value) => open_t.extend_from_slice(&[1.0, (value - t0) as f32, 0.0, 0.0]),
|
||||
None => open_t.extend_from_slice(&[0.0; 4]),
|
||||
}
|
||||
}
|
||||
|
||||
let (omega_plus, omega_minus) = flow.omegas();
|
||||
let acc = flow.acceleration();
|
||||
let forced = acc != [0.0; 3];
|
||||
let cells = groups(n)?;
|
||||
let links_groups = groups(links.len())?;
|
||||
let open_groups = groups(open.len())?;
|
||||
let force_groups = groups((group_count * WG as usize).max(1))?;
|
||||
let partial_count = (cells.0 as usize) * (cells.1 as usize);
|
||||
let mut params = Vec::with_capacity(64);
|
||||
let u = |v: usize| u32::try_from(v).map_err(|_| Error::Limit("too many cells for the GPU solver".into()));
|
||||
for v in [
|
||||
u(n)?,
|
||||
u(flow.grid().cells[0])?,
|
||||
u(flow.grid().cells[1])?,
|
||||
u(flow.grid().cells[2])?,
|
||||
cells.0,
|
||||
u(links.len())?,
|
||||
u(open.len())?,
|
||||
u(group_count)?,
|
||||
] {
|
||||
params.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for v in [
|
||||
omega_plus as f32,
|
||||
omega_minus as f32,
|
||||
(1.0 - 0.5 * omega_plus) as f32,
|
||||
(1.0 - 0.5 * omega_minus) as f32,
|
||||
acc[0] as f32,
|
||||
acc[1] as f32,
|
||||
acc[2] as f32,
|
||||
if forced { 1.0 } else { 0.0 },
|
||||
] {
|
||||
params.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let buoyancy = thermal.map_or([0.0; 3], |t| t.buoyancy());
|
||||
for v in [
|
||||
buoyancy[0] as f32,
|
||||
buoyancy[1] as f32,
|
||||
buoyancy[2] as f32,
|
||||
if buoyancy != [0.0; 3] { 1.0 } else { 0.0 },
|
||||
thermal.map_or(0.0, |t| t.omega()) as f32,
|
||||
t0 as f32,
|
||||
if thermal.is_some() { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
] {
|
||||
params.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
// Buffers, with the device's out-of-memory errors caught.
|
||||
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
|
||||
let storage = |label: &str, bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: if bytes.is_empty() { &[0u8; 16][..] } else { bytes },
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
})
|
||||
};
|
||||
let empty = |label: &str, size: u64| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let params = [0u32, 1].map(|phase| {
|
||||
let mut bytes = params.clone();
|
||||
bytes.extend_from_slice(&bytes_u32(&[phase, 0, 0, 0]));
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("lbm params"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
})
|
||||
});
|
||||
let fluid_buf = storage("lbm fluid", &bytes_u32(&fluid_u32));
|
||||
// AA reuses binding g for boundary scratch only, never another full population field.
|
||||
let pops = [storage("lbm f", &bytes_f32(&f)), empty("lbm g / AA boundary scratch", if aa { 4 * links.len() as u64 } else { pop_bytes })];
|
||||
let link_u = storage("lbm links", &bytes_u32(&link_u));
|
||||
let link_f = storage("lbm link du", &bytes_f32(&link_f));
|
||||
let open_u = storage("lbm open cells", &bytes_u32(&open_u));
|
||||
let open_f = storage("lbm open targets", &bytes_f32(&open_f));
|
||||
let moments = empty("lbm moments", 16 * n as u64);
|
||||
let stats = storage("lbm stats", &bytes_u32(&[0, 0, 0, 0]));
|
||||
let contrib = empty("lbm link forces", 16 * links.len() as u64);
|
||||
let by_group = storage("lbm links by group", &bytes_u32(&by_group));
|
||||
let group_start = storage("lbm group start", &bytes_u32(&group_start));
|
||||
let forces = empty("lbm forces", 16 * group_count as u64);
|
||||
let partials = empty("lbm partials", 4 * partial_count as u64);
|
||||
let therm_bytes = elem * (QT * n) as u64;
|
||||
let therm = if thermal.is_some() {
|
||||
[storage("lbm t", &bytes_f32(&tpop)), empty("lbm t2", therm_bytes)]
|
||||
} else {
|
||||
[empty("lbm t", 16), empty("lbm t2", 16)]
|
||||
};
|
||||
let group_t = storage("lbm thermal conditions", &bytes_f32(&group_t));
|
||||
let open_t = storage("lbm open temperatures", &bytes_f32(&open_t));
|
||||
|
||||
let module = device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("fieldcraft lbm"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let entry = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("fieldcraft lbm"),
|
||||
entries: &[
|
||||
entry(0, wgpu::BufferBindingType::Uniform),
|
||||
entry(1, read),
|
||||
entry(2, write),
|
||||
entry(3, write),
|
||||
entry(5, read),
|
||||
entry(6, read),
|
||||
entry(7, read),
|
||||
entry(8, read),
|
||||
entry(9, write),
|
||||
entry(10, write),
|
||||
entry(11, write),
|
||||
entry(12, read),
|
||||
entry(13, read),
|
||||
entry(14, write),
|
||||
entry(15, write),
|
||||
entry(16, write),
|
||||
entry(17, write),
|
||||
entry(18, read),
|
||||
entry(19, read),
|
||||
],
|
||||
});
|
||||
let bind = [0usize, 1].map(|k| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("fieldcraft lbm"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: params[k].as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: fluid_buf.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: pops[if aa { 0 } else { k }].as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: pops[if aa { 1 } else { 1 - k }].as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: link_u.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 6, resource: link_f.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 7, resource: open_u.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 8, resource: open_f.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 9, resource: moments.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 10, resource: stats.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 11, resource: contrib.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 12, resource: by_group.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 13, resource: group_start.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 14, resource: forces.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 15, resource: partials.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 16, resource: therm[k].as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 17, resource: therm[1 - k].as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 18, resource: group_t.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 19, resource: open_t.as_entire_binding() },
|
||||
],
|
||||
})
|
||||
});
|
||||
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("fieldcraft lbm"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let mut pipelines = HashMap::new();
|
||||
let pipeline_start = std::time::Instant::now();
|
||||
for name in [
|
||||
"collide",
|
||||
"stream",
|
||||
"stream_links",
|
||||
"stream_links_exchange",
|
||||
"scatter_links",
|
||||
"open_boundaries",
|
||||
"link_forces",
|
||||
"group_forces",
|
||||
"cell_moments",
|
||||
"mass",
|
||||
"collide_thermal",
|
||||
"stream_thermal",
|
||||
"stream_links_thermal",
|
||||
"open_thermal",
|
||||
"thermal_check",
|
||||
] {
|
||||
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(name),
|
||||
layout: Some(&pl),
|
||||
module: &module,
|
||||
entry_point: Some(name),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("AA_MODE", if aa { 1.0 } else { 0.0 })], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
pipelines.insert(name, p);
|
||||
}
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(gpu_error(e));
|
||||
}
|
||||
}
|
||||
let pipeline_seconds = pipeline_start.elapsed().as_secs_f64();
|
||||
Ok(GpuFlow {
|
||||
device,
|
||||
queue,
|
||||
kernel_times: profile.then(Default::default),
|
||||
pipeline_seconds,
|
||||
aa,
|
||||
adapter: adapter_name,
|
||||
pipelines,
|
||||
bind,
|
||||
current: 0,
|
||||
n,
|
||||
cells,
|
||||
link_count: links.len(),
|
||||
links_groups,
|
||||
open_count: open.len(),
|
||||
open_groups,
|
||||
group_count,
|
||||
force_groups,
|
||||
stats,
|
||||
moments,
|
||||
forces,
|
||||
partials,
|
||||
partial_count,
|
||||
thermal: thermal.map(|_| therm),
|
||||
initial_temperature: t0,
|
||||
fluid_cells: flow.fluid_cells(),
|
||||
fluid,
|
||||
groups: flow.groups().to_vec(),
|
||||
empty_obstacles: flow.empty_obstacles().to_vec(),
|
||||
steps_done: 0,
|
||||
exchange_cache: Some(vec![[0.0; 4]; group_count]),
|
||||
weight_forces,
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter's name.
|
||||
pub fn adapter_name(&self) -> &str {
|
||||
&self.adapter
|
||||
}
|
||||
|
||||
/// Whether the populations stream in place (see [`Streaming`]).
|
||||
pub fn streams_in_place(&self) -> bool {
|
||||
self.aa
|
||||
}
|
||||
|
||||
/// How long creating the compute pipelines took, in seconds.
|
||||
pub fn pipeline_seconds(&self) -> f64 {
|
||||
self.pipeline_seconds
|
||||
}
|
||||
|
||||
/// With [`Options::profile`], the GPU time of each kernel so far, by kernel name.
|
||||
pub fn kernel_times(&self) -> Option<std::collections::BTreeMap<&'static str, KernelTime>> {
|
||||
self.kernel_times.as_ref().map(|t| t.borrow().clone())
|
||||
}
|
||||
|
||||
fn pipeline(&self, name: &str) -> Result<&wgpu::ComputePipeline> {
|
||||
self.pipelines.get(name).ok_or_else(|| Error::Invalid(format!("GPU: no kernel {name}")))
|
||||
}
|
||||
|
||||
fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>, bind: usize, name: &str, (gx, gy): (u32, u32)) -> Result<()> {
|
||||
pass.set_bind_group(0, self.bind.get(bind).ok_or_else(|| Error::Invalid("GPU: no bind group".into()))?, &[]);
|
||||
pass.set_pipeline(self.pipeline(name)?);
|
||||
pass.dispatch_workgroups(gx, gy, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Timestamped passes are diagnostic: their extra pass boundaries and readback are not
|
||||
/// used for throughput measurements. Normally every chunk remains one compute pass.
|
||||
fn submit(&self, label: &str, commands: &[Dispatch]) -> Result<()> {
|
||||
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
|
||||
let Some(times) = &self.kernel_times else {
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(label), timestamp_writes: None });
|
||||
for &(bind, name, groups) in commands {
|
||||
self.dispatch(&mut pass, bind, name, groups)?;
|
||||
}
|
||||
}
|
||||
self.queue.submit([enc.finish()]);
|
||||
return Ok(());
|
||||
};
|
||||
let count = u32::try_from(commands.len() * 2).map_err(|_| gpu_error("too many timestamp queries"))?;
|
||||
if count == 0 || count > wgpu::QUERY_SET_MAX_QUERIES {
|
||||
return Err(gpu_error("timestamp query count is outside the device limit"));
|
||||
}
|
||||
let query = self.device.create_query_set(&wgpu::QuerySetDescriptor { label: Some(label), ty: wgpu::QueryType::Timestamp, count });
|
||||
let result = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("lbm timestamps"),
|
||||
size: u64::from(count) * 8,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
for (i, &(bind, name, groups)) in commands.iter().enumerate() {
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some(name),
|
||||
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
|
||||
query_set: &query,
|
||||
beginning_of_pass_write_index: Some(2 * i as u32),
|
||||
end_of_pass_write_index: Some(2 * i as u32 + 1),
|
||||
}),
|
||||
});
|
||||
self.dispatch(&mut pass, bind, name, groups)?;
|
||||
}
|
||||
enc.resolve_query_set(&query, 0..count, &result, 0);
|
||||
self.queue.submit([enc.finish()]);
|
||||
let bytes = self.read(&result, u64::from(count) * 8)?;
|
||||
let period = f64::from(self.queue.get_timestamp_period());
|
||||
let mut totals = times.borrow_mut();
|
||||
for ((_, name, _), pair) in commands.iter().zip(bytes.as_chunks::<16>().0) {
|
||||
let [begin, end] = pair.as_chunks::<8>().0 else { return Err(gpu_error("invalid timestamp pair")) };
|
||||
let ticks = u64::from_le_bytes(*end).checked_sub(u64::from_le_bytes(*begin)).ok_or_else(|| gpu_error("invalid GPU timestamps"))?;
|
||||
let total = totals.entry(name).or_default();
|
||||
total.dispatches += 1;
|
||||
total.seconds += ticks as f64 * period * 1e-9;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy `size` bytes of `src` back to the host.
|
||||
fn read(&self, src: &wgpu::Buffer, size: u64) -> Result<Vec<u8>> {
|
||||
let size = size.max(16);
|
||||
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("lbm readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("lbm readback") });
|
||||
enc.copy_buffer_to_buffer(src, 0, &staging, 0, size);
|
||||
self.queue.submit([enc.finish()]);
|
||||
let slice = staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
// A bounded wait: a hung or lost device is an error, never a hang.
|
||||
match self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(DEVICE_TIMEOUT) }) {
|
||||
Ok(_) => {}
|
||||
Err(e) => return Err(gpu_error(format!("the device did not answer within {} s: {e}", DEVICE_TIMEOUT.as_secs()))),
|
||||
}
|
||||
rx.try_recv().map_err(|_| gpu_error("the readback did not complete"))?.map_err(gpu_error)?;
|
||||
let bytes = slice.get_mapped_range().map_err(gpu_error)?.to_vec();
|
||||
staging.unmap();
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_f32(&self, src: &wgpu::Buffer, count: usize) -> Result<Vec<f32>> {
|
||||
let bytes = self.read(src, 4 * count as u64)?;
|
||||
Ok(bytes.as_chunks::<4>().0.iter().take(count).map(|b| f32::from_le_bytes(*b)).collect())
|
||||
}
|
||||
|
||||
/// The momentum (xyz) and heat (w) each group received in the last step.
|
||||
fn exchange(&mut self) -> Result<&[[f64; 4]]> {
|
||||
if self.exchange_cache.is_none() {
|
||||
let v = self.read_f32(&self.forces, 4 * self.group_count)?;
|
||||
self.exchange_cache = Some(
|
||||
v.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&self.weight_forces)
|
||||
.map(|(g, w)| [f64::from(g[0]) + w[0], f64::from(g[1]) + w[1], f64::from(g[2]) + w[2], f64::from(g[3])])
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Ok(self.exchange_cache.as_deref().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The moments of the current state into the moments buffer.
|
||||
fn compute_moments(&self) -> Result<()> {
|
||||
self.submit("lbm moments", &[(self.current, "cell_moments", self.cells)])
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuFlow {
|
||||
/// One submission of `steps` (≤ MAX_CHUNK) time steps, then the stability check.
|
||||
fn advance_chunk(&mut self, steps: u64) -> Result<()> {
|
||||
self.queue.write_buffer(&self.stats, 0, &bytes_u32(&[0, 0, 0, 0]));
|
||||
let mut commands = Vec::with_capacity(8 * MAX_CHUNK as usize + 3);
|
||||
for step in 0..steps {
|
||||
commands.push((self.current, "collide", self.cells));
|
||||
if !self.aa {
|
||||
commands.push((self.current, "stream", self.cells));
|
||||
}
|
||||
if self.link_count > 0 {
|
||||
// The last step's momentum exchange is the one read back.
|
||||
let exchange = self.aa && step + 1 == steps;
|
||||
commands.push((self.current, if exchange { "stream_links_exchange" } else { "stream_links" }, self.links_groups));
|
||||
if self.aa {
|
||||
commands.push((self.current, "scatter_links", self.links_groups));
|
||||
}
|
||||
}
|
||||
if self.open_count > 0 {
|
||||
commands.push((self.current, "open_boundaries", self.open_groups));
|
||||
}
|
||||
if self.thermal.is_some() {
|
||||
commands.push((self.current, "collide_thermal", self.cells));
|
||||
commands.push((self.current, "stream_thermal", self.cells));
|
||||
if self.link_count > 0 {
|
||||
commands.push((self.current, "stream_links_thermal", self.links_groups));
|
||||
}
|
||||
if self.open_count > 0 {
|
||||
commands.push((self.current, "open_thermal", self.open_groups));
|
||||
}
|
||||
}
|
||||
self.current ^= 1;
|
||||
}
|
||||
// The momentum (and heat) exchange of the last step, from its post-collision
|
||||
// populations.
|
||||
if self.link_count > 0 && self.group_count > 0 {
|
||||
commands.push((self.current ^ 1, "link_forces", self.links_groups));
|
||||
commands.push((self.current ^ 1, "group_forces", self.force_groups));
|
||||
}
|
||||
if self.thermal.is_some() {
|
||||
commands.push((self.current, "thermal_check", self.cells));
|
||||
}
|
||||
self.submit("lbm steps", &commands)?;
|
||||
self.steps_done += steps;
|
||||
self.exchange_cache = None;
|
||||
let stats = self.read(&self.stats, 16)?;
|
||||
let word = |k: usize| stats.get(4 * k..4 * k + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes).unwrap_or(0);
|
||||
let max_u2 = f32::from_bits(word(0));
|
||||
if word(1) != 0 || !(f64::from(max_u2) <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
|
||||
return Err(Error::Unstable(format!(
|
||||
"the flow went unstable by time step {}: the lattice speed reached {:.3} (limit {MAX_LATTICE_SPEED}) or a density stopped being a positive number; lower the time step, refine the spacing or check the boundary conditions",
|
||||
self.steps_done,
|
||||
max_u2.sqrt()
|
||||
)));
|
||||
}
|
||||
if word(2) != 0 {
|
||||
return Err(Error::Unstable(format!(
|
||||
"the heat went unstable by time step {}: a temperature stopped being a number; lower the time step, refine the spacing or check the heat fluxes",
|
||||
self.steps_done
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for GpuFlow {
|
||||
fn name(&self) -> String {
|
||||
format!("gpu: {}, {} streaming", self.adapter, if self.aa { "in-place" } else { "two-buffer" })
|
||||
}
|
||||
|
||||
fn advance(&mut self, steps: u64) -> Result<()> {
|
||||
// Submissions of at most MAX_CHUNK steps: the stability check runs after each, so a
|
||||
// run that blows up is caught within that many steps whatever the caller asks for.
|
||||
let mut left = steps;
|
||||
while left > 0 {
|
||||
let chunk = left.min(MAX_CHUNK);
|
||||
self.advance_chunk(chunk)?;
|
||||
left -= chunk;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn steps_done(&self) -> u64 {
|
||||
self.steps_done
|
||||
}
|
||||
|
||||
fn fields(&mut self) -> Result<Fields> {
|
||||
self.compute_moments()?;
|
||||
let m = self.read_f32(&self.moments, 4 * self.n)?;
|
||||
let density = m.as_chunks::<4>().0.iter().map(|v| f64::from(v[3])).collect();
|
||||
let velocity = m.as_chunks::<4>().0.iter().map(|v| [f64::from(v[0]), f64::from(v[1]), f64::from(v[2])]).collect();
|
||||
// The temperature from the scalar populations, summed on the host: T₀ + Σ t (T₀ in
|
||||
// solid cells, whose populations stay zero).
|
||||
let temperature = match &self.thermal {
|
||||
None => Vec::new(),
|
||||
Some(therm) => {
|
||||
let t = self.read_f32(therm.get(self.current).ok_or_else(|| Error::Invalid("GPU: no thermal buffer".into()))?, QT * self.n)?;
|
||||
(0..self.n)
|
||||
.map(|cell| self.initial_temperature + (0..QT).map(|q| t.get(q * self.n + cell).copied().map_or(0.0, f64::from)).sum::<f64>())
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
Ok(Fields { density, velocity, temperature })
|
||||
}
|
||||
|
||||
fn heat(&mut self) -> Result<Vec<f64>> {
|
||||
if self.thermal.is_none() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self.exchange()?.iter().map(|g| g[3]).collect())
|
||||
}
|
||||
|
||||
fn mass(&mut self) -> Result<f64> {
|
||||
self.compute_moments()?;
|
||||
self.submit("lbm mass", &[(self.current, "mass", self.cells)])?;
|
||||
let partials = self.read_f32(&self.partials, self.partial_count)?;
|
||||
// Summed in a fixed order on the host, so the result is deterministic on this device.
|
||||
Ok(partials.iter().map(|v| f64::from(*v)).sum())
|
||||
}
|
||||
|
||||
fn forces(&mut self) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(self.exchange()?.iter().map(|g| [g[0], g[1], g[2]]).collect())
|
||||
}
|
||||
|
||||
fn groups(&self) -> &[Group] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
fn fluid_cells(&self) -> usize {
|
||||
self.fluid_cells
|
||||
}
|
||||
|
||||
fn is_fluid(&self, cell: usize) -> bool {
|
||||
self.fluid.get(cell).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn empty_obstacles(&self) -> &[String] {
|
||||
&self.empty_obstacles
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Throughput of the GPU backend (ignored; run with
|
||||
//! `cargo test --release -p fieldcraft-lbm-gpu --test throughput -- --ignored --nocapture`),
|
||||
//! on the cases of `fieldcraft-lbm`'s throughput test, streaming in place and with two buffers
|
||||
//! (what `Streaming::Auto` picks on this device is marked). `FIELDCRAFT_LBM_GPU_PROFILE=1`
|
||||
//! adds each kernel's GPU time (timestamp queries; the throughput is then not representative).
|
||||
|
||||
#[path = "../../lbm/tests/common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use fieldcraft_gpu::select::Policy;
|
||||
use fieldcraft_lbm::backend::Backend;
|
||||
use fieldcraft_lbm_gpu::{GpuFlow, Options, Streaming};
|
||||
use fieldcraft_model::{BoxFace, FaceCondition};
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn cell_updates_per_second() {
|
||||
for (name, l, sphere_radius) in [("periodic box", 64usize, 0.0), ("periodic box", 128, 0.0), ("box with a sphere", 128, 32.0)] {
|
||||
let side = l as f64;
|
||||
let mut d = domain([0.0; 3], [side; 3], 1.0);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = FaceCondition::Periodic {};
|
||||
}
|
||||
d.acceleration = [1e-6, 0.0, 0.0];
|
||||
let mut obstacles = Vec::new();
|
||||
if sphere_radius > 0.0 {
|
||||
d.obstacles = vec!["Ball".into()];
|
||||
obstacles.push(obstacle("Ball", sphere([side / 2.0; 3], sphere_radius, 4), &["skin"]));
|
||||
}
|
||||
let steps = 200;
|
||||
let m = model(
|
||||
1.0,
|
||||
1.0 / 6.0,
|
||||
d,
|
||||
fieldcraft_model::Procedure::Fluid {
|
||||
domain: "Flow".into(),
|
||||
end_time: None,
|
||||
steps: Some(steps),
|
||||
time_step: Some(1.0),
|
||||
frames: 1,
|
||||
magic: None,
|
||||
},
|
||||
);
|
||||
let (_, flow) = prepare(&m, &obstacles);
|
||||
let policy = Policy::from_env().expect("FIELDCRAFT_GPU names a policy");
|
||||
let profile = std::env::var("FIELDCRAFT_LBM_GPU_PROFILE").is_ok_and(|v| v == "1");
|
||||
let Ok(auto) = GpuFlow::new(&flow, policy) else {
|
||||
println!("skipping {name}, {l}³: no GPU or not enough memory");
|
||||
continue;
|
||||
};
|
||||
let picked = auto.streams_in_place();
|
||||
drop(auto);
|
||||
for (streaming, in_place) in [(Streaming::InPlace, true), (Streaming::TwoBuffers, false)] {
|
||||
let mut gpu = GpuFlow::with_options(&flow, policy, Options { streaming, profile }).unwrap();
|
||||
// One warm-up chunk, then the timed run: the chunk's steps are one submission.
|
||||
gpu.advance(1).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
gpu.advance(steps).unwrap();
|
||||
let _ = gpu.mass().unwrap();
|
||||
let s = start.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"{name}, {l}³ on {}, {}{}: {:.1} M cell updates/s (pipelines {:.2} s)",
|
||||
gpu.adapter_name(),
|
||||
if in_place { "in place" } else { "two buffers" },
|
||||
if in_place == picked { " (auto)" } else { "" },
|
||||
(gpu.fluid_cells() as f64 * steps as f64) / s / 1e6,
|
||||
gpu.pipeline_seconds()
|
||||
);
|
||||
for (kernel, t) in gpu.kernel_times().unwrap_or_default() {
|
||||
println!(" {kernel}: {} dispatches, {:.3} ms each", t.dispatches, 1e3 * t.seconds / t.dispatches.max(1) as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
//! The GPU backend against the CPU scheme and the analytic cases of `fieldcraft-lbm`'s
|
||||
//! verification suite. The stated tolerances of the backend, with populations stored as
|
||||
//! deviations from their weights in single precision (measured on lavapipe at 2–5e-6, with
|
||||
//! a margin): velocity fields within 2e-5 of the largest speed, densities within 1e-6, wall
|
||||
//! forces within 5e-5 of their magnitude (a wall's shear within 5e-5 of itself), mass drift below
|
||||
//! 1e-6; with heat, temperatures within 2e-5 of the field's range and wall heats within 1e-4 of
|
||||
//! the largest (a heat is a difference of populations, so it keeps fewer digits). Skipped without
|
||||
//! a GPU adapter unless `FIELDCRAFT_REQUIRE_GPU=1`, which makes a missing adapter a failure;
|
||||
//! `FIELDCRAFT_GPU` picks the adapter (`hardware` refuses a software one).
|
||||
|
||||
#[path = "../../lbm/tests/common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use fieldcraft_gpu::select::{Policy, gpu_required};
|
||||
use fieldcraft_lbm::backend::Backend;
|
||||
use fieldcraft_lbm::{Flow, Setup};
|
||||
use fieldcraft_lbm_gpu::{GpuFlow, Options, Streaming};
|
||||
use fieldcraft_model::{
|
||||
BoxFace, Directional, FaceCondition, Fluid, FluidDomain, FluidThermal, Material, Model, Procedure, Profile, Step, Thermal, ThermalCondition,
|
||||
};
|
||||
|
||||
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
|
||||
|
||||
/// The flow on the GPU `FIELDCRAFT_GPU` picks, or `None` to skip (a failure when a GPU is
|
||||
/// required).
|
||||
fn gpu(flow: &Flow, streaming: Streaming) -> Option<GpuFlow> {
|
||||
let policy = Policy::from_env().expect("FIELDCRAFT_GPU names a policy");
|
||||
match GpuFlow::with_options(flow, policy, Options { streaming, profile: false }) {
|
||||
Ok(g) => {
|
||||
println!("GPU: {} ({streaming:?})", g.adapter_name());
|
||||
Some(g)
|
||||
}
|
||||
Err(e) if gpu_required() => panic!("a GPU is required: {e}"),
|
||||
// Only a missing device skips: a shader that does not compile is a failure anywhere.
|
||||
Err(e) if e.to_string().contains("no GPU device") => {
|
||||
println!("skipping: {e}");
|
||||
None
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Both backends through every step; the CPU's fields and the GPU's.
|
||||
fn both(setup: &Setup, mut cpu: Flow, streaming: Streaming) -> Option<(Flow, GpuFlow)> {
|
||||
let mut gpu = gpu(&cpu, streaming)?;
|
||||
cpu.advance(setup.steps).unwrap();
|
||||
gpu.advance(setup.steps).unwrap();
|
||||
Some((cpu, gpu))
|
||||
}
|
||||
|
||||
/// The largest difference of the velocity fields over the largest CPU speed, and of the density
|
||||
/// fields.
|
||||
fn field_difference(cpu: &mut Flow, gpu: &mut GpuFlow) -> (f64, f64) {
|
||||
let (a, b) = (cpu.fields().unwrap(), gpu.fields().unwrap());
|
||||
let scale = a.velocity.iter().map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()).fold(0.0, f64::max).max(1e-30);
|
||||
let du = a
|
||||
.velocity
|
||||
.iter()
|
||||
.zip(&b.velocity)
|
||||
.map(|(x, y)| ((x[0] - y[0]).powi(2) + (x[1] - y[1]).powi(2) + (x[2] - y[2]).powi(2)).sqrt())
|
||||
.fold(0.0, f64::max);
|
||||
let drho = a.density.iter().zip(&b.density).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max);
|
||||
(du / scale, drho)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_poiseuille_matches_the_analytic_profile_and_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, nu, umax, n) = (1.0, 1.0 / 6.0, 0.1, 16usize);
|
||||
let dx = h / n as f64;
|
||||
let a = 8.0 * nu * umax / (h * h);
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
d.acceleration = [a, 0.0, 0.0];
|
||||
let m = model(1.0, nu, d, step(4.0 * h * h / nu, dx * dx, Some(3.0 / 16.0)));
|
||||
let (setup, flow) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
|
||||
let f = gpu.fields().unwrap();
|
||||
let mut err: f64 = 0.0;
|
||||
for j in 0..n {
|
||||
let y = (j as f64 + 0.5) * dx;
|
||||
let exact = a / (2.0 * nu) * y * (h - y);
|
||||
err = err.max((f.velocity[j][0] * setup.velocity_scale() - exact).abs() / umax);
|
||||
}
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
println!("Poiseuille on the GPU: max error {err:.2e} of the peak; CPU/GPU velocity {du:.2e}, density {drho:.2e}");
|
||||
assert!(err < 2e-5, "{err}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn couette_wall_forces_match_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, nu, uw, n) = (1.0, 0.1, 0.05, 16usize);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
d.faces.y_max = FaceCondition::MovingWall { velocity: [uw, 0.0, 0.0] };
|
||||
let m = model(1.0, nu, d, step(3.0 * h * h / nu, 0.5 * dx * dx / (3.0 * nu), None));
|
||||
let (setup, flow) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
|
||||
let (fc, fg) = (Backend::forces(&mut cpu).unwrap(), gpu.forces().unwrap());
|
||||
let bottom = BoxFace::ALL.iter().position(|f| f.name() == "y_min").unwrap();
|
||||
let top = BoxFace::ALL.iter().position(|f| f.name() == "y_max").unwrap();
|
||||
let tau = nu * uw / h / setup.force_scale() * dx * dx;
|
||||
println!("Couette: wall force CPU {:?} / GPU {:?} (expected ±{tau:.3e} per step)", fc[bottom], fg[bottom]);
|
||||
// The tangential force is the shear: a cancellation of the links' exchanges to 1e-6 per link,
|
||||
// summed from the stored deviations alone (adding the weights back in single precision left
|
||||
// it 1e-4 of itself on an RTX 4090; 3e-6 on lavapipe this way). The normal force is the
|
||||
// pressure.
|
||||
for g in [bottom, top] {
|
||||
println!("Couette group {g}: CPU {:.9e}, GPU {:.9e}, shear parity {:.9e}", fc[g][0], fg[g][0], (fc[g][0] - fg[g][0]).abs() / tau.abs());
|
||||
assert!((fc[g][0] - fg[g][0]).abs() <= 5e-5 * tau.abs(), "group {g} shear: {} vs {}", fc[g][0], fg[g][0]);
|
||||
assert!((fc[g][1] - fg[g][1]).abs() <= 1e-6 * fc[g][1].abs(), "group {g} normal: {} vs {}", fc[g][1], fg[g][1]);
|
||||
}
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taylor_green_decays_like_the_cpu_and_conserves_mass() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (nu, u0, l, n) = (0.1, 0.1, 2.0 * std::f64::consts::PI, 32usize);
|
||||
let dx = l / n as f64;
|
||||
let mut d = domain([0.0; 3], [l, l, dx], dx);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
let dt = 0.3 * dx * dx / (3.0 * nu);
|
||||
let half_life = std::f64::consts::LN_2 / (2.0 * nu);
|
||||
let m = model(1.0, nu, d, step(half_life, dt, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
let grid = *flow.grid();
|
||||
let to_lattice = 1.0 / setup.velocity_scale();
|
||||
flow.set_equilibrium(|c| {
|
||||
let [x, y, _] = grid.centre(c);
|
||||
let p = -u0 * u0 / 4.0 * ((2.0 * x).cos() + (2.0 * y).cos());
|
||||
(setup.lattice_density(p), [-x.cos() * y.sin() * u0 * to_lattice, x.sin() * y.cos() * u0 * to_lattice, 0.0])
|
||||
});
|
||||
let Some(mut gpu) = gpu(&flow, streaming) else { continue };
|
||||
let mass0 = gpu.mass().unwrap();
|
||||
let mut cpu = flow;
|
||||
cpu.advance(setup.steps).unwrap();
|
||||
gpu.advance(setup.steps).unwrap();
|
||||
let drift = (gpu.mass().unwrap() - mass0).abs() / mass0;
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
let f = gpu.fields().unwrap();
|
||||
let (mut num, mut den) = (0.0, 0.0);
|
||||
for c in 0..grid.len() {
|
||||
let [x, y, _] = grid.centre(c);
|
||||
let mode = [-x.cos() * y.sin(), x.sin() * y.cos()];
|
||||
num += f.velocity[c][0] * mode[0] + f.velocity[c][1] * mode[1];
|
||||
den += mode[0] * mode[0] + mode[1] * mode[1];
|
||||
}
|
||||
let amplitude = num / den * setup.velocity_scale();
|
||||
let exact = u0 * (-2.0 * nu * setup.steps as f64 * setup.time_step).exp();
|
||||
let err = (amplitude / exact - 1.0).abs();
|
||||
println!("Taylor–Green on the GPU: amplitude error {err:.2e}; CPU/GPU velocity {du:.2e}, density {drho:.2e}; mass drift {drift:.2e}");
|
||||
// The amplitude error is the scheme's at 32 cells (the CPU test shows second order).
|
||||
assert!(err < 5e-3, "{err}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
assert!(drift < 1e-6, "{drift}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_past_a_sphere_matches_the_cpu_fields_and_forces() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let l = 24usize;
|
||||
let side = l as f64;
|
||||
let mut d = domain([0.0; 3], [side; 3], 1.0);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
d.acceleration = [1e-5, 0.0, 0.0];
|
||||
d.obstacles = vec!["Ball".into()];
|
||||
let ball = obstacle("Ball", sphere([side / 2.0; 3], 6.0, 3), &["skin"]);
|
||||
let m = model(
|
||||
1.0,
|
||||
1.0 / 6.0,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(300), time_step: Some(1.0), frames: 1, magic: None },
|
||||
);
|
||||
let (setup, flow) = prepare(&m, std::slice::from_ref(&ball));
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
let (fc, fg) = (Backend::forces(&mut cpu).unwrap(), gpu.forces().unwrap());
|
||||
let skin = cpu.groups().iter().position(|g| g.name == "skin").unwrap();
|
||||
let scale = fc[skin].iter().map(|v| v.abs()).fold(0.0, f64::max);
|
||||
let dforce = (0..3).map(|a| (fc[skin][a] - fg[skin][a]).abs()).fold(0.0, f64::max) / scale;
|
||||
println!("Sphere: CPU/GPU velocity {du:.2e}, density {drho:.2e}; drag CPU {:?} GPU {:?} ({dforce:.2e})", fc[skin], fg[skin]);
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
assert!(dforce < 5e-5, "{dforce}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zou_he_channel_matches_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, l, umax, nu) = (1.0, 4.0, 0.1, 0.05);
|
||||
let dx = h / 10.0;
|
||||
let mut d = domain([0.0; 3], [l, h, dx], dx);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: Profile::Parabolic };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let m = model(1.0, nu, d, step(2.0 * l / umax, 0.05 * dx / umax, None));
|
||||
let (setup, flow) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, flow, streaming) else { continue };
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
println!("Zou–He channel: CPU/GPU velocity {du:.2e}, density {drho:.2e}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Heat ----
|
||||
|
||||
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
|
||||
/// ρ α, unit specific heat).
|
||||
fn hot_model(density: f64, nu: f64, alpha: f64, domain: FluidDomain, procedure: Procedure) -> Model {
|
||||
let mut m = Model::default();
|
||||
m.add_material(
|
||||
"Fluid",
|
||||
Material {
|
||||
density: Some(density),
|
||||
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
|
||||
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
|
||||
..Material::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
m.add_fluid_domain("Flow", domain).unwrap();
|
||||
m.add_step(Step::new("Run", procedure), None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// The largest difference of the temperature fields over the CPU's temperature range, and of
|
||||
/// the groups' heats over the largest CPU heat.
|
||||
fn heat_difference(cpu: &mut Flow, gpu: &mut GpuFlow) -> (f64, f64) {
|
||||
let (a, b) = (cpu.fields().unwrap(), gpu.fields().unwrap());
|
||||
assert_eq!(a.temperature.len(), b.temperature.len());
|
||||
let (lo, hi) = a.temperature.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), t| (lo.min(*t), hi.max(*t)));
|
||||
let dt = a.temperature.iter().zip(&b.temperature).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max) / (hi - lo).max(1e-30);
|
||||
let (ha, hb) = (cpu.heat().unwrap(), gpu.heat().unwrap());
|
||||
assert_eq!(ha.len(), hb.len());
|
||||
let scale = ha.iter().map(|h| h.abs()).fold(0.0, f64::max).max(1e-30);
|
||||
let dh = ha.iter().zip(&hb).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max) / scale;
|
||||
(dt, dh)
|
||||
}
|
||||
|
||||
/// Plates held at 20 and 21 with the fluid at rest: the steady temperature is linear between
|
||||
/// them (anti-bounce-back puts the walls halfway between cells), the heat through both walls
|
||||
/// is k ΔT A / H, and the GPU agrees with the CPU although it stores the small differences
|
||||
/// about T₀ = 20 in single precision.
|
||||
#[test]
|
||||
fn conduction_between_plates_matches_the_analytic_profile_and_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, alpha, n) = (1.0, 0.05, 16usize);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 20.0, ..FluidThermal::default() };
|
||||
t.faces.y_min = ThermalCondition::Temperature { value: 20.0 };
|
||||
t.faces.y_max = ThermalCondition::Temperature { value: 21.0 };
|
||||
d.thermal = Some(t);
|
||||
let m = hot_model(1.0, 0.1, alpha, d, step(8.0 * h * h / alpha, dx * dx / (8.0 * alpha), None));
|
||||
let (setup, cpu) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
|
||||
let f = gpu.fields().unwrap();
|
||||
let grid = *cpu.grid();
|
||||
let mut err: f64 = 0.0;
|
||||
for j in 0..n {
|
||||
let expected = 20.0 + (j as f64 + 0.5) / n as f64;
|
||||
err = err.max((f.temperature[grid.index([0, j, 0])] - expected).abs());
|
||||
}
|
||||
let q = alpha * 1.0 / h * dx * dx;
|
||||
let heat = gpu.heat().unwrap();
|
||||
let cold = cpu.groups().iter().position(|g| g.name == "y_min").unwrap();
|
||||
let hot = cpu.groups().iter().position(|g| g.name == "y_max").unwrap();
|
||||
// lattice heat (temperature × faces per step) to model units: ρ c h³ / dt
|
||||
let scale = setup.thermal.as_ref().unwrap().heat_scale;
|
||||
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
|
||||
println!(
|
||||
"conduction: profile error {err:.2e}; heat {:.6e} / {:.6e} vs {q:.6e}; GPU vs CPU: temperature {dt:.2e} heat {dh:.2e}",
|
||||
heat[cold] * scale,
|
||||
heat[hot] * scale
|
||||
);
|
||||
assert!(err < 5e-5, "{err}");
|
||||
assert!((heat[cold] * scale / q - 1.0).abs() < 1e-4 && (heat[hot] * scale / q + 1.0).abs() < 1e-4, "{heat:?}");
|
||||
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A heated channel: a velocity inlet carrying 1 in, a pressure outlet, a flux into the fluid
|
||||
/// at the bottom wall, the top wall held at 0, periodic across. Every rule of the scalar
|
||||
/// lattice (pulls, the three wall conditions, the inlet and the outlet) agrees with the CPU.
|
||||
#[test]
|
||||
fn a_heated_channel_matches_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, l, umax, nu, alpha) = (1.0, 2.0, 0.1, 0.05, 0.02);
|
||||
let dx = h / 8.0;
|
||||
let mut d = domain([0.0; 3], [l, h, dx], dx);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: Profile::Parabolic };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal::default();
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.y_min = ThermalCondition::Flux { value: 0.05 };
|
||||
t.faces.y_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
let m = hot_model(1.0, nu, alpha, d, step(2.0 * l / umax, 0.05 * dx / umax, None));
|
||||
let (setup, cpu) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
|
||||
println!("heated channel: GPU vs CPU: velocity {du:.2e} density {drho:.2e} temperature {dt:.2e} heat {dh:.2e}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A differentially heated cavity with buoyancy (the de Vahl Davis geometry at Ra 10³ on 16
|
||||
/// cells, over the first diffusive time): the temperature drives the flow on both backends
|
||||
/// alike.
|
||||
#[test]
|
||||
fn natural_convection_matches_the_cpu() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let (h, pr, alpha, n, u_max) = (1.0, 0.71, 1.0, 16usize, 3.65);
|
||||
let nu = pr * alpha;
|
||||
let g_beta = 1e3 * nu * alpha / (h * h * h);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [h, h, dx], dx);
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -g_beta, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
let dt = (0.05 * dx / (u_max * alpha / h)).min(dx * dx / (4.0 * alpha));
|
||||
let m = hot_model(1.0, nu, alpha, d, step(h * h / alpha, dt, None));
|
||||
let (setup, cpu) = prepare(&m, &[]);
|
||||
let Some((mut cpu, mut gpu)) = both(&setup, cpu, streaming) else { continue };
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
|
||||
// the fields are in lattice units: the time step puts the cavity's fastest flow at 0.05
|
||||
let speed = cpu.fields().unwrap().velocity.iter().map(|v| (v[0] * v[0] + v[1] * v[1]).sqrt()).fold(0.0, f64::max);
|
||||
println!("cavity: fastest lattice speed {speed:.3e}; GPU vs CPU: velocity {du:.2e} density {drho:.2e} temperature {dt:.2e} heat {dh:.2e}");
|
||||
assert!(speed > 0.01, "the buoyancy drives a flow: {speed}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "{du} {drho}");
|
||||
assert!(dt < 2e-5 && dh < 1e-4, "{dt} {dh}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Readbacks and force caches must describe the same state on both AA orientations,
|
||||
/// including calls that cross a 64-step submission boundary. The CPU defines the result.
|
||||
fn compare_incremental(mut cpu: Flow, heated: bool, streaming: Streaming) {
|
||||
let Some(mut gpu) = gpu(&cpu, streaming) else { return };
|
||||
let mut total = 0;
|
||||
for steps in [1, 1, 61, 1, 1, 64, 65] {
|
||||
cpu.advance(steps).unwrap();
|
||||
gpu.advance(steps).unwrap();
|
||||
total += steps;
|
||||
assert_eq!(gpu.steps_done(), total);
|
||||
let (du, drho) = field_difference(&mut cpu, &mut gpu);
|
||||
println!("incremental step {total}: velocity {du:.9e}, density {drho:.9e}");
|
||||
assert!(du < 2e-5 && drho < 1e-6, "step {total}: {du} {drho}");
|
||||
let mass = Backend::mass(&mut cpu).unwrap();
|
||||
assert!((gpu.mass().unwrap() - mass).abs() < 1e-6 * mass.abs());
|
||||
let a = Backend::forces(&mut cpu).unwrap();
|
||||
let b = gpu.forces().unwrap();
|
||||
let scale = a.iter().flatten().map(|v| v.abs()).fold(0.0, f64::max).max(1e-30);
|
||||
let error = a.iter().flatten().zip(b.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
|
||||
assert!(error < 5e-5 * scale, "step {total}: force {error} / {scale}");
|
||||
gpu.advance(0).unwrap();
|
||||
assert_eq!(gpu.forces().unwrap(), b, "a zero-step advance preserves the force cache");
|
||||
if heated {
|
||||
let (dt, dh) = heat_difference(&mut cpu, &mut gpu);
|
||||
assert!(dt < 2e-5 && dh < 1e-4, "step {total}: heat {dt} {dh}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_even_and_chunk_readbacks_match_the_cpu_with_heat_open_and_slip_faces() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let mut d = domain([0.0; 3], [16.0, 8.0, 1.0], 1.0);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [0.01, 0.0, 0.0], profile: Profile::Uniform };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
d.faces.y_max = FaceCondition::Slip {};
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 0.5, ..FluidThermal::default() };
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.y_min = ThermalCondition::Flux { value: 0.001 };
|
||||
t.faces.y_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
let m = hot_model(
|
||||
1.0,
|
||||
0.1,
|
||||
0.05,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
|
||||
);
|
||||
let (_, flow) = prepare(&m, &[]);
|
||||
compare_incremental(flow, true, streaming);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_even_and_chunk_readbacks_match_the_cpu_around_an_obstacle() {
|
||||
// Both layouts: the default depends on the device (see `Streaming`).
|
||||
for streaming in [Streaming::InPlace, Streaming::TwoBuffers] {
|
||||
let mut d = domain([0.0; 3], [12.0; 3], 1.0);
|
||||
for face in BoxFace::ALL {
|
||||
*d.faces.get_mut(face) = PERIODIC;
|
||||
}
|
||||
d.acceleration = [1e-5, 0.0, 0.0];
|
||||
d.obstacles = vec!["Ball".into()];
|
||||
let ball = obstacle("Ball", sphere([6.0; 3], 3.0, 2), &["skin"]);
|
||||
let m = model(
|
||||
1.0,
|
||||
1.0 / 6.0,
|
||||
d,
|
||||
Procedure::Fluid { domain: "Flow".into(), end_time: None, steps: Some(194), time_step: Some(1.0), frames: 1, magic: None },
|
||||
);
|
||||
let (_, flow) = prepare(&m, &[ball]);
|
||||
compare_incremental(flow, false, streaming);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! What steps a prepared flow: the CPU scheme in [`crate::flow::Flow`], or a device backend an
|
||||
//! application registers (a GPU crate above this layer implements the trait and hands a factory
|
||||
//! to [`register_backend`]; `solve.fluid` picks it when asked). Every backend takes its grid,
|
||||
//! links and open cells from the [`Flow`] that prepared them, so voxelisation and every boundary
|
||||
//! rule exist once, here.
|
||||
//!
|
||||
//! The CPU result is the reference: a device backend is deterministic on its device and agrees
|
||||
//! with the CPU within a stated tolerance (its tests say which).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::flow::{Flow, Group};
|
||||
use crate::setup::Setup;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The density and velocity of every cell (lattice units; zero in solid cells), and the
|
||||
/// temperature (model units) when the flow carries heat (empty otherwise).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Fields {
|
||||
pub density: Vec<f64>,
|
||||
pub velocity: Vec<[f64; 3]>,
|
||||
pub temperature: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A stepper of one prepared flow.
|
||||
pub trait Backend {
|
||||
/// What steps the flow, for reports: `cpu`, `gpu: <adapter>`.
|
||||
fn name(&self) -> String;
|
||||
/// Advance `steps` time steps. Fails with [`Error::Unstable`] when a density stops being a
|
||||
/// positive number or a lattice speed exceeds [`crate::setup::MAX_LATTICE_SPEED`] (a device
|
||||
/// backend may notice at the end of the chunk rather than the step).
|
||||
fn advance(&mut self, steps: u64) -> Result<()>;
|
||||
fn steps_done(&self) -> u64;
|
||||
/// The density and velocity (with the half force) of every cell.
|
||||
fn fields(&mut self) -> Result<Fields>;
|
||||
/// The total mass of the fluid cells (the sum of every population).
|
||||
fn mass(&mut self) -> Result<f64>;
|
||||
/// The momentum each force group received in the last step (lattice units: force per step).
|
||||
fn forces(&mut self) -> Result<Vec<[f64; 3]>>;
|
||||
/// The heat each force group received in the last step (lattice units: temperature times
|
||||
/// cell faces per step); empty without heat.
|
||||
fn heat(&mut self) -> Result<Vec<f64>>;
|
||||
fn groups(&self) -> &[Group];
|
||||
fn fluid_cells(&self) -> usize;
|
||||
fn is_fluid(&self, cell: usize) -> bool;
|
||||
/// Obstacles that cover no cell centre.
|
||||
fn empty_obstacles(&self) -> &[String];
|
||||
}
|
||||
|
||||
/// Makes a device backend from a prepared flow (its initial state included) and its setup.
|
||||
pub type BackendFactory = dyn Fn(&Flow, &Setup) -> Result<Box<dyn Backend>> + Send + Sync;
|
||||
|
||||
static FACTORY: OnceLock<Box<BackendFactory>> = OnceLock::new();
|
||||
|
||||
/// Register the process's device backend (an application does this once at start; a second
|
||||
/// registration is ignored).
|
||||
pub fn register_backend(factory: Box<BackendFactory>) {
|
||||
let _ = FACTORY.set(factory);
|
||||
}
|
||||
|
||||
/// The registered device backend's factory, if an application registered one.
|
||||
pub fn backend_factory() -> Option<&'static BackendFactory> {
|
||||
FACTORY.get().map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// Which backend a solve asks for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Choice {
|
||||
/// The CPU scheme: the reference, bit for bit reproducible.
|
||||
#[default]
|
||||
Cpu,
|
||||
/// The registered device backend; an error when none is registered.
|
||||
Device,
|
||||
}
|
||||
|
||||
impl Choice {
|
||||
/// `cpu` or `gpu`, exactly.
|
||||
pub fn parse(s: &str) -> Option<Choice> {
|
||||
match s {
|
||||
"cpu" => Some(Choice::Cpu),
|
||||
"gpu" => Some(Choice::Device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend `choice` names for `flow`.
|
||||
pub fn backend(choice: Choice, flow: Flow, setup: &Setup) -> Result<Box<dyn Backend>> {
|
||||
match choice {
|
||||
Choice::Cpu => Ok(Box::new(flow)),
|
||||
Choice::Device => match backend_factory() {
|
||||
Some(make) => make(&flow, setup),
|
||||
None => Err(Error::Invalid("no GPU backend is available in this build: solve on the CPU (`backend: \"cpu\"`)".into())),
|
||||
},
|
||||
}
|
||||
}
|
||||
+23
-503
@@ -27,20 +27,6 @@
|
||||
//! 65:041203, 2002): a population `f*` leaving a fluid cell toward the wall and the one `f'`
|
||||
//! coming back transfer `c_out f* − c_in f'` to the wall.
|
||||
//!
|
||||
//! # Heat
|
||||
//! With thermal settings a second lattice carries the temperature: D3Q7 (the rest direction and
|
||||
//! the six axis directions, the first seven of D3Q19), weights ¼ and ⅛, `c_s² = ¼`, equilibrium
|
||||
//! `w_q T (1 + 4 c_q·u)` with the velocity the momentum lattice just collided to, BGK collision
|
||||
//! with `τ_T = 4 α + ½` (Krüger et al. 2017, ch. 8: the passive-scalar double-distribution
|
||||
//! model). Its streaming follows the momentum lattice's links: a wall held at a temperature is
|
||||
//! anti-bounce-back (`g_q = −g*_q̄ + 2 w_q T_w`, which puts the temperature halfway between the
|
||||
//! cells), an adiabatic wall bounces back, a heat flux through a wall face adds the flux to the
|
||||
//! bounced population (one axis direction crosses each wall face, so the cell gains exactly that
|
||||
//! heat), an inlet holds its temperature (equilibrium at the cell's velocity) and an outlet takes
|
||||
//! its neighbour's. Buoyancy is the Boussinesq force per mass `−β (T − T₀) g`, added to the body
|
||||
//! force of every cell (Guo forcing as for the constant acceleration). The heat each wall face
|
||||
//! receives is summed over its links like the momentum exchange.
|
||||
//!
|
||||
//! # Layout and determinism
|
||||
//! Two arrays of populations, structure of arrays (`f[q n + cell]`), A–B pattern. Streaming
|
||||
//! pulls: every population of a cell comes from one source (a neighbour, a periodic image, the
|
||||
@@ -50,7 +36,7 @@
|
||||
//! only its own populations; the wall forces are summed in link order. There are no parallel
|
||||
//! reductions, so the result is the same bit for bit on any number of threads and platform.
|
||||
|
||||
use fieldcraft_model::{BoxFace, FaceCondition, Profile, ThermalCondition};
|
||||
use fieldcraft_model::{BoxFace, FaceCondition, Profile};
|
||||
|
||||
use fieldcraft_parallel as parallel;
|
||||
|
||||
@@ -59,10 +45,9 @@ use crate::setup::{MAX_LATTICE_SPEED, Obstacle, Setup};
|
||||
use crate::voxel::{Grid, TriangleTree, voxelise};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Where a population of a cell next to a wall or box face comes from (every other population
|
||||
/// streams from the neighbour at `−c_q`). Public so a device backend can apply the same rules.
|
||||
/// Where a population of a cell next to a wall or box face comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Rule {
|
||||
enum Rule {
|
||||
/// The same direction from another cell (a neighbour or a periodic image).
|
||||
Pull { from: u32 },
|
||||
/// Bounce-back at a wall of force group `group`; `du` is the moving-wall term.
|
||||
@@ -74,26 +59,25 @@ pub enum Rule {
|
||||
}
|
||||
|
||||
/// A population, direction `q` of `cell`, and where it comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Link {
|
||||
pub cell: u32,
|
||||
pub q: u8,
|
||||
pub rule: Rule,
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Link {
|
||||
cell: u32,
|
||||
q: u8,
|
||||
rule: Rule,
|
||||
}
|
||||
|
||||
/// An inlet or outlet cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct OpenCell {
|
||||
pub cell: u32,
|
||||
pub axis: usize,
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct OpenCell {
|
||||
cell: u32,
|
||||
axis: usize,
|
||||
/// +1 at a min face (the inward normal points up the axis), −1 at a max face.
|
||||
pub inward: f64,
|
||||
pub target: Target,
|
||||
inward: f64,
|
||||
target: Target,
|
||||
}
|
||||
|
||||
/// What an open cell holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Target {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Target {
|
||||
/// The velocity (lattice units).
|
||||
Velocity([f64; 3]),
|
||||
/// The density.
|
||||
@@ -109,67 +93,6 @@ pub struct Group {
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// The scalar lattice: directions 0..7 of D3Q19 (rest and the axes).
|
||||
const QT: usize = 7;
|
||||
/// Its weights.
|
||||
const WT: [f64; QT] = [0.25, 0.125, 0.125, 0.125, 0.125, 0.125, 0.125];
|
||||
|
||||
/// The scalar equilibrium `w_q T (1 + c_q·u / c_s²)`, `c_s² = ¼`.
|
||||
#[inline(always)]
|
||||
fn equilibrium_t(q: usize, t: f64, u: [f64; 3]) -> f64 {
|
||||
let cq = c(q);
|
||||
WT.get(q).copied().unwrap_or(0.0) * t * (1.0 + 4.0 * (cq[0] * u[0] + cq[1] * u[1] + cq[2] * u[2]))
|
||||
}
|
||||
|
||||
/// The temperature lattice and what it needs (see the module notes).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThermalState {
|
||||
/// `g[q stride + cell]` and the streaming target, A–B like the momentum lattice.
|
||||
g: Vec<f64>,
|
||||
g2: Vec<f64>,
|
||||
/// The temperature of every cell after the last step (the initial one in solid cells).
|
||||
temperature: Vec<f64>,
|
||||
omega: f64,
|
||||
initial: f64,
|
||||
/// `−β g` in lattice units; zero without buoyancy.
|
||||
buoyancy: [f64; 3],
|
||||
/// Each force group's thermal condition (fluxes in lattice units).
|
||||
groups: Vec<ThermalCondition>,
|
||||
/// Per open cell: the temperature it holds (an inlet), or `None` to take its neighbour's
|
||||
/// (an outlet).
|
||||
open: Vec<Option<f64>>,
|
||||
/// The heat each group received in the last step (lattice units: temperature times cell
|
||||
/// faces per step).
|
||||
heat: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ThermalState {
|
||||
/// The scalar relaxation rate 1 / τ_T.
|
||||
pub fn omega(&self) -> f64 {
|
||||
self.omega
|
||||
}
|
||||
|
||||
/// The initial temperature (and the reference of the buoyancy).
|
||||
pub fn initial(&self) -> f64 {
|
||||
self.initial
|
||||
}
|
||||
|
||||
/// `−β g` in lattice units; zero without buoyancy.
|
||||
pub fn buoyancy(&self) -> [f64; 3] {
|
||||
self.buoyancy
|
||||
}
|
||||
|
||||
/// Each force group's thermal condition (fluxes in lattice units).
|
||||
pub fn groups(&self) -> &[ThermalCondition] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
/// Per open cell, the temperature it holds, or `None` to take its neighbour's.
|
||||
pub fn open(&self) -> &[Option<f64>] {
|
||||
&self.open
|
||||
}
|
||||
}
|
||||
|
||||
/// The flow state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Flow {
|
||||
@@ -195,13 +118,6 @@ pub struct Flow {
|
||||
steps_done: u64,
|
||||
/// Obstacles that cover no cell centre (smaller than the spacing, or outside the box).
|
||||
empty_obstacles: Vec<String>,
|
||||
/// The temperature lattice, with thermal settings.
|
||||
thermal: Option<ThermalState>,
|
||||
/// With buoyancy: every cell's body acceleration (the constant one plus the buoyant one),
|
||||
/// refreshed from the temperature before each step.
|
||||
cell_force: Vec<[f64; 3]>,
|
||||
/// With heat: every cell's velocity from the last collision, for the scalar equilibrium.
|
||||
cell_u: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
const NONE: u32 = u32::MAX;
|
||||
@@ -410,57 +326,6 @@ impl Flow {
|
||||
f[q * stride + cell] = if fluid[cell] { equilibrium(q, 1.0, setup.initial_velocity) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Heat: the scalar lattice at the initial temperature, each group's condition (the
|
||||
// box faces, then every obstacle face), each open cell's temperature.
|
||||
let (thermal, cell_force, cell_u) = match &setup.thermal {
|
||||
None => (None, Vec::new(), Vec::new()),
|
||||
Some(ts) => {
|
||||
let mut group_conditions: Vec<ThermalCondition> = ts.faces.to_vec();
|
||||
for faces in &setup.obstacles {
|
||||
group_conditions.extend(faces.thermal.iter().copied());
|
||||
}
|
||||
group_conditions.resize(groups.len(), ThermalCondition::Adiabatic {});
|
||||
let open_t: Vec<Option<f64>> = open
|
||||
.iter()
|
||||
.map(|oc| {
|
||||
let face = BoxFace::of(oc.axis, oc.inward < 0.0);
|
||||
match (oc.target, ts.faces.get(face.index()).copied().unwrap_or_default()) {
|
||||
(_, ThermalCondition::Temperature { value }) => Some(value),
|
||||
(Target::Velocity(_), _) => Some(ts.initial_temperature),
|
||||
(Target::Density(_), _) => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let t_n = stride.checked_mul(QT).ok_or_else(|| Error::Limit("the grid is too large".into()))?;
|
||||
let mut tg = filled(t_n, 0.0, "temperature populations")?;
|
||||
let tg2 = filled(t_n, 0.0, "temperature populations")?;
|
||||
for cell in 0..n {
|
||||
if fluid[cell] {
|
||||
for q in 0..QT {
|
||||
tg[q * stride + cell] = equilibrium_t(q, ts.initial_temperature, setup.initial_velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
let buoyant = ts.buoyancy != [0.0; 3];
|
||||
let state = ThermalState {
|
||||
g: tg,
|
||||
g2: tg2,
|
||||
temperature: filled(n, ts.initial_temperature, "temperature")?,
|
||||
omega: 1.0 / ts.tau,
|
||||
initial: ts.initial_temperature,
|
||||
buoyancy: ts.buoyancy,
|
||||
groups: group_conditions,
|
||||
open: open_t,
|
||||
heat: vec![0.0; groups.len()],
|
||||
};
|
||||
(
|
||||
Some(state),
|
||||
if buoyant { filled(n, setup.acceleration, "cell forces")? } else { Vec::new() },
|
||||
filled(n, [0.0; 3], "cell velocities")?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Flow {
|
||||
grid,
|
||||
n,
|
||||
@@ -479,68 +344,9 @@ impl Flow {
|
||||
fluid_cells,
|
||||
steps_done: 0,
|
||||
empty_obstacles,
|
||||
thermal,
|
||||
cell_force,
|
||||
cell_u,
|
||||
})
|
||||
}
|
||||
|
||||
/// The body acceleration of a cell: the constant one, plus the buoyant one with heat.
|
||||
#[inline(always)]
|
||||
fn force_at(&self, cell: usize) -> [f64; 3] {
|
||||
self.cell_force.get(cell).copied().unwrap_or(self.acceleration)
|
||||
}
|
||||
|
||||
/// The temperature of a cell (model units), or `None` without heat.
|
||||
pub fn temperature(&self, cell: usize) -> Option<f64> {
|
||||
self.thermal.as_ref().and_then(|t| t.temperature.get(cell).copied())
|
||||
}
|
||||
|
||||
/// Whether the flow carries heat.
|
||||
pub fn has_heat(&self) -> bool {
|
||||
self.thermal.is_some()
|
||||
}
|
||||
|
||||
/// The temperature lattice and its settings, with heat.
|
||||
pub fn thermal(&self) -> Option<&ThermalState> {
|
||||
self.thermal.as_ref()
|
||||
}
|
||||
|
||||
/// Scalar population `q` (0..7) of a cell; zero without heat.
|
||||
pub fn thermal_population(&self, q: usize, cell: usize) -> f64 {
|
||||
self.thermal.as_ref().and_then(|t| t.g.get(q * self.stride + cell).copied()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The heat each group received in the last step (lattice units), empty without heat.
|
||||
pub fn heat(&self) -> &[f64] {
|
||||
self.thermal.as_ref().map_or(&[], |t| &t.heat)
|
||||
}
|
||||
|
||||
/// Set every fluid cell's temperature (and its populations to the equilibrium at its
|
||||
/// velocity). Nothing without heat.
|
||||
pub fn set_temperature(&mut self, temperature: impl Fn(usize) -> f64) {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let velocities: Vec<[f64; 3]> = (0..n).map(|c| self.moments(c).map_or([0.0; 3], |m| m.1)).collect();
|
||||
let fluid = &self.fluid;
|
||||
let Some(t) = &mut self.thermal else { return };
|
||||
for cell in 0..n {
|
||||
if !fluid.get(cell).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let value = temperature(cell);
|
||||
if let Some(slot) = t.temperature.get_mut(cell) {
|
||||
*slot = value;
|
||||
}
|
||||
let u = velocities.get(cell).copied().unwrap_or([0.0; 3]);
|
||||
for q in 0..QT {
|
||||
if let Some(g) = t.g.get_mut(q * s + cell) {
|
||||
*g = equilibrium_t(q, value, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grid(&self) -> &Grid {
|
||||
&self.grid
|
||||
}
|
||||
@@ -571,36 +377,6 @@ impl Flow {
|
||||
self.steps_done
|
||||
}
|
||||
|
||||
/// The links that do not stream from the neighbour at `−c_q`, in cell order.
|
||||
pub fn links(&self) -> &[Link] {
|
||||
&self.links
|
||||
}
|
||||
|
||||
/// The inlet and outlet cells.
|
||||
pub fn open_cells(&self) -> &[OpenCell] {
|
||||
&self.open
|
||||
}
|
||||
|
||||
/// Per direction, the index offset of the neighbour a population streams from.
|
||||
pub fn offsets(&self) -> [isize; Q] {
|
||||
self.offset
|
||||
}
|
||||
|
||||
/// `1/τ⁺` and `1/τ⁻`.
|
||||
pub fn omegas(&self) -> (f64, f64) {
|
||||
(self.omega_plus, self.omega_minus)
|
||||
}
|
||||
|
||||
/// The body acceleration in lattice units.
|
||||
pub fn acceleration(&self) -> [f64; 3] {
|
||||
self.acceleration
|
||||
}
|
||||
|
||||
/// Population `q` of `cell` (zero in a solid cell).
|
||||
pub fn population(&self, q: usize, cell: usize) -> f64 {
|
||||
self.f.get(q * self.stride + cell).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Set every fluid cell to the equilibrium of `state(cell) = (density, velocity)` in lattice
|
||||
/// units (velocity as defined with the half force).
|
||||
pub fn set_equilibrium(&mut self, state: impl Fn(usize) -> (f64, [f64; 3])) {
|
||||
@@ -611,8 +387,7 @@ impl Flow {
|
||||
continue;
|
||||
}
|
||||
let (rho, u) = state(cell);
|
||||
let force = self.force_at(cell);
|
||||
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * force[a]);
|
||||
let bare = [0, 1, 2].map(|a| u[a] - 0.5 * self.acceleration[a]);
|
||||
for q in 0..Q {
|
||||
self.f[q * s + cell] = equilibrium(q, rho, bare);
|
||||
}
|
||||
@@ -634,8 +409,7 @@ impl Flow {
|
||||
j[1] += fq * cq[1];
|
||||
j[2] += fq * cq[2];
|
||||
}
|
||||
let force = self.force_at(cell);
|
||||
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * force[a]) / rho);
|
||||
let u = [0, 1, 2].map(|a| (j[a] + 0.5 * rho * self.acceleration[a]) / rho);
|
||||
Some((rho, u))
|
||||
}
|
||||
|
||||
@@ -655,7 +429,6 @@ impl Flow {
|
||||
/// One time step. Fails with [`Error::Unstable`] when a density stops being a positive
|
||||
/// number or a lattice speed exceeds [`MAX_LATTICE_SPEED`].
|
||||
pub fn step(&mut self) -> Result<()> {
|
||||
self.refresh_buoyancy();
|
||||
let max_u2 = self.collide();
|
||||
if !(max_u2 <= MAX_LATTICE_SPEED * MAX_LATTICE_SPEED) {
|
||||
return Err(Error::Unstable(format!(
|
||||
@@ -667,177 +440,10 @@ impl Flow {
|
||||
self.stream();
|
||||
self.open_boundaries();
|
||||
std::mem::swap(&mut self.f, &mut self.g);
|
||||
if self.thermal.is_some() {
|
||||
self.collide_thermal();
|
||||
self.stream_thermal();
|
||||
self.open_boundaries_thermal();
|
||||
if let Some(t) = &mut self.thermal {
|
||||
std::mem::swap(&mut t.g, &mut t.g2);
|
||||
}
|
||||
if !self.update_temperature() {
|
||||
return Err(Error::Unstable(format!(
|
||||
"the heat went unstable at time step {}: a temperature stopped being a number; lower the time step, refine the spacing or check the heat fluxes",
|
||||
self.steps_done + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
self.steps_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With buoyancy: every cell's acceleration from its temperature.
|
||||
fn refresh_buoyancy(&mut self) {
|
||||
let Some(t) = &self.thermal else { return };
|
||||
if self.cell_force.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (a, b, t0) = (self.acceleration, t.buoyancy, t.initial);
|
||||
for (force, temp) in self.cell_force.iter_mut().zip(&t.temperature) {
|
||||
let dt = temp - t0;
|
||||
*force = [a[0] + b[0] * dt, a[1] + b[1] * dt, a[2] + b[2] * dt];
|
||||
}
|
||||
}
|
||||
|
||||
/// BGK collision of the scalar lattice, in place, with the velocity of the momentum
|
||||
/// collision.
|
||||
fn collide_thermal(&mut self) {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let fluid = &self.fluid;
|
||||
let cell_u = &self.cell_u;
|
||||
let Some(t) = &mut self.thermal else { return };
|
||||
let omega = t.omega;
|
||||
let mut blocks = cell_blocks(&mut t.g, s, n);
|
||||
parallel::for_each_mut(&mut blocks, |b, g| {
|
||||
let len = g.first().map_or(0, |d| d.len());
|
||||
for i in 0..len {
|
||||
let cell = b * CELLS + i;
|
||||
if !fluid.get(cell).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let mut temp = 0.0;
|
||||
for q in 0..QT {
|
||||
temp += g.get(q).and_then(|d| d.get(i)).copied().unwrap_or(0.0);
|
||||
}
|
||||
let u = cell_u.get(cell).copied().unwrap_or([0.0; 3]);
|
||||
for q in 0..QT {
|
||||
if let Some(x) = g.get_mut(q).and_then(|d| d.get_mut(i)) {
|
||||
*x -= omega * (*x - equilibrium_t(q, temp, u));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pull streaming of the scalar lattice into `g2` with the wall rules of the links (the
|
||||
/// first seven directions), then the heat received per group.
|
||||
fn stream_thermal(&mut self) {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let (links, offset) = (&self.links, &self.offset);
|
||||
let Some(t) = &mut self.thermal else { return };
|
||||
let (g, groups) = (&t.g, &t.groups);
|
||||
let mut blocks = cell_blocks(&mut t.g2, s, n);
|
||||
parallel::for_each_mut(&mut blocks, |b, out| {
|
||||
let start = b * CELLS;
|
||||
for (q, dst) in out.iter_mut().enumerate().take(QT) {
|
||||
let off = offset.get(q).copied().unwrap_or(0);
|
||||
let src = g.get(q * s..q * s + n).unwrap_or_default();
|
||||
for (i, x) in dst.iter_mut().enumerate() {
|
||||
if let Some(from) = (start + i).checked_add_signed(-off)
|
||||
&& let Some(v) = src.get(from)
|
||||
{
|
||||
*x = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = links.partition_point(|l| (l.cell as usize) < start);
|
||||
for link in links.get(first..).unwrap_or_default().iter().take_while(|l| (l.cell as usize) < start + CELLS) {
|
||||
let (cell, q) = (link.cell as usize, link.q as usize);
|
||||
if q >= QT {
|
||||
continue;
|
||||
}
|
||||
let v = thermal_rule(g, s, groups, cell, q, link.rule);
|
||||
if let (Some(v), Some(x)) = (v, out.get_mut(q).and_then(|d| d.get_mut(cell - start))) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
t.heat.fill(0.0);
|
||||
for link in links {
|
||||
let (cell, q) = (link.cell as usize, link.q as usize);
|
||||
if q >= QT {
|
||||
continue;
|
||||
}
|
||||
if let (Rule::Bounce { group, .. } | Rule::Reflect { group, .. }, Some(back)) =
|
||||
(link.rule, thermal_rule(g, s, groups, cell, q, link.rule))
|
||||
{
|
||||
let out = g.get(opposite(q) * s + cell).copied().unwrap_or(0.0);
|
||||
if let Some(h) = t.heat.get_mut(group as usize) {
|
||||
// what left the fluid toward the wall minus what came back
|
||||
*h += out - back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inlets hold their temperature; outlets take their neighbour's populations.
|
||||
fn open_boundaries_thermal(&mut self) {
|
||||
let s = self.stride;
|
||||
let (open, offset, cell_u) = (&self.open, &self.offset, &self.cell_u);
|
||||
let Some(t) = &mut self.thermal else { return };
|
||||
for (oc, held) in open.iter().zip(&t.open) {
|
||||
let cell = oc.cell as usize;
|
||||
match held {
|
||||
Some(value) => {
|
||||
let u = cell_u.get(cell).copied().unwrap_or([0.0; 3]);
|
||||
for q in 0..QT {
|
||||
if let Some(x) = t.g2.get_mut(q * s + cell) {
|
||||
*x = equilibrium_t(q, *value, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// the neighbour one cell inward along the face's axis
|
||||
let axis_dir = 1 + 2 * oc.axis + usize::from(oc.inward < 0.0);
|
||||
let off = offset.get(axis_dir).copied().unwrap_or(0);
|
||||
if let Some(from) = cell.checked_add_signed(off) {
|
||||
for q in 0..QT {
|
||||
let v = t.g2.get(q * s + from).copied();
|
||||
if let (Some(v), Some(x)) = (v, t.g2.get_mut(q * s + cell)) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every fluid cell's temperature from its populations.
|
||||
/// The temperature of every fluid cell from its populations; false when one is not finite.
|
||||
fn update_temperature(&mut self) -> bool {
|
||||
let n = self.n;
|
||||
let s = self.stride;
|
||||
let fluid = &self.fluid;
|
||||
let Some(t) = &mut self.thermal else { return true };
|
||||
let mut finite = true;
|
||||
for cell in 0..n {
|
||||
if !fluid.get(cell).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let mut temp = 0.0;
|
||||
for q in 0..QT {
|
||||
temp += t.g.get(q * s + cell).copied().unwrap_or(0.0);
|
||||
}
|
||||
finite &= temp.is_finite();
|
||||
if let Some(slot) = t.temperature.get_mut(cell) {
|
||||
*slot = temp;
|
||||
}
|
||||
}
|
||||
finite
|
||||
}
|
||||
|
||||
/// Two-relaxation-time collision with Guo forcing, in place, by blocks of [`CELLS`] cells
|
||||
/// in parallel. Returns the largest squared lattice speed, or NaN when a density is not
|
||||
/// positive.
|
||||
@@ -845,27 +451,19 @@ impl Flow {
|
||||
let n = self.n;
|
||||
let (wp, wm) = (self.omega_plus, self.omega_minus);
|
||||
let (sp, sm) = (1.0 - 0.5 * wp, 1.0 - 0.5 * wm);
|
||||
let constant = self.acceleration;
|
||||
let cell_force = &self.cell_force;
|
||||
let forced = constant != [0.0; 3] || !cell_force.is_empty();
|
||||
let a = self.acceleration;
|
||||
let forced = a != [0.0; 3];
|
||||
let fluid = &self.fluid;
|
||||
let mut blocks = cell_blocks(&mut self.f, self.stride, n);
|
||||
let mut maxima = vec![0.0f64; blocks.len()];
|
||||
// with heat, each block's velocities go to its slice of cell_u (empty otherwise)
|
||||
let mut u_blocks: Vec<&mut [[f64; 3]]> = self.cell_u.chunks_mut(CELLS).collect();
|
||||
let mut work: Vec<(&mut Vec<&mut [f64]>, &mut f64, Option<&mut [[f64; 3]]>)> = Vec::with_capacity(blocks.len());
|
||||
let mut u_iter = u_blocks.drain(..);
|
||||
for (f, m) in blocks.iter_mut().zip(maxima.iter_mut()) {
|
||||
work.push((f, m, u_iter.next()));
|
||||
}
|
||||
parallel::for_each_mut(&mut work, |b, (f, max_u2, u_out)| {
|
||||
let mut work: Vec<(&mut Vec<&mut [f64]>, &mut f64)> = blocks.iter_mut().zip(maxima.iter_mut()).collect();
|
||||
parallel::for_each_mut(&mut work, |b, (f, max_u2)| {
|
||||
let mut fq = [0.0; Q];
|
||||
let len = f.first().map_or(0, |d| d.len());
|
||||
for i in 0..len {
|
||||
if !fluid.get(b * CELLS + i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let a = cell_force.get(b * CELLS + i).copied().unwrap_or(constant);
|
||||
let (mut rho, mut j) = (0.0, [0.0; 3]);
|
||||
for q in 0..Q {
|
||||
let v = f.get(q).and_then(|d| d.get(i)).copied().unwrap_or(0.0);
|
||||
@@ -884,9 +482,6 @@ impl Flow {
|
||||
let u = [(j[0] + 0.5 * force[0]) / rho, (j[1] + 0.5 * force[1]) / rho, (j[2] + 0.5 * force[2]) / rho];
|
||||
let uu = u[0] * u[0] + u[1] * u[1] + u[2] * u[2];
|
||||
**max_u2 = max_u2.max(uu);
|
||||
if let Some(slot) = u_out.as_mut().and_then(|u| u.get_mut(i)) {
|
||||
*slot = u;
|
||||
}
|
||||
let uf = u[0] * force[0] + u[1] * force[1] + u[2] * force[2];
|
||||
let mut put = |q: usize, v: f64| {
|
||||
if let Some(x) = f.get_mut(q).and_then(|d| d.get_mut(i)) {
|
||||
@@ -997,9 +592,9 @@ impl Flow {
|
||||
/// Zou–He velocity and pressure boundaries on the streamed populations.
|
||||
fn open_boundaries(&mut self) {
|
||||
let s = self.stride;
|
||||
let a_half = self.acceleration.map(|v| 0.5 * v);
|
||||
for oc in &self.open {
|
||||
let cell = oc.cell as usize;
|
||||
let a_half = self.force_at(cell).map(|v| 0.5 * v);
|
||||
let axis = oc.axis;
|
||||
let sign = oc.inward;
|
||||
let ci = |q: usize| f64::from(C[q][axis]);
|
||||
@@ -1047,78 +642,3 @@ impl Flow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a scalar population of a cell next to a wall or open face becomes (`None`: streamed as
|
||||
/// usual): a periodic image pulls, an adiabatic wall (or a slip face) bounces back, a held
|
||||
/// temperature anti-bounces back, a flux adds its heat, an open face is set afterwards.
|
||||
fn thermal_rule(g: &[f64], s: usize, groups: &[ThermalCondition], cell: usize, q: usize, rule: Rule) -> Option<f64> {
|
||||
let back = g.get(opposite(q) * s + cell).copied()?;
|
||||
match rule {
|
||||
Rule::Pull { from } => g.get(q * s + from as usize).copied(),
|
||||
Rule::Bounce { group, .. } | Rule::Reflect { group, .. } => Some(match groups.get(group as usize).copied().unwrap_or_default() {
|
||||
ThermalCondition::Adiabatic {} => back,
|
||||
ThermalCondition::Temperature { value } => -back + 2.0 * WT.get(q).copied().unwrap_or(0.0) * value,
|
||||
ThermalCondition::Flux { value } => back + value,
|
||||
}),
|
||||
Rule::Open => Some(back),
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::backend::Backend for Flow {
|
||||
fn name(&self) -> String {
|
||||
"cpu".into()
|
||||
}
|
||||
|
||||
fn advance(&mut self, steps: u64) -> Result<()> {
|
||||
for _ in 0..steps {
|
||||
self.step()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn steps_done(&self) -> u64 {
|
||||
self.steps_done
|
||||
}
|
||||
|
||||
fn fields(&mut self) -> Result<crate::backend::Fields> {
|
||||
let n = self.n;
|
||||
let mut density = filled(n, 0.0, "density field")?;
|
||||
let mut velocity = filled(n, [0.0; 3], "velocity field")?;
|
||||
for cell in 0..n {
|
||||
if let (Some((rho, u)), Some(d), Some(v)) = (self.moments(cell), density.get_mut(cell), velocity.get_mut(cell)) {
|
||||
*d = rho;
|
||||
*v = u;
|
||||
}
|
||||
}
|
||||
let temperature = self.thermal.as_ref().map(|t| t.temperature.clone()).unwrap_or_default();
|
||||
Ok(crate::backend::Fields { density, velocity, temperature })
|
||||
}
|
||||
|
||||
fn heat(&mut self) -> Result<Vec<f64>> {
|
||||
Ok(Flow::heat(self).to_vec())
|
||||
}
|
||||
|
||||
fn mass(&mut self) -> Result<f64> {
|
||||
Ok(Flow::mass(self))
|
||||
}
|
||||
|
||||
fn forces(&mut self) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(self.forces.clone())
|
||||
}
|
||||
|
||||
fn groups(&self) -> &[Group] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
fn fluid_cells(&self) -> usize {
|
||||
self.fluid_cells
|
||||
}
|
||||
|
||||
fn is_fluid(&self, cell: usize) -> bool {
|
||||
Flow::is_fluid(self, cell)
|
||||
}
|
||||
|
||||
fn empty_obstacles(&self) -> &[String] {
|
||||
&self.empty_obstacles
|
||||
}
|
||||
}
|
||||
@@ -48,18 +48,15 @@
|
||||
// Population arrays are indexed by cell and direction counts fixed when they are allocated.
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
pub mod backend;
|
||||
pub mod flow;
|
||||
pub mod lattice;
|
||||
pub mod setup;
|
||||
pub mod solve;
|
||||
pub mod voxel;
|
||||
|
||||
pub use backend::{Backend, Choice, Fields, register_backend};
|
||||
pub use flow::{Flow, Group, Link, OpenCell, Rule, Target, ThermalState};
|
||||
pub use setup::ThermalSetup;
|
||||
pub use flow::{Flow, Group};
|
||||
pub use setup::{Obstacle, Setup, fluid_step};
|
||||
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, HeatHistory, run, solve_fluid, solve_fluid_on};
|
||||
pub use solve::{FluidResult, FluidStats, ForceHistory, Frame, run, solve_fluid};
|
||||
pub use voxel::Grid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
|
||||
+3
-118
@@ -26,7 +26,7 @@
|
||||
//! density stops the run with [`crate::Error::Unstable`].
|
||||
|
||||
use fieldcraft_mesh::surface::BoundarySurface;
|
||||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition, ThermalCondition};
|
||||
use fieldcraft_model::{BoxFace, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition};
|
||||
|
||||
use crate::voxel::Grid;
|
||||
use crate::{Error, Limits, Result};
|
||||
@@ -66,25 +66,6 @@ pub struct ObstacleFaces {
|
||||
pub names: Vec<String>,
|
||||
/// Every face tag's wall velocity in lattice units (zero for walls at rest).
|
||||
pub velocities: Vec<[f64; 3]>,
|
||||
/// Every face tag's thermal condition (fluxes in lattice units); adiabatic without heat.
|
||||
pub thermal: Vec<ThermalCondition>,
|
||||
}
|
||||
|
||||
/// Heat in the fluid, in lattice units: a D3Q7 scalar lattice for the temperature
|
||||
/// (`flow::ThermalState`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThermalSetup {
|
||||
/// The scalar lattice's relaxation time: its diffusivity is `(τ_T − ½) / 4`.
|
||||
pub tau: f64,
|
||||
/// The fluid's thermal diffusivity k / (ρ c), model units.
|
||||
pub diffusivity: f64,
|
||||
pub initial_temperature: f64,
|
||||
/// `−β g` in lattice units: the buoyant acceleration per temperature above the initial one.
|
||||
pub buoyancy: [f64; 3],
|
||||
/// The box faces' conditions (fluxes in lattice units: temperature per step through a face).
|
||||
pub faces: [ThermalCondition; 6],
|
||||
/// Power per lattice heat flow (a temperature per step through one cell face): ρ c Δx³ / Δt.
|
||||
pub heat_scale: f64,
|
||||
}
|
||||
|
||||
/// A fluid step in lattice units.
|
||||
@@ -112,8 +93,6 @@ pub struct Setup {
|
||||
pub initial_velocity: [f64; 3],
|
||||
/// The domain's obstacles, in its order.
|
||||
pub obstacles: Vec<ObstacleFaces>,
|
||||
/// Heat in the fluid, when the domain has thermal settings.
|
||||
pub thermal: Option<ThermalSetup>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -129,21 +108,6 @@ pub fn fluid_step<'m>(model: &'m Model, step: Option<&str>) -> Result<(&'m field
|
||||
Ok((s, domain))
|
||||
}
|
||||
|
||||
/// A thermal condition in lattice units: a flux (power per area) becomes the temperature it adds
|
||||
/// to the cell behind the face per step.
|
||||
fn lattice_thermal(c: &ThermalCondition, flux_scale: f64) -> Result<ThermalCondition> {
|
||||
Ok(match *c {
|
||||
ThermalCondition::Flux { value } => {
|
||||
let lattice = value * flux_scale;
|
||||
if !lattice.is_finite() {
|
||||
return Err(Error::Invalid(format!("a heat flux of {value} is too large for this lattice")));
|
||||
}
|
||||
ThermalCondition::Flux { value: lattice }
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
/// Convert a fluid step, checking it against the stability limits and `limits`.
|
||||
pub fn new(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<Setup> {
|
||||
@@ -279,68 +243,9 @@ impl Setup {
|
||||
)));
|
||||
}
|
||||
|
||||
// Heat: the scalar lattice's relaxation time from the diffusivity, the conditions in
|
||||
// lattice units (a flux is a temperature per step through a cell face).
|
||||
let mut warnings = Vec::new();
|
||||
let thermal = match &domain.thermal {
|
||||
None => None,
|
||||
Some(t) => {
|
||||
let props = material.and_then(|m| m.thermal.as_ref());
|
||||
let (Some(Directional::Isotropic(k)), Some(c)) = (props.and_then(|p| p.conductivity), props.and_then(|p| p.specific_heat)) else {
|
||||
return Err(Error::Invalid(format!(
|
||||
"material `{}` needs an isotropic conductivity and a specific heat for heat in the fluid",
|
||||
domain.material
|
||||
)));
|
||||
};
|
||||
let rho_c = density * c;
|
||||
let diffusivity = k / rho_c;
|
||||
if !(diffusivity > 0.0 && diffusivity.is_finite()) {
|
||||
return Err(Error::Invalid(format!("the thermal diffusivity k / (ρ c) of material `{}` is {diffusivity}", domain.material)));
|
||||
}
|
||||
let alpha = diffusivity * dt / (h * h);
|
||||
let tau = 4.0 * alpha + 0.5;
|
||||
if !(tau >= MIN_TAU) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"the thermal relaxation time τ_T = {tau:.5} is below {MIN_TAU}: the grid cannot resolve diffusivity {diffusivity} at this time step (cell Péclet number {:.1}); refine the spacing or raise the time step",
|
||||
if alpha > 0.0 { lattice_velocity / alpha } else { f64::INFINITY }
|
||||
)));
|
||||
}
|
||||
if tau > 2.0 {
|
||||
warnings.push(format!("the thermal relaxation time τ_T = {tau:.3} is large: heat diffuses fast on this grid and time step; a shorter time step is more accurate"));
|
||||
}
|
||||
let flux_scale = dt / (rho_c * h);
|
||||
let mut faces = [ThermalCondition::Adiabatic {}; 6];
|
||||
for face in BoxFace::ALL {
|
||||
let c = t.faces.get(face);
|
||||
if matches!(c, ThermalCondition::Flux { .. })
|
||||
&& matches!(
|
||||
domain.faces.get(face),
|
||||
FaceCondition::Velocity { .. } | FaceCondition::Pressure { .. } | FaceCondition::Periodic {}
|
||||
)
|
||||
{
|
||||
return Err(Error::Invalid(format!(
|
||||
"box face {}: a heat flux goes on a wall, not on an inlet, outlet or periodic face",
|
||||
face.name()
|
||||
)));
|
||||
}
|
||||
if let Some(slot) = faces.get_mut(face.index()) {
|
||||
*slot = lattice_thermal(c, flux_scale)?;
|
||||
}
|
||||
}
|
||||
let beta = t.expansion.unwrap_or(0.0);
|
||||
Some(ThermalSetup {
|
||||
tau,
|
||||
diffusivity,
|
||||
initial_temperature: t.initial_temperature,
|
||||
buoyancy: t.gravity.map(|g| -beta * g * dt * dt / h),
|
||||
faces,
|
||||
heat_scale: rho_c * h * h * h / dt,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Obstacles, their face names and their faces' wall velocities.
|
||||
let mut domain_obstacles = Vec::with_capacity(domain.obstacles.len());
|
||||
let mut warnings = Vec::new();
|
||||
for body in &domain.obstacles {
|
||||
let (index, o) = obstacles
|
||||
.iter()
|
||||
@@ -350,25 +255,6 @@ impl Setup {
|
||||
let tags = o.surface.tri_face().iter().copied().max().map_or(1, |m| m as usize + 1).max(o.faces.len());
|
||||
let names: Vec<String> = (0..tags).map(|t| o.faces.get(t).cloned().unwrap_or_else(|| format!("face{t}"))).collect();
|
||||
let mut velocities = vec![[0.0; 3]; tags];
|
||||
let mut thermal_faces = vec![ThermalCondition::Adiabatic {}; tags];
|
||||
if let (Some(t), Some(ts)) = (&domain.thermal, &thermal) {
|
||||
// power per area → temperature per step through a cell face, as for the box faces
|
||||
let flux_scale = h * h / ts.heat_scale;
|
||||
for sb in t.surfaces.iter().filter(|sb| &sb.face.body == body) {
|
||||
let mut hit = false;
|
||||
for (tag, name) in names.iter().enumerate() {
|
||||
if sb.face.selects(&fieldcraft_model::GeometryRef::face(body.as_str(), name.as_str())?) {
|
||||
hit = true;
|
||||
if let Some(slot) = thermal_faces.get_mut(tag) {
|
||||
*slot = lattice_thermal(&sb.condition, flux_scale)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for sb in domain.surfaces.iter().filter(|sb| &sb.face.body == body) {
|
||||
let mut hit = false;
|
||||
for (tag, name) in names.iter().enumerate() {
|
||||
@@ -383,7 +269,7 @@ impl Setup {
|
||||
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
|
||||
}
|
||||
}
|
||||
domain_obstacles.push(ObstacleFaces { index, names, velocities, thermal: thermal_faces });
|
||||
domain_obstacles.push(ObstacleFaces { index, names, velocities });
|
||||
}
|
||||
if mach > 0.1 + 1e-12 {
|
||||
warnings.push(format!(
|
||||
@@ -412,7 +298,6 @@ impl Setup {
|
||||
acceleration,
|
||||
initial_velocity: domain.initial_velocity.map(|c| c * dt / h),
|
||||
obstacles: domain_obstacles,
|
||||
thermal,
|
||||
warnings,
|
||||
domain,
|
||||
})
|
||||
|
||||
+31
-85
@@ -2,7 +2,6 @@
|
||||
|
||||
use fieldcraft_model::Model;
|
||||
|
||||
use crate::backend::{Backend, Choice, Fields};
|
||||
use crate::flow::Flow;
|
||||
use crate::setup::{Obstacle, Setup};
|
||||
use crate::voxel::Grid;
|
||||
@@ -23,21 +22,10 @@ pub struct Frame {
|
||||
pub velocity: Vec<[f32; 3]>,
|
||||
/// Gauge pressure.
|
||||
pub pressure: Vec<f32>,
|
||||
/// Temperature, when the flow carries heat (empty otherwise).
|
||||
pub temperature: Vec<f32>,
|
||||
/// The total fluid mass (density times volume).
|
||||
pub mass: f64,
|
||||
}
|
||||
|
||||
/// The heat the fluid gives one wall over time (power; positive into the wall).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HeatHistory {
|
||||
pub name: String,
|
||||
pub body: Option<String>,
|
||||
/// One value per [`FluidResult::history_times`].
|
||||
pub power: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The force the fluid exerts on one wall over time.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ForceHistory {
|
||||
@@ -66,10 +54,6 @@ pub struct FluidStats {
|
||||
pub mach: f64,
|
||||
/// Fluid cells times steps.
|
||||
pub cell_updates: u64,
|
||||
/// What stepped the flow: `cpu`, or `gpu: <adapter>`.
|
||||
pub backend: String,
|
||||
/// The scalar lattice's relaxation time, with heat.
|
||||
pub tau_thermal: Option<f64>,
|
||||
}
|
||||
|
||||
/// The result of a fluid step.
|
||||
@@ -84,72 +68,58 @@ pub struct FluidResult {
|
||||
pub history_times: Vec<f64>,
|
||||
/// Every box wall and every obstacle face.
|
||||
pub forces: Vec<ForceHistory>,
|
||||
/// The heat every wall receives, with heat (empty otherwise), the same walls and times.
|
||||
pub heat: Vec<HeatHistory>,
|
||||
pub stats: FluidStats,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl FluidResult {
|
||||
/// The cell containing a point and the frame's velocity, pressure and temperature (`None`
|
||||
/// without heat) there (`None` outside the grid; zeros in a solid cell).
|
||||
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32, Option<f32>)> {
|
||||
/// The cell containing a point and the frame's velocity and pressure there (`None` outside
|
||||
/// the grid; zeros in a solid cell).
|
||||
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32)> {
|
||||
let cell = self.grid.cell_at(p)?;
|
||||
let f = self.frames.get(frame)?;
|
||||
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?, f.temperature.get(cell).copied()))
|
||||
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the fluid step named `step` (or the model's only fluid step) with the obstacles' surfaces
|
||||
/// prepared by the caller, on the CPU.
|
||||
/// prepared by the caller.
|
||||
pub fn solve_fluid(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<FluidResult> {
|
||||
solve_fluid_on(model, step, obstacles, limits, Choice::Cpu)
|
||||
}
|
||||
|
||||
/// [`solve_fluid`] on the backend `choice` names (the registered device backend, or the CPU).
|
||||
pub fn solve_fluid_on(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits, choice: Choice) -> Result<FluidResult> {
|
||||
let setup = Setup::new(model, step, obstacles, limits)?;
|
||||
let flow = Flow::new(&setup, obstacles)?;
|
||||
let mut backend = crate::backend::backend(choice, flow, &setup)?;
|
||||
run(&setup, backend.as_mut(), limits)
|
||||
let mut flow = Flow::new(&setup, obstacles)?;
|
||||
run(&setup, &mut flow, limits)
|
||||
}
|
||||
|
||||
fn frame(setup: &Setup, flow: &mut dyn Backend, step: u64) -> Result<Frame> {
|
||||
fn frame(setup: &Setup, flow: &Flow, step: u64) -> Result<Frame> {
|
||||
let n = setup.grid.len();
|
||||
let mut velocity = Vec::new();
|
||||
let mut pressure = Vec::new();
|
||||
velocity.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
|
||||
pressure.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
|
||||
let vs = setup.velocity_scale();
|
||||
let Fields { density, velocity: u, temperature: t } = flow.fields()?;
|
||||
let mut temperature = Vec::new();
|
||||
if !t.is_empty() {
|
||||
temperature.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
|
||||
}
|
||||
for cell in 0..n {
|
||||
if flow.is_fluid(cell) {
|
||||
velocity.push(u.get(cell).copied().unwrap_or([0.0; 3]).map(|c| (c * vs) as f32));
|
||||
pressure.push(setup.gauge_pressure(density.get(cell).copied().unwrap_or(1.0)) as f32);
|
||||
} else {
|
||||
velocity.push([0.0; 3]);
|
||||
pressure.push(0.0);
|
||||
}
|
||||
if !t.is_empty() {
|
||||
temperature.push(t.get(cell).copied().unwrap_or(0.0) as f32);
|
||||
match flow.moments(cell) {
|
||||
Some((rho, u)) => {
|
||||
velocity.push(u.map(|c| (c * vs) as f32));
|
||||
pressure.push(setup.gauge_pressure(rho) as f32);
|
||||
}
|
||||
None => {
|
||||
velocity.push([0.0; 3]);
|
||||
pressure.push(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mass = flow.mass()? * setup.density * setup.grid.spacing.powi(3);
|
||||
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, temperature, mass })
|
||||
let mass = flow.mass() * setup.density * setup.grid.spacing.powi(3);
|
||||
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, mass })
|
||||
}
|
||||
|
||||
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state, on any
|
||||
/// backend) through the setup's steps, recording frames and force histories. The steps go to
|
||||
/// the backend in chunks, up to the next frame, history sample or progress check.
|
||||
pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<FluidResult> {
|
||||
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state) through
|
||||
/// the setup's steps, recording frames and force histories.
|
||||
pub fn run(setup: &Setup, flow: &mut Flow, limits: &Limits) -> Result<FluidResult> {
|
||||
let n = setup.grid.len();
|
||||
let total = setup.steps;
|
||||
let frames = u64::from(setup.frames).min(total).max(1);
|
||||
let frame_bytes = (frames + 1) as f64 * n as f64 * if setup.thermal.is_some() { 20.0 } else { 16.0 };
|
||||
let frame_bytes = (frames + 1) as f64 * n as f64 * 16.0;
|
||||
if !(frame_bytes <= limits.max_frame_bytes as f64) {
|
||||
return Err(Error::Limit(format!(
|
||||
"{} frames of {n} cells need {:.0} MB, above the cap of {:.0} MB; write fewer frames or raise `max_frame_bytes`",
|
||||
@@ -160,42 +130,26 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
}
|
||||
let every = total.div_ceil(MAX_HISTORY).max(1);
|
||||
let fs = setup.force_scale();
|
||||
let hs = setup.thermal.as_ref().map_or(0.0, |t| t.heat_scale);
|
||||
|
||||
let mut out_frames = vec![frame(setup, flow, 0)?];
|
||||
let mut history_times = Vec::new();
|
||||
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
|
||||
let mut heats: Vec<Vec<f64>> = vec![Vec::new(); if setup.thermal.is_some() { flow.groups().len() } else { 0 }];
|
||||
let mut next_frame = 1u64;
|
||||
// Frame j (1..=frames) at step round(j · total / frames).
|
||||
let frame_step = |j: u64| (j * total + frames / 2) / frames;
|
||||
fieldcraft_model::progress::stage("Fluid steps", Some(total as f64), "");
|
||||
let mut k = 0u64;
|
||||
while k < total {
|
||||
for k in 1..=total {
|
||||
fieldcraft_model::progress::check()?;
|
||||
// Up to the next event: a history sample, a frame, a progress count.
|
||||
let mut next = total.min(k + 64 - k % 64);
|
||||
next = next.min((k / every + 1) * every);
|
||||
if next_frame <= frames {
|
||||
next = next.min(frame_step(next_frame).max(k + 1));
|
||||
}
|
||||
flow.advance(next - k)?;
|
||||
k = next;
|
||||
if k.is_multiple_of(64) || k == total {
|
||||
flow.step()?;
|
||||
if k % 64 == 0 {
|
||||
fieldcraft_model::progress::count(k as f64);
|
||||
}
|
||||
if k.is_multiple_of(every) || k == total {
|
||||
if k % every == 0 || k == total {
|
||||
history_times.push(k as f64 * setup.time_step);
|
||||
for (h, f) in forces.iter_mut().zip(flow.forces()?) {
|
||||
for (h, f) in forces.iter_mut().zip(flow.forces()) {
|
||||
h.push(f.map(|c| c * fs));
|
||||
}
|
||||
if !heats.is_empty() {
|
||||
for (h, q) in heats.iter_mut().zip(flow.heat()?) {
|
||||
h.push(q * hs);
|
||||
}
|
||||
}
|
||||
}
|
||||
while next_frame <= frames && k == frame_step(next_frame) {
|
||||
// Frame j (1..=frames) at step round(j · total / frames).
|
||||
while next_frame <= frames && k == (next_frame * total + frames / 2) / frames {
|
||||
out_frames.push(frame(setup, flow, k)?);
|
||||
next_frame += 1;
|
||||
}
|
||||
@@ -204,8 +158,7 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
let mut warnings = setup.warnings.clone();
|
||||
// The weakly compressible scheme turns pressure differences into density differences of
|
||||
// Δp / (ρ c_s²) in lattice units; large ones show the time step is too long for the flow.
|
||||
let end = flow.fields()?;
|
||||
let spread = (0..n).filter(|c| flow.is_fluid(*c)).filter_map(|c| end.density.get(c)).map(|rho| (rho - 1.0).abs()).fold(0.0, f64::max);
|
||||
let spread = (0..n).filter_map(|c| flow.moments(c)).map(|(rho, _)| (rho - 1.0).abs()).fold(0.0, f64::max);
|
||||
if spread > COMPRESSIBILITY_WARNING {
|
||||
warnings.push(format!(
|
||||
"the density varies by {:.1}% at the end: the weakly compressible error is visible; a shorter time step reduces it (quadratically)",
|
||||
@@ -215,10 +168,6 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
for body in flow.empty_obstacles() {
|
||||
warnings.push(format!("obstacle `{body}` covers no cell centre: it is smaller than the spacing or outside the box"));
|
||||
}
|
||||
if flow.name() != "cpu" {
|
||||
warnings
|
||||
.push(format!("solved on {}: single precision, deterministic on this device, within the backend's tolerance of the CPU", flow.name()));
|
||||
}
|
||||
let solid: Vec<bool> = (0..n).map(|c| !flow.is_fluid(c)).collect();
|
||||
Ok(FluidResult {
|
||||
step: setup.step.clone(),
|
||||
@@ -227,7 +176,6 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
frames: out_frames,
|
||||
history_times,
|
||||
forces: flow.groups().iter().zip(forces).map(|(g, force)| ForceHistory { name: g.name.clone(), body: g.body.clone(), force }).collect(),
|
||||
heat: flow.groups().iter().zip(heats).map(|(g, power)| HeatHistory { name: g.name.clone(), body: g.body.clone(), power }).collect(),
|
||||
stats: FluidStats {
|
||||
cells: n,
|
||||
fluid_cells: flow.fluid_cells(),
|
||||
@@ -241,8 +189,6 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
|
||||
lattice_velocity: setup.lattice_velocity,
|
||||
mach: setup.mach,
|
||||
cell_updates: (flow.fluid_cells() as u64).saturating_mul(total),
|
||||
backend: flow.name(),
|
||||
tau_thermal: setup.thermal.as_ref().map(|t| t.tau),
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ pub fn domain(min: [f64; 3], max: [f64; 3], spacing: f64) -> FluidDomain {
|
||||
initial_velocity: [0.0; 3],
|
||||
acceleration: [0.0; 3],
|
||||
tolerance: None,
|
||||
thermal: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
//! Heat in the fluid: the temperature lattice against closed-form solutions and a published
|
||||
//! benchmark (de Vahl Davis, heavy).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use fieldcraft_lbm::backend::Backend;
|
||||
use fieldcraft_lbm::{Limits, run};
|
||||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, Fluid, FluidThermal, Material, Model, Procedure, Step, Thermal, ThermalCondition};
|
||||
|
||||
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
|
||||
|
||||
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
|
||||
/// ρ α, unit specific heat), so k / (ρ c) = α.
|
||||
fn hot_model(density: f64, nu: f64, alpha: f64, domain: fieldcraft_model::FluidDomain, procedure: Procedure) -> Model {
|
||||
let mut m = Model::default();
|
||||
m.add_material(
|
||||
"Fluid",
|
||||
Material {
|
||||
density: Some(density),
|
||||
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
|
||||
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
|
||||
..Material::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
m.add_fluid_domain("Flow", domain).unwrap();
|
||||
m.add_step(Step::new("Run", procedure), None).unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
/// Plates at y = 0 and y = 1, periodic in x and z, no flow: held at 0 and 1, the steady
|
||||
/// temperature is linear, and anti-bounce-back puts the walls exactly halfway between cells.
|
||||
#[test]
|
||||
fn conduction_between_plates_is_linear() {
|
||||
for n in [8usize, 16] {
|
||||
let (h, alpha) = (1.0, 0.05);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal::default();
|
||||
t.faces.y_min = ThermalCondition::Temperature { value: 0.0 };
|
||||
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
|
||||
d.thermal = Some(t);
|
||||
// τ_T = 1: Δt = Δx² / (8 α)
|
||||
let dt = dx * dx / (8.0 * alpha);
|
||||
let m = hot_model(1.0, 0.1, alpha, d, step(5.0 * h * h / alpha, dt, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
assert!((setup.thermal.as_ref().unwrap().tau - 1.0).abs() < 1e-12);
|
||||
run_all(&setup, &mut flow);
|
||||
let mut err: f64 = 0.0;
|
||||
for j in 0..n {
|
||||
let y = (j as f64 + 0.5) * dx;
|
||||
let t = flow.temperature(j).unwrap();
|
||||
err = err.max((t - y / h).abs());
|
||||
}
|
||||
println!("conduction between plates, {n} cells: max error {err:.2e}");
|
||||
assert!(err < 1e-9, "{n}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A flux q through the cold plate and a held temperature on the other: the steady drop over
|
||||
/// the gap is q H / k, and the heat the hot plate receives is the flux times its area.
|
||||
#[test]
|
||||
fn a_heat_flux_sets_the_gradient_and_the_wall_heat_balances() {
|
||||
let (n, h, alpha, rho) = (16usize, 1.0, 0.05, 2.0);
|
||||
let dx = h / n as f64;
|
||||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||||
d.faces.x_min = PERIODIC;
|
||||
d.faces.x_max = PERIODIC;
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let q = 0.3;
|
||||
let mut t = FluidThermal::default();
|
||||
t.faces.y_min = ThermalCondition::Flux { value: q };
|
||||
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
|
||||
d.thermal = Some(t);
|
||||
let dt = dx * dx / (8.0 * alpha);
|
||||
let m = hot_model(rho, 0.1, alpha, d, step(8.0 * h * h / alpha, dt, None));
|
||||
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
|
||||
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
|
||||
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
|
||||
let k = rho * alpha;
|
||||
// the temperature halfway below the first cell (the wall) against the hot wall at 1
|
||||
let t0 = f64::from(r.frames.last().unwrap().temperature[0]) + 0.5 * dx * q / k;
|
||||
let drop = t0 - 1.0;
|
||||
let expected = q * h / k;
|
||||
println!("flux plate: drop {drop:.6} vs q H / k = {expected:.6}");
|
||||
assert!((drop - expected).abs() < 1e-6 * expected, "{drop} vs {expected}");
|
||||
let hot = r.heat.iter().find(|w| w.name == "y_max").unwrap();
|
||||
let last = *hot.power.last().unwrap();
|
||||
let area = dx * dx;
|
||||
println!("heat into the hot wall: {last:.6e} vs q A = {:.6e}", q * area);
|
||||
assert!((last - q * area).abs() < 1e-6 * q * area, "{last} vs {}", q * area);
|
||||
let cold = r.heat.iter().find(|w| w.name == "y_min").unwrap();
|
||||
assert!((cold.power.last().unwrap() + q * area).abs() < 1e-6 * q * area, "the flux face gives q A");
|
||||
}
|
||||
|
||||
/// A channel with a velocity inlet at 1, slip (adiabatic) walls and a pressure outlet: the
|
||||
/// plug flow carries the inlet temperature through the whole channel. The flow starts at
|
||||
/// its steady state (an impulsive start in a frictionless channel rings for a long time).
|
||||
#[test]
|
||||
fn the_flow_carries_the_inlet_temperature() {
|
||||
let (h, l, umax, nu, alpha) = (1.0, 4.0, 0.1, 0.05, 0.01);
|
||||
let dx = h / 8.0;
|
||||
let mut d = domain([0.0; 3], [l, h, dx], dx);
|
||||
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: fieldcraft_model::Profile::Uniform };
|
||||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||||
d.faces.y_min = FaceCondition::Slip {};
|
||||
d.faces.y_max = FaceCondition::Slip {};
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal::default();
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
d.thermal = Some(t);
|
||||
let lattice_u = 0.05;
|
||||
let m = hot_model(1.0, nu, alpha, d, step(4.0 * l / umax, lattice_u * dx / umax, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
flow.set_equilibrium(|_| (1.0, [lattice_u, 0.0, 0.0]));
|
||||
run_all(&setup, &mut flow);
|
||||
let grid = *flow.grid();
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for c in 0..grid.len() {
|
||||
let t = flow.temperature(c).unwrap();
|
||||
lo = lo.min(t);
|
||||
hi = hi.max(t);
|
||||
}
|
||||
println!("inlet temperature carried through: {lo:.6}..{hi:.6}");
|
||||
assert!(lo > 1.0 - 1e-3 && hi < 1.0 + 1e-3, "{lo}..{hi}");
|
||||
let f = flow.fields().unwrap();
|
||||
assert_eq!(f.temperature.len(), grid.len());
|
||||
}
|
||||
|
||||
/// Natural convection in a square cavity (de Vahl Davis, Int. J. Numer. Meth. Fluids 3 (1983)
|
||||
/// 249–264): hot left wall at 1, cold right wall at 0, adiabatic top and bottom, Pr = 0.71.
|
||||
/// The mean Nusselt number on the hot wall at Ra = 10³ is 1.118 (10⁴: 2.243); `u_max` is the
|
||||
/// fastest flow in the cavity in units of α / H (3.65 and 19.6). Returns the Nusselt number
|
||||
/// and the steady power into the hot wall and into the cold wall (the mean over the last
|
||||
/// tenth of the run).
|
||||
fn cavity(ra: f64, n: usize, u_max: f64) -> (f64, f64, f64) {
|
||||
let (h, pr) = (1.0, 0.71);
|
||||
let dx = h / n as f64;
|
||||
let alpha = 1.0;
|
||||
let nu = pr * alpha;
|
||||
// g β ΔT H³ / (ν α) = Ra, with ΔT = 1, H = 1
|
||||
let g_beta = ra * nu * alpha / (h * h * h);
|
||||
let mut d = domain([0.0; 3], [h, h, dx], dx);
|
||||
d.faces.z_min = PERIODIC;
|
||||
d.faces.z_max = PERIODIC;
|
||||
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -g_beta, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
|
||||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||||
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
|
||||
d.thermal = Some(t);
|
||||
// the fastest flow at lattice speed 0.05, and a lattice diffusivity of at most 1/4 (τ_T ≤ 1.5)
|
||||
let dt = (0.05 * dx / (u_max * alpha / h)).min(dx * dx / (4.0 * alpha));
|
||||
let m = hot_model(1.0, nu, alpha, d, step(4.0 * h * h / alpha, dt, None));
|
||||
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
|
||||
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
|
||||
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
|
||||
// Nu = (heat into the fluid through the hot wall) H / (k ΔT A): the hot wall's history
|
||||
// holds the power the wall receives (negative when it heats the fluid).
|
||||
let steady = |name: &str| {
|
||||
let w = r.heat.iter().find(|w| w.name == name).unwrap();
|
||||
let m = w.power.len();
|
||||
let tail = &w.power[m - m / 10..];
|
||||
tail.iter().sum::<f64>() / tail.len() as f64
|
||||
};
|
||||
let (hot, cold) = (steady("x_min"), steady("x_max"));
|
||||
let k = alpha;
|
||||
let area = h * dx;
|
||||
let nu_mean = -hot * h / (k * 1.0 * area);
|
||||
println!("cavity Ra = {ra:.0e}, {n} cells: Nu = {nu_mean:.4}, hot wall {hot:.6e}, cold wall {cold:.6e}; {:?}", r.warnings);
|
||||
(nu_mean, hot, cold)
|
||||
}
|
||||
|
||||
/// At steady state the heat the hot wall gives the fluid is the heat the cold wall takes.
|
||||
fn heat_balances(hot: f64, cold: f64, tolerance: f64) {
|
||||
assert!(hot < 0.0 && cold > 0.0, "the hot wall heats the fluid ({hot}) and the cold wall cools it ({cold})");
|
||||
assert!((hot + cold).abs() < tolerance * cold, "heat in {hot:e} vs heat out {cold:e}");
|
||||
}
|
||||
|
||||
/// The published cavity on a coarse grid, every run: Ra = 10³ on 16 cells is within 2 % of
|
||||
/// de Vahl Davis (the heavy test refines it), and the heat balances between the walls.
|
||||
#[test]
|
||||
fn natural_convection_cavity_is_close_on_a_coarse_grid() {
|
||||
let (nu3, hot, cold) = cavity(1e3, 16, 3.65);
|
||||
assert!((nu3 / 1.118 - 1.0).abs() < 0.02, "Ra 1e3: {nu3}");
|
||||
heat_balances(hot, cold, 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||||
fn natural_convection_cavity_matches_de_vahl_davis() {
|
||||
let (nu3, hot, cold) = cavity(1e3, 32, 3.65);
|
||||
assert!((nu3 / 1.118 - 1.0).abs() < 0.01, "Ra 1e3: {nu3}");
|
||||
heat_balances(hot, cold, 1e-3);
|
||||
let (nu4, hot, cold) = cavity(1e4, 48, 19.6);
|
||||
assert!((nu4 / 2.243 - 1.0).abs() < 0.01, "Ra 1e4: {nu4}");
|
||||
heat_balances(hot, cold, 1e-3);
|
||||
}
|
||||
|
||||
/// A periodic box with a uniform flow and a blob of heat: the total temperature is conserved
|
||||
/// to round-off and the blob moves with the flow.
|
||||
#[test]
|
||||
fn a_periodic_flow_conserves_and_carries_heat() {
|
||||
let (l, u0, nu, alpha) = (4.0, 0.1, 0.05, 0.01);
|
||||
let dx = l / 32.0;
|
||||
let mut d = domain([0.0; 3], [l, dx, dx], dx);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
d.initial_velocity = [u0, 0.0, 0.0];
|
||||
d.thermal = Some(FluidThermal::default());
|
||||
let m = hot_model(1.0, nu, alpha, d, step(l / u0, 0.05 * dx / u0, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
let grid = *flow.grid();
|
||||
flow.set_temperature(|c| if grid.coords(c)[0] < 8 { 1.0 } else { 0.0 });
|
||||
let total = |f: &fieldcraft_lbm::Flow| (0..grid.len()).map(|c| f.temperature(c).unwrap()).sum::<f64>();
|
||||
let t0 = total(&flow);
|
||||
run_all(&setup, &mut flow);
|
||||
let t1 = total(&flow);
|
||||
let row: Vec<String> = (0..32).map(|i| format!("{:.3}", flow.temperature(grid.index([i, 0, 0])).unwrap())).collect();
|
||||
println!("periodic blob after one lap: total {t0:.6} -> {t1:.6}; {}", row.join(" "));
|
||||
assert!((t1 - t0).abs() < 1e-10 * t0, "{t0} vs {t1}");
|
||||
}
|
||||
|
||||
/// A sine of temperature in a periodic box drifting with a uniform flow: it keeps its shape,
|
||||
/// moves with the flow and decays as e^(−α k² t). At rest the decay converges to the
|
||||
/// diffusivity at second order. With the flow the D3Q7 lattice's linear equilibrium diffuses
|
||||
/// less along the flow by (τ_T − ½) u² in lattice units, 4 u² of the diffusivity (Chopard,
|
||||
/// Falcone and Latt, Eur. Phys. J. Special Topics 171 (2009) 245–249): the measured decay
|
||||
/// shows that deficit, which bounds the lattice speed a heat problem should run at.
|
||||
#[test]
|
||||
fn a_drifting_sine_decays_at_the_diffusivity() {
|
||||
let (l, nu, alpha, amplitude) = (4.0, 0.05, 0.01, 0.25);
|
||||
let k = 2.0 * std::f64::consts::PI / l;
|
||||
let decay_time = 1.0 / (alpha * k * k);
|
||||
let mut at_rest = Vec::new();
|
||||
for (n, lattice_u) in [(32usize, 0.0), (64, 0.0), (64, 0.1), (64, 0.15)] {
|
||||
let dx = l / n as f64;
|
||||
// At rest τ_T = 1; with the flow at `lattice_u` the time step follows the speed.
|
||||
let u0 = if lattice_u > 0.0 { 0.1 } else { 0.0 };
|
||||
let dt = if lattice_u > 0.0 { lattice_u * dx / u0 } else { dx * dx / (8.0 * alpha) };
|
||||
let mut d = domain([0.0; 3], [l, dx, dx], dx);
|
||||
for f in BoxFace::ALL {
|
||||
*d.faces.get_mut(f) = PERIODIC;
|
||||
}
|
||||
d.initial_velocity = [u0, 0.0, 0.0];
|
||||
d.thermal = Some(FluidThermal { initial_temperature: 0.5, ..FluidThermal::default() });
|
||||
let m = hot_model(1.0, nu, alpha, d, step(decay_time, dt, None));
|
||||
let (setup, mut flow) = prepare(&m, &[]);
|
||||
let tau = setup.thermal.as_ref().unwrap().tau;
|
||||
let grid = *flow.grid();
|
||||
let x = |c: usize| (grid.coords(c)[0] as f64 + 0.5) * dx;
|
||||
flow.set_temperature(|c| 0.5 + amplitude * (k * x(c)).sin());
|
||||
run_all(&setup, &mut flow);
|
||||
let t = setup.steps as f64 * setup.time_step;
|
||||
// The sine's amplitude and phase from the Fourier coefficient at k, then the shape.
|
||||
let (mut a, mut b) = (0.0, 0.0);
|
||||
for i in 0..n {
|
||||
let c = grid.index([i, 0, 0]);
|
||||
let temp = flow.temperature(c).unwrap();
|
||||
a += temp * (k * x(c)).sin();
|
||||
b += temp * (k * x(c)).cos();
|
||||
}
|
||||
let measured = (a * a + b * b).sqrt() * 2.0 / n as f64;
|
||||
let drift = -b.atan2(a) / k;
|
||||
let mut shape: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
let c = grid.index([i, 0, 0]);
|
||||
shape = shape.max((flow.temperature(c).unwrap() - 0.5 - measured * (k * (x(c) - drift)).sin()).abs());
|
||||
}
|
||||
let alpha_measured = -(measured / amplitude).ln() / (k * k * t);
|
||||
let error = alpha_measured / alpha - 1.0;
|
||||
let lag = (drift - u0 * t).rem_euclid(l);
|
||||
let lag = lag.min(l - lag);
|
||||
println!(
|
||||
"drifting sine, {n} cells, lattice speed {lattice_u}: τ_T = {tau:.4}, α measured {alpha_measured:.5} vs {alpha} ({error:+.2e}), lag {lag:.2e}, shape error {shape:.2e}"
|
||||
);
|
||||
// A Fourier mode of a linear scheme on a periodic grid stays one, to round-off.
|
||||
assert!(shape < 1e-9 * amplitude, "{n} cells at {lattice_u}: the sine lost its shape by {shape}");
|
||||
assert!(lag < dx / 5.0, "{n} cells at {lattice_u}: drift {drift} vs {}", u0 * t);
|
||||
if lattice_u > 0.0 {
|
||||
let deficit = -4.0 * lattice_u * lattice_u;
|
||||
assert!((error - deficit).abs() < 0.01, "{n} cells at {lattice_u}: α error {error} vs the deficit {deficit}");
|
||||
} else {
|
||||
assert!(error.abs() < 1e-3, "{n} cells at rest: α error {error}");
|
||||
at_rest.push(error.abs());
|
||||
}
|
||||
}
|
||||
let order = orders(&at_rest, 2.0);
|
||||
println!("decay at rest converges at order {order:?}");
|
||||
assert!(order.iter().all(|o| *o > 1.8), "{order:?}");
|
||||
}
|
||||
@@ -10,11 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-model = { workspace = true }
|
||||
fieldcraft-parallel = { workspace = true }
|
||||
# Pure-Rust maths (a port of musl's libm) for sin, cos, log, exp, pow, hypot, acos: the same
|
||||
# bits on every platform, where the system library's are not correctly rounded and differ (the
|
||||
# plate's hex mesh differed between macOS and Linux, #438). atan2 and cbrt are crate::vec3's.
|
||||
libm = "0.2"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -146,7 +146,7 @@ impl Path {
|
||||
if norm(sub(back, p)) > 1e-9 * la.max(lb) || ((c * c + s * s).sqrt() - 1.0).abs() > 1e-9 {
|
||||
return Err(Error::Invalid(format!("corner {p:?} is not on the edge's ellipse or arc about {center:?}")));
|
||||
}
|
||||
Ok(crate::vec3::atan2(s, c))
|
||||
Ok(s.atan2(c))
|
||||
};
|
||||
let start = angle(from)?;
|
||||
let mut sweep = angle(to)? - start;
|
||||
@@ -170,7 +170,7 @@ impl Path {
|
||||
Path::Line { from, to } => add_scaled(from, s, sub(to, from)),
|
||||
Path::Ellipse { center, a, b, start, sweep, .. } => {
|
||||
let t = start + s * sweep;
|
||||
add_scaled(add_scaled(center, libm::cos(t), a), libm::sin(t), b)
|
||||
add_scaled(add_scaled(center, t.cos(), a), t.sin(), b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ impl Path {
|
||||
/// The parameters of `n` elements along one direction with last/first size ratio `bias`, at
|
||||
/// `order` points per element (`order · n + 1` values from 0 to 1; mid-side values halfway).
|
||||
fn parameters(n: usize, bias: f64, order: usize) -> Vec<f64> {
|
||||
let q = if n > 1 { libm::pow(bias, 1.0 / (n - 1) as f64) } else { 1.0 };
|
||||
let q = if n > 1 { bias.powf(1.0 / (n - 1) as f64) } else { 1.0 };
|
||||
let mut ends = Vec::with_capacity(n + 1);
|
||||
let (mut sum, mut size) = (0.0, 1.0);
|
||||
ends.push(0.0);
|
||||
@@ -645,7 +645,7 @@ mod tests {
|
||||
];
|
||||
let m = block(&Block { kind: ElementKind::Hex20, corners, divisions: [3, 6, 2], bias: [1.0; 3], curves }).unwrap();
|
||||
for p in &m.points {
|
||||
let r = libm::hypot(p[0], p[1]);
|
||||
let r = p[0].hypot(p[1]);
|
||||
assert!(r > a - 1e-12 && r < b + 1e-12, "{p:?}");
|
||||
}
|
||||
let exact = std::f64::consts::PI / 4.0 * (b * b - a * a) * 0.5;
|
||||
|
||||
@@ -372,7 +372,7 @@ fn composite_tags(surface: &BoundarySurface, surfaces: &[Option<Surface>]) -> Ve
|
||||
unit(cross(sub(b, a), sub(c, a)))
|
||||
})
|
||||
};
|
||||
let cos_min = libm::cos(SMOOTH_ANGLE);
|
||||
let cos_min = SMOOTH_ANGLE.cos();
|
||||
// Per pair of faces (low tag, high tag): whether every segment between them is smooth.
|
||||
let mut pairs: BTreeMap<(u32, u32), bool> = BTreeMap::new();
|
||||
for fe in surface.feature_edges().iter().filter(|e| e.kind == FeatureKind::FaceBoundary) {
|
||||
|
||||
@@ -166,7 +166,7 @@ fn sub(a: Q, b: Q) -> Q {
|
||||
}
|
||||
|
||||
fn len(a: Q) -> f64 {
|
||||
libm::hypot(a[0], a[1])
|
||||
a[0].hypot(a[1])
|
||||
}
|
||||
|
||||
fn cross(a: Q, b: Q) -> f64 {
|
||||
@@ -186,17 +186,14 @@ fn unit(a: Q) -> Option<Q> {
|
||||
/// region on the left: π on a straight line, less at a convex corner, more at a concave one.
|
||||
fn interior(a: Q, o: Q, b: Q) -> f64 {
|
||||
let (u, v) = (sub(o, a), sub(b, o));
|
||||
std::f64::consts::PI - crate::vec3::atan2(cross(u, v), dot(u, v))
|
||||
std::f64::consts::PI - cross(u, v).atan2(dot(u, v))
|
||||
}
|
||||
|
||||
/// The inward bisector at a corner of interior angle `theta` whose outgoing edge runs along unit
|
||||
/// `w`: `w` turned left by half the angle.
|
||||
fn bisector(w: Q, theta: f64) -> Q {
|
||||
let half = 0.5 * theta;
|
||||
{
|
||||
let (s, c) = (libm::sin(half), libm::cos(half));
|
||||
[w[0] * c - w[1] * s, w[0] * s + w[1] * c]
|
||||
}
|
||||
[w[0] * half.cos() - w[1] * half.sin(), w[0] * half.sin() + w[1] * half.cos()]
|
||||
}
|
||||
|
||||
fn centroid(pts: &[Q]) -> Q {
|
||||
@@ -916,8 +913,8 @@ fn assignable(loops: &[Poly], cuts: &[Vec<Q>], s: &Settings) -> bool {
|
||||
fn o_grid(loops: &[Poly], s: &Settings, budget: &mut usize) -> Vec<Vec<Q>> {
|
||||
let (Some(outer), Some(hole)) = (loops.first(), loops.get(1)) else { return Vec::new() };
|
||||
let c = centroid(&hole.pts);
|
||||
let corner = |p: &Poly, i: usize| p.is_end(i) && region::is_corner(p.angle(i));
|
||||
let concave = |p: &Poly, i: usize| p.is_end(i) && region::is_reflex(p.angle(i));
|
||||
let corner = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) <= 0.75 * std::f64::consts::PI;
|
||||
let concave = |p: &Poly, i: usize| p.is_end(i) && p.angle(i) >= 1.25 * std::f64::consts::PI;
|
||||
// A cut from loop point (l, i) along d that must end on loop `target`.
|
||||
let ray = |l: usize, i: usize, d: Q, target: usize| -> Option<Vec<Q>> {
|
||||
let o = loops.get(l)?.at(i);
|
||||
@@ -1134,9 +1131,8 @@ mod tests {
|
||||
let n = ((a1 - a0).abs() / 5.0).ceil().max(1.0) as usize;
|
||||
(0..=n)
|
||||
.map(|k| {
|
||||
// The mesher's portable trig, so every platform gets the same points (#556).
|
||||
let t = (a0 + (a1 - a0) * k as f64 / n as f64).to_radians();
|
||||
[c[0] + r * libm::cos(t), c[1] + r * libm::sin(t)]
|
||||
[c[0] + r * t.cos(), c[1] + r * t.sin()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1309,10 +1305,6 @@ mod tests {
|
||||
]);
|
||||
let m = mesh_profile(&profile, &[], 2.5);
|
||||
check(&m, &profile, 0.5);
|
||||
// The intended O-grid on every platform (#556): Windows' last-bit-different arcs used to
|
||||
// give 10 regions and 2063 quads with a 0.375 corner.
|
||||
assert_eq!(m.section.schemes.len(), 9, "{:?}", m.section.schemes);
|
||||
assert_eq!(m.section.quads.iter().map(Vec::len).sum::<usize>(), 848);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1328,7 +1320,7 @@ mod tests {
|
||||
/// The half crank's cross-section (y ≤ 0) as the extrusion projects it: the outline, the
|
||||
/// bosses' circles where they rise above the arm, and the pocket's outline.
|
||||
fn crank_half() -> (Profile, Vec<Vec<Q>>) {
|
||||
let deg = |p: Q, c: Q| crate::vec3::atan2(p[1] - c[1], p[0] - c[0]).to_degrees();
|
||||
let deg = |p: Q, c: Q| (p[1] - c[1]).atan2(p[0] - c[0]).to_degrees();
|
||||
let spindle = [0.0, 0.0];
|
||||
let pedal = [170.0, 0.0];
|
||||
let j1 = [13.455063072993756, -18.65372021077189];
|
||||
@@ -1352,7 +1344,7 @@ mod tests {
|
||||
};
|
||||
let outer = vec![
|
||||
a(spindle, 23.0, [-23.0, 0.0], j1, true),
|
||||
a(c1, libm::hypot(j1[0] - c1[0], j1[1] - c1[1]), j1, r1, false),
|
||||
a(c1, (j1[0] - c1[0]).hypot(j1[1] - c1[1]), j1, r1, false),
|
||||
line(r1, r2),
|
||||
a(c2, 6.0, r2, j2, false),
|
||||
a(pedal, 15.0, j2, [185.0, 0.0], true),
|
||||
|
||||
@@ -410,7 +410,7 @@ fn section_boundary(quads: &[[u32; 4]]) -> Result<SectionBoundary> {
|
||||
/// The layer parameters `0 = t_0 < … < t_n = 1`, thicknesses in geometric progression with the
|
||||
/// last over the first equal to `bias`.
|
||||
pub(crate) fn parameters(n: usize, bias: f64) -> Vec<f64> {
|
||||
let r = if n > 1 { libm::pow(bias, 1.0 / (n - 1) as f64) } else { 1.0 };
|
||||
let r = if n > 1 { bias.powf(1.0 / (n - 1) as f64) } else { 1.0 };
|
||||
let mut out = Vec::with_capacity(n + 1);
|
||||
let (mut sum, mut h) = (0.0, 1.0);
|
||||
out.push(0.0);
|
||||
|
||||
@@ -101,7 +101,7 @@ impl Surface {
|
||||
let h = dot(w, axis);
|
||||
let radial = unit(sub(w, scale(axis, h)))?;
|
||||
let rho = norm(sub(w, scale(axis, h)));
|
||||
let (c, s) = (libm::cos(half_angle), libm::sin(half_angle));
|
||||
let (c, s) = (half_angle.cos(), half_angle.sin());
|
||||
let t = (h * c + rho * s).max(0.0);
|
||||
add(apex, add(scale(axis, t * c), scale(radial, t * s)))
|
||||
}
|
||||
@@ -128,7 +128,7 @@ impl Surface {
|
||||
Surface::Cone { apex, axis, half_angle } => {
|
||||
let w = sub(p, apex);
|
||||
let radial = unit(sub(w, scale(axis, dot(w, axis))))?;
|
||||
unit(sub(scale(radial, libm::cos(half_angle)), scale(axis, libm::sin(half_angle))))
|
||||
unit(sub(scale(radial, half_angle.cos()), scale(axis, half_angle.sin())))
|
||||
}
|
||||
Surface::Sphere { center, .. } => unit(sub(p, center)),
|
||||
Surface::Torus { center, axis, major, .. } => {
|
||||
@@ -383,7 +383,7 @@ impl<'a> Inside<'a> {
|
||||
let (a, b, c) = (sub(*a, x), sub(*b, x), sub(*c, x));
|
||||
let (la, lb, lc) = (norm(a), norm(b), norm(c));
|
||||
let den = la * lb * lc + dot(a, b) * lc + dot(a, c) * lb + dot(b, c) * la;
|
||||
sum += 2.0 * crate::vec3::atan2(dot(a, cross(b, c)), den);
|
||||
sum += 2.0 * dot(a, cross(b, c)).atan2(den);
|
||||
}
|
||||
Ok(sum / (4.0 * std::f64::consts::PI))
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ impl Frame {
|
||||
let w = sub(p, o);
|
||||
let z = dot(w, a);
|
||||
let (x, y) = (dot(w, e1), dot(w, e2));
|
||||
let t = crate::vec3::atan2(y, x);
|
||||
([z, libm::hypot(x, y)], if t < 0.0 { t + TAU } else { t })
|
||||
let t = y.atan2(x);
|
||||
([z, x.hypot(y)], if t < 0.0 { t + TAU } else { t })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ impl Frame {
|
||||
pub(crate) fn point(&self, q: Q, h: f64) -> P {
|
||||
match *self {
|
||||
Frame::Translate { o, u, v, d } => add(o, add(add(scale(u, q[0]), scale(v, q[1])), scale(d, h))),
|
||||
Frame::Rotate { o, a, e1, e2 } => add(o, add(scale(a, q[0]), scale(add(scale(e1, libm::cos(h)), scale(e2, libm::sin(h))), q[1]))),
|
||||
Frame::Rotate { o, a, e1, e2 } => add(o, add(scale(a, q[0]), scale(add(scale(e1, h.cos()), scale(e2, h.sin())), q[1]))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ impl CellGrid {
|
||||
}
|
||||
(lo, hi)
|
||||
});
|
||||
let per_axis = (crate::vec3::cbrt(ctxs.len() as f64).ceil() as usize * 2).clamp(1, MAX_GRID_DIM);
|
||||
let per_axis = ((ctxs.len() as f64).cbrt().ceil() as usize * 2).clamp(1, MAX_GRID_DIM);
|
||||
let extent = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
|
||||
let step = if extent > 0.0 && extent.is_finite() { extent / per_axis as f64 } else { 1.0 };
|
||||
let dim = |k: usize| (((hi[k] - lo[k]) / step).ceil() as usize).clamp(1, MAX_GRID_DIM);
|
||||
|
||||
@@ -47,24 +47,6 @@ const MAX_ANNULUS_NODES: usize = 4096;
|
||||
/// Smoothing sweeps over the interior of a triangle primitive.
|
||||
const TRIANGLE_SWEEPS: usize = 20;
|
||||
|
||||
/// How close (radians) an interior angle must come to a classification threshold to count as on
|
||||
/// it. Exact geometry lands on the thresholds (an O-grid's 45° cuts meet straight sides at
|
||||
/// exactly 135°), and the computed angle is then rounded either way by the last bits of its
|
||||
/// points: those differ between platforms' `sin`/`cos` and between tessellations, and the
|
||||
/// scheme must not (#556). An angle within this of a threshold is taken as exactly on it, and
|
||||
/// on a threshold is smooth, neither a corner nor reflex: that keeps the slot's intended O-grid.
|
||||
const ANGLE_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
/// A corner: an interior angle clearly below 135° (exactly 135° is not one).
|
||||
pub(crate) fn is_corner(angle: f64) -> bool {
|
||||
angle < 0.75 * PI - ANGLE_TOLERANCE
|
||||
}
|
||||
|
||||
/// A reflex turn no mapped or triangle region can take: clearly above 225°.
|
||||
pub(crate) fn is_reflex(angle: f64) -> bool {
|
||||
angle > 1.25 * PI + ANGLE_TOLERANCE
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Scheme {
|
||||
/// Corner positions (curve uses) in the single loop.
|
||||
@@ -86,7 +68,7 @@ fn angles(lp: &[Vec<Q>], ccw: bool) -> Option<Vec<f64>> {
|
||||
let next = lp.get(k)?;
|
||||
let (a, o, b) = (*prev.get(prev.len().checked_sub(2)?)?, *next.first()?, *next.get(1)?);
|
||||
let (u, v) = ([o[0] - a[0], o[1] - a[1]], [b[0] - o[0], b[1] - o[1]]);
|
||||
let turn = crate::vec3::atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]);
|
||||
let turn = (u[0] * v[1] - u[1] * v[0]).atan2(u[0] * v[0] + u[1] * v[1]);
|
||||
out.push(PI - if ccw { turn } else { -turn });
|
||||
}
|
||||
Some(out)
|
||||
@@ -107,10 +89,10 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
||||
match loops {
|
||||
[lp] => {
|
||||
let Some(a) = angles(lp, ccw(lp)) else { return Scheme::Free };
|
||||
if a.iter().any(|&x| is_reflex(x)) {
|
||||
if a.iter().any(|&x| x >= 1.25 * PI) {
|
||||
return Scheme::Free;
|
||||
}
|
||||
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| is_corner(x))).collect();
|
||||
let corners: Vec<usize> = (0..a.len()).filter(|&k| a.get(k).is_some_and(|&x| x <= 0.75 * PI)).collect();
|
||||
if corners.is_empty() {
|
||||
return Scheme::Disk;
|
||||
}
|
||||
@@ -121,7 +103,7 @@ pub(crate) fn classify(loops: &[Vec<Vec<Q>>]) -> Scheme {
|
||||
}
|
||||
}
|
||||
[outer, inner] => {
|
||||
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| !is_corner(x) && !is_reflex(x)));
|
||||
let smooth = |lp: &Vec<Vec<Q>>| angles(lp, ccw(lp)).is_some_and(|a| a.iter().all(|&x| x > 0.75 * PI && x < 1.25 * PI));
|
||||
if smooth(outer) && smooth(inner) { Scheme::Annulus } else { Scheme::Free }
|
||||
}
|
||||
_ => Scheme::Free,
|
||||
@@ -461,11 +443,8 @@ fn disk(loops: &[Vec<Q>], size: &dyn SizeField, opts: &QuadOptions) -> Result<Op
|
||||
// ∫y², ∫x² (both × 12) and ∫xy (× 24), signed as the loop: the region's long direction is
|
||||
// the principal axis of the largest ∫ along², the angle of 0.5 · atan2(2 ∫xy, ∫x² − ∫y²).
|
||||
let sign = a2.signum();
|
||||
let theta = 0.5 * crate::vec3::atan2(sign * ixy, sign * (iyy - ixx));
|
||||
let (u, v) = {
|
||||
let (s, c) = (libm::sin(theta), libm::cos(theta));
|
||||
([c, s], [-s, c])
|
||||
};
|
||||
let theta = 0.5 * (sign * ixy).atan2(sign * (iyy - ixx));
|
||||
let (u, v) = ([theta.cos(), theta.sin()], [-theta.sin(), theta.cos()]);
|
||||
let local = |p: Q| {
|
||||
let d = [p[0] - centre[0], p[1] - centre[1]];
|
||||
[d[0] * u[0] + d[1] * u[1], d[0] * v[0] + d[1] * v[1]]
|
||||
@@ -586,30 +565,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::size::UniformSize;
|
||||
|
||||
/// An angle of exactly 135°, the threshold, is not a corner however the last bits of its
|
||||
/// points round (Windows' `sin` against glibc's, or two tessellations): a trapezoid with one
|
||||
/// stays a triangle rather than flipping to mapped when that vertex moves by an ulp (#556).
|
||||
#[test]
|
||||
fn an_angle_on_the_threshold_is_not_a_corner_whichever_way_it_rounds() {
|
||||
// (0, 0) 90°, (3, 0) 45°, (2, 1) 135°, (0, 1) 90°: three corners and a 135° bend.
|
||||
let trapezoid = |p: Q| {
|
||||
let pts = [[0.0, 0.0], [3.0, 0.0], p, [0.0, 1.0]];
|
||||
let curves: Vec<Vec<Q>> = (0..4).map(|k| vec![pts[k], pts[(k + 1) % 4]]).collect();
|
||||
classify(&[curves])
|
||||
};
|
||||
let steps: [fn(f64) -> f64; 3] = [|v| v, f64::next_up, f64::next_down];
|
||||
for nx in steps {
|
||||
for ny in steps {
|
||||
let mut p = [nx(2.0), ny(1.0)];
|
||||
// A few ulps further each way as well.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(trapezoid(p), Scheme::Triangle([0, 1, 3]), "{p:?}");
|
||||
p = [nx(p[0]), ny(p[1])];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn circle(r: f64, n: usize, ccw: bool) -> Vec<Q> {
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
|
||||
@@ -249,6 +249,25 @@ impl<'a> Geometry<'a> {
|
||||
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()];
|
||||
@@ -286,16 +305,6 @@ impl<'a> Geometry<'a> {
|
||||
}
|
||||
patch.faces.sort_unstable();
|
||||
patch.faces.dedup();
|
||||
patches.push(patch);
|
||||
}
|
||||
|
||||
// A crease with the same patch on both sides (one inside a face that does not run from
|
||||
// feature to feature, such as a tessellation crease across a fillet) separates nothing:
|
||||
// no region border can follow it and its open ends could never be mapped, so it is not a
|
||||
// feature.
|
||||
edge_set.retain(|_, t| t.map(|t| tri_patch.get(t as usize).copied()).iter().collect::<std::collections::BTreeSet<_>>().len() == 2);
|
||||
// The widths count only the curves that bound the patches.
|
||||
for patch in &mut patches {
|
||||
let mut perimeter = 0.0;
|
||||
for &t in &patch.triangles {
|
||||
let tri = tris.get(t as usize).copied().unwrap_or([0; 3]);
|
||||
@@ -306,26 +315,11 @@ impl<'a> Geometry<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.width = if perimeter > 0.0 { 2.0 * patch.area / perimeter } else { f64::INFINITY };
|
||||
if perimeter > 0.0 {
|
||||
patch.width = 2.0 * patch.area / perimeter;
|
||||
}
|
||||
patches.push(patch);
|
||||
}
|
||||
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();
|
||||
|
||||
// Curves: chains from corners, then closed loops.
|
||||
let mut used: BTreeMap<(u32, u32), bool> = edge_set.keys().map(|k| (*k, false)).collect();
|
||||
|
||||
@@ -88,35 +88,6 @@ impl Tree {
|
||||
self.leaves
|
||||
}
|
||||
|
||||
/// Allow at most `max_leaves` leaves from now on (an error when there are more already).
|
||||
pub fn set_max_leaves(&mut self, max_leaves: usize) -> Result<()> {
|
||||
if self.leaves > max_leaves {
|
||||
return Err(Error::Limit(format!("the grid has {} cells; the limit is {max_leaves}", self.leaves)));
|
||||
}
|
||||
self.max_leaves = max_leaves;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make `depth` the deepest level allowed (an error when a leaf is deeper already).
|
||||
pub fn set_depth(&mut self, depth: u8) -> Result<()> {
|
||||
if depth > MAX_LEVEL || self.nodes.keys().any(|k| k.0 > depth) {
|
||||
return Err(Error::Invalid(format!("grid depth {depth} is shallower than its leaves")));
|
||||
}
|
||||
self.depth = depth;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves per level, root level first.
|
||||
pub fn level_counts(&self) -> Vec<usize> {
|
||||
let mut out = vec![0; usize::from(self.depth) + 1];
|
||||
for (k, _) in self.nodes.iter().filter(|(_, leaf)| **leaf) {
|
||||
if let Some(n) = out.get_mut(usize::from(k.0)) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `key` is a leaf.
|
||||
pub fn is_leaf(&self, key: &Key) -> bool {
|
||||
self.nodes.get(key).copied().unwrap_or(false)
|
||||
|
||||
+79
-328
@@ -8,14 +8,12 @@
|
||||
//! 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 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 a
|
||||
//! wall, measured along the inward normal of each boundary triangle to a face that faces it)
|
||||
//! and where a patch is narrow, then strongly balanced and closed under the transition
|
||||
//! templates. The levels the thinnest wall and narrowest patch need are found before the grid
|
||||
//! is built, and the root size is chosen with them (see [`octree_hex_mesh`]'s plan): a part
|
||||
//! that needs more levels or cells than allowed fails at once, with the numbers.
|
||||
//! 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.
|
||||
@@ -59,16 +57,9 @@
|
||||
//! 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. On curved parts
|
||||
//! this closure is most of the grid: the corpus's rounded box goes from 8 266 refined cells to
|
||||
//! 85 460 after closure, the loft from 6 650 to 80 438, the vase from 4 206 to 53 346 (thin walls
|
||||
//! cost little: the I-beam's 469 304 cells are all refinement). Schneiders' modified edge rule
|
||||
//! (an edge with a marked end split in three; R. Schneiders, VKI lecture series 2000-4, §5.3)
|
||||
//! makes every concave face a full 3 × 3 face and the seven-marked cell the 27-split, but the
|
||||
//! cell with six marks (a concave edge) then needs a template whose two faces at the unmarked
|
||||
//! edge both fold their three columns into that edge, and no valid construction of it with
|
||||
//! usable element quality is published; the dual schemes need a 2-refinement grid. Both remain
|
||||
//! the upgrade path.
|
||||
//! 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,
|
||||
@@ -105,9 +96,7 @@ use smooth::{Constraint, Optimizer};
|
||||
/// Options and caps of [`octree_hex_mesh`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OctreeOptions {
|
||||
/// The most refinement levels below the root cells (each one a third of the size), at most
|
||||
/// 8. The levels the part's walls and narrow faces need are found before the grid is built
|
||||
/// (see [`octree_hex_mesh`]); a part that needs more fails at once.
|
||||
/// 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,
|
||||
@@ -135,7 +124,7 @@ pub struct OctreeOptions {
|
||||
impl Default for OctreeOptions {
|
||||
fn default() -> Self {
|
||||
OctreeOptions {
|
||||
max_depth: 4,
|
||||
max_depth: 2,
|
||||
max_elements: 5_000_000,
|
||||
curvature_angle: 25.0,
|
||||
curvature_levels: 1,
|
||||
@@ -156,17 +145,6 @@ pub struct OctreeReport {
|
||||
pub attempts: usize,
|
||||
/// Grid leaves of the last build.
|
||||
pub grid_cells: usize,
|
||||
/// The root cells' edge and the deepest level of the grid (see [`octree_hex_mesh`]).
|
||||
pub root_size: f64,
|
||||
pub levels: u8,
|
||||
/// The thinnest wall and the narrowest patch the grid was chosen for (`∞` when none).
|
||||
pub thinnest_wall: f64,
|
||||
pub narrowest_patch: f64,
|
||||
/// Grid leaves per level (root level first) of the last build: after the refinement
|
||||
/// criteria (size, curvature, thickness, narrow patches and retry hints), and after balance
|
||||
/// and closure.
|
||||
pub refined_cells: Vec<usize>,
|
||||
pub closed_cells: Vec<usize>,
|
||||
/// Core elements (from the grid) and boundary-layer elements.
|
||||
pub core_elements: usize,
|
||||
pub layer_elements: usize,
|
||||
@@ -214,11 +192,6 @@ const FIT_STAGES: &[f64] = &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0];
|
||||
const MIN_SNAP_QUALITY: f64 = 0.05;
|
||||
/// Elements below this corner scaled Jacobian are improved by compass search.
|
||||
const IMPROVE_BELOW: f64 = 0.3;
|
||||
/// The two faces of a wall open by at most this angle (degrees) from facing each other. Where a
|
||||
/// convex edge is sharper (a wedge), the part is thinner at a distance `d` from the edge than
|
||||
/// `2 d tan(30°) ≈ 1.15 d`, so the cells that resolve the edge do not resolve its thickness; at
|
||||
/// blunter edges they do, and the edge is a feature, not a wall.
|
||||
const WALL_OPENING: f64 = 60.0;
|
||||
/// 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).
|
||||
@@ -236,14 +209,45 @@ enum Attempt {
|
||||
}
|
||||
|
||||
/// Mesh the solid bounded by `surface` with hexahedra of about `field`'s size (the root cell
|
||||
/// size is the field's largest size ([`SizeField::range`]), else the field at the centre of the
|
||||
/// part's box; cells are refined where the field is smaller than they are; finer cells follow the part's curvature,
|
||||
/// 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 thickness = triangle_thickness(&g);
|
||||
let ctx = plan(&g, field, options, &thickness)?;
|
||||
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 {
|
||||
@@ -275,162 +279,6 @@ pub fn octree_hex_mesh(surface: &BoundarySurface, field: &dyn SizeField, options
|
||||
Err(Error::Failed(format!("octree hex meshing failed after {} builds: {}", retries.len(), retries.join("; "))))
|
||||
}
|
||||
|
||||
/// What the part's walls and narrow patches ask of the grid, measured before it is built.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Needs {
|
||||
/// The thinnest wall (`∞` when no two faces face each other).
|
||||
wall: f64,
|
||||
/// The narrowest patch (`∞` when no patch is bounded by curves).
|
||||
patch: f64,
|
||||
/// The largest cell edge that puts [`OctreeOptions::thickness_cells`] cells across every wall
|
||||
/// and two across every patch.
|
||||
edge: f64,
|
||||
}
|
||||
|
||||
impl Needs {
|
||||
fn of(g: &Geometry, thickness: &[f64], options: &OctreeOptions) -> Needs {
|
||||
let wall = thickness.iter().copied().filter(|t| t.is_finite() && *t > 0.0).fold(f64::INFINITY, f64::min);
|
||||
let patch = g.patches.iter().map(|p| p.width).filter(|w| w.is_finite() && *w > 0.0).fold(f64::INFINITY, f64::min);
|
||||
let by_wall = if options.thickness_cells > 0.0 { wall / options.thickness_cells } else { f64::INFINITY };
|
||||
Needs { wall, patch, edge: by_wall.min(0.5 * patch) }
|
||||
}
|
||||
|
||||
/// Why the cells must be as small as [`Needs::edge`].
|
||||
fn reason(&self, options: &OctreeOptions) -> String {
|
||||
if options.thickness_cells > 0.0 && self.wall / options.thickness_cells <= 0.5 * self.patch {
|
||||
format!("the part has walls {:.4} thick, which {} cells across need cells of {:.4}", self.wall, options.thickness_cells, self.edge)
|
||||
} else {
|
||||
format!("the part has a face {:.4} wide, which two cells across need cells of {:.4}", self.patch, self.edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The levels below a root cell of edge `h` down to cells no larger than `edge` (`None` past
|
||||
/// [`grid::MAX_LEVEL`]).
|
||||
fn levels_below(h: f64, edge: f64) -> Option<u8> {
|
||||
(0..=grid::MAX_LEVEL).find(|&d| h / 3f64.powi(i32::from(d)) <= edge * (1.0 + 1e-9))
|
||||
}
|
||||
|
||||
/// Choose the grid before building it. An octree's cell sizes are the root size divided by
|
||||
/// powers of 3, so the cells across the thinnest wall come out up to 3 times smaller than the
|
||||
/// wall needs, and the cells along every wall are counted in cubes of that. The root cells at
|
||||
/// the requested size, with the walls refined as deep as they need, come first; when that does
|
||||
/// not fit the levels or cells allowed, the root cells are shrunk (by less than 3 times) so that
|
||||
/// one level fewer reaches exactly the size the walls need, which puts far fewer cells along
|
||||
/// them. When neither fits, meshing fails here, before anything is built, naming the wall, the
|
||||
/// levels and the cells each would take. Rebuilds after a failure refine within the levels the
|
||||
/// plan reached, and at least one level below [`OctreeOptions::curvature_levels`].
|
||||
fn plan<'a>(g: &'a Geometry<'a>, field: &'a dyn SizeField, options: &'a OctreeOptions, thickness: &'a [f64]) -> Result<Context<'a>> {
|
||||
let frame = choose_frame(g);
|
||||
let (lo, hi) = frame_box(g, &frame);
|
||||
let centre = from_frame(&frame, [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k])));
|
||||
// The root cells at the largest size; the depth reaches the smallest (size controls).
|
||||
let h = match field.range() {
|
||||
Some((_, coarsest)) if coarsest > 0.0 && coarsest.is_finite() => coarsest,
|
||||
_ => checked_size(field, centre)?,
|
||||
};
|
||||
if let Some((finest, _)) = field.range()
|
||||
&& finest > 0.0
|
||||
&& finest < h
|
||||
{
|
||||
match levels_below(h, finest) {
|
||||
Some(d) if d <= options.max_depth => {}
|
||||
d => {
|
||||
let n = d.map_or_else(|| format!("more than {}", grid::MAX_LEVEL), |l| l.to_string());
|
||||
return Err(Error::Limit(format!(
|
||||
"a size of {finest:.4} below the root size {h:.4} needs {n} levels (each a third); `max_depth` is {}: raise it, or use a larger local size",
|
||||
options.max_depth
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let needs = Needs::of(g, thickness, options);
|
||||
let deep = levels_below(h, needs.edge);
|
||||
let mut anchors: Vec<(f64, Option<u8>)> = vec![(h, deep)];
|
||||
if let Some(d) = deep.filter(|&d| d >= 1) {
|
||||
anchors.push((needs.edge * 3f64.powi(i32::from(d) - 1), Some(d - 1)));
|
||||
}
|
||||
let mut best: Option<(usize, Context<'a>)> = None;
|
||||
let mut refused: Vec<String> = Vec::new();
|
||||
for (root, levels) in anchors {
|
||||
let Some(levels) = levels.filter(|&l| l <= options.max_depth) else {
|
||||
let n = levels.map_or_else(|| format!("more than {}", grid::MAX_LEVEL), |l| l.to_string());
|
||||
refused.push(format!("{n} levels below a size of {root:.4}"));
|
||||
continue;
|
||||
};
|
||||
let ctx = Context::new(g, field, options, thickness, frame, root, (lo, hi), needs)?;
|
||||
match ctx.refine(options.max_elements)? {
|
||||
Refined::Within(tree) => {
|
||||
let mut closed = tree.clone();
|
||||
match closed.close(CLOSURE_ROUNDS, &|k| ctx.outside(k)) {
|
||||
Ok(()) => {
|
||||
let n = closed.leaf_count();
|
||||
// Rebuilds refine within the levels the plan reached, and one level
|
||||
// below the curvature levels.
|
||||
let deepest = closed.level_counts().iter().rposition(|&c| c > 0).and_then(|l| u8::try_from(l).ok()).unwrap_or(0);
|
||||
let depth = deepest.max(options.curvature_levels.saturating_add(1)).min(options.max_depth);
|
||||
let mut tree = tree;
|
||||
tree.set_depth(depth)?;
|
||||
closed.set_depth(depth)?;
|
||||
*ctx.refined_cells.borrow_mut() = tree.level_counts();
|
||||
*ctx.refined.borrow_mut() = Some(tree);
|
||||
*ctx.closed.borrow_mut() = Some(closed);
|
||||
best = Some((n, ctx));
|
||||
break;
|
||||
}
|
||||
Err(Error::Limit(_)) => {
|
||||
refused.push(format!(
|
||||
"{levels} levels below a size of {root:.4}, {} grid cells refined and more than {} after balancing and closure",
|
||||
tree.leaf_count(),
|
||||
options.max_elements
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Refined::Over { level, at_least } => {
|
||||
refused.push(format!("{levels} levels below a size of {root:.4}, more than {at_least} grid cells by level {level}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((_, ctx)) => Ok(ctx),
|
||||
None if needs.edge.is_finite() => Err(Error::Limit(format!(
|
||||
"{}: {}; the limits are {} levels (`max_depth`) and {} cells (`max_elements`); use a smaller size, more levels or cells, or a structured (swept) hex mesh",
|
||||
needs.reason(options),
|
||||
refused.join("; or "),
|
||||
options.max_depth,
|
||||
options.max_elements
|
||||
))),
|
||||
None => Err(Error::Limit(format!(
|
||||
"the grid at a size of {h:.4} needs {}; the limits are {} levels (`max_depth`) and {} cells (`max_elements`)",
|
||||
refused.join("; or "),
|
||||
options.max_depth,
|
||||
options.max_elements
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The part's box in the frame's coordinates.
|
||||
fn frame_box(g: &Geometry, frame: &[[f64; 3]; 3]) -> (P, P) {
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for &p in g.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]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// A grid refined by the criteria, or how far the refinement got before the cell limit.
|
||||
enum Refined {
|
||||
Within(Tree),
|
||||
Over { level: u8, at_least: usize },
|
||||
}
|
||||
|
||||
fn check_options(o: &OctreeOptions) -> Result<()> {
|
||||
let ok = o.max_depth <= 8
|
||||
&& o.curvature_levels <= o.max_depth
|
||||
@@ -462,7 +310,7 @@ fn to_frame(f: &[[f64; 3]; 3], p: P) -> P {
|
||||
/// 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 = libm::sin(1f64.to_radians());
|
||||
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() {
|
||||
@@ -495,20 +343,13 @@ fn choose_frame(g: &Geometry) -> [[f64; 3]; 3] {
|
||||
}
|
||||
|
||||
/// The distance through the part from each boundary triangle: a ray from its centre along the
|
||||
/// inward normal to the far side of the wall, a triangle whose normal opens from the opposite of
|
||||
/// this one by at most [`WALL_OPENING`] (`∞` when the ray first leaves through another face). A
|
||||
/// ray that starts near a convex edge leaves through the face beyond the edge after a distance
|
||||
/// that only measures how near the edge it started; the faces of a wall face each other.
|
||||
/// 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;
|
||||
let opposed = -libm::cos(WALL_OPENING.to_radians());
|
||||
(0..g.surface.triangles().len() as u32)
|
||||
.map(|t| {
|
||||
let n = g.normals.get(t as usize).copied().unwrap_or([0.0; 3]);
|
||||
match g.ray(g.triangle_centre(t), n.map(|x| -x), far, t) {
|
||||
Some((hit, s)) if g.normals.get(hit as usize).is_some_and(|&m| dot(m, n) <= opposed) => s,
|
||||
_ => f64::INFINITY,
|
||||
}
|
||||
g.ray(g.triangle_centre(t), n.map(|x| -x), far, t).map_or(f64::INFINITY, |(_, s)| s)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -533,17 +374,12 @@ struct Context<'a> {
|
||||
origin: P,
|
||||
h: f64,
|
||||
roots: [i64; 3],
|
||||
thickness: &'a [f64],
|
||||
needs: Needs,
|
||||
thickness: Vec<f64>,
|
||||
outside_memo: RefCell<HashMap<grid::Key, bool>>,
|
||||
/// The grid refined by the criteria (before hints and closure), and closed, kept from the
|
||||
/// plan: the first build takes the closed grid, rebuilds refine the other near their hints.
|
||||
refined: RefCell<Option<Tree>>,
|
||||
closed: RefCell<Option<Tree>>,
|
||||
/// Grid leaves of the last build.
|
||||
last_leaves: Cell<usize>,
|
||||
/// Leaves per level of the last build after the refinement criteria.
|
||||
refined_cells: RefCell<Vec<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
|
||||
@@ -556,51 +392,6 @@ struct Work {
|
||||
core_count: usize,
|
||||
}
|
||||
|
||||
impl<'a> Context<'a> {
|
||||
/// Root cells of edge `h` over the part's box `(lo, hi)` (in the frame's coordinates).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
g: &'a Geometry<'a>,
|
||||
field: &'a dyn SizeField,
|
||||
options: &'a OctreeOptions,
|
||||
thickness: &'a [f64],
|
||||
frame: [[f64; 3]; 3],
|
||||
h: f64,
|
||||
(lo, hi): (P, P),
|
||||
needs: Needs,
|
||||
) -> Result<Context<'a>> {
|
||||
if !(h.is_finite() && h > 0.0) {
|
||||
return Err(Error::Invalid(format!("a root cell size of {h}")));
|
||||
}
|
||||
let centre_u = [0, 1, 2].map(|k| 0.5 * (lo[k] + hi[k]));
|
||||
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);
|
||||
Ok(Context {
|
||||
g,
|
||||
field,
|
||||
options,
|
||||
frame,
|
||||
origin,
|
||||
h,
|
||||
roots,
|
||||
thickness,
|
||||
needs,
|
||||
outside_memo: RefCell::new(HashMap::new()),
|
||||
refined: RefCell::new(None),
|
||||
closed: RefCell::new(None),
|
||||
last_leaves: Cell::new(0),
|
||||
refined_cells: RefCell::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Context<'_> {
|
||||
fn edge(&self, level: u8) -> f64 {
|
||||
self.h / 3f64.powi(i32::from(level))
|
||||
@@ -625,7 +416,7 @@ impl Context<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
let reach = 0.5 * 3f64.sqrt() * e * 1.05;
|
||||
let cos = libm::cos(self.options.curvature_angle.to_radians());
|
||||
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);
|
||||
@@ -638,12 +429,16 @@ impl Context<'_> {
|
||||
if key.0 < self.options.curvature_levels && dot(f, n) < cos {
|
||||
return Ok(true);
|
||||
}
|
||||
// (With a relative margin, so cells planned at exactly the size a wall needs stay.)
|
||||
if self.thickness.get(t as usize).is_some_and(|&th| th * (1.0 + 1e-9) < self.options.thickness_cells * e) {
|
||||
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 * (1.0 + 1e-9) < 2.0 * e) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -682,69 +477,25 @@ impl Context<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The grid refined level by level where [`Context::needs_split`] asks, within `max_leaves`
|
||||
/// cells; past them, how far it got and a lower bound on the cells it needs (the cells so far
|
||||
/// and the splits the level still asks for).
|
||||
fn refine(&self, max_leaves: usize) -> Result<Refined> {
|
||||
let roots = self.roots.iter().try_fold(1usize, |a, &n| usize::try_from(n).ok().and_then(|n| a.checked_mul(n))).unwrap_or(usize::MAX);
|
||||
let mut tree = match Tree::new(self.roots, self.options.max_depth, max_leaves) {
|
||||
Err(Error::Limit(_)) => return Ok(Refined::Over { level: 0, at_least: roots }),
|
||||
other => other?,
|
||||
};
|
||||
for level in 0..self.options.max_depth {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let keys: Vec<grid::Key> = tree.sorted_leaves().into_iter().filter(|k| k.0 == level).collect();
|
||||
for (i, &key) in keys.iter().enumerate() {
|
||||
if !self.needs_split(key)? {
|
||||
continue;
|
||||
}
|
||||
match tree.split(key) {
|
||||
Ok(()) => {}
|
||||
Err(Error::Limit(_)) if tree.leaf_count() + 26 > max_leaves => {
|
||||
let mut more = 1usize;
|
||||
for &k in keys.get(i + 1..).unwrap_or(&[]) {
|
||||
if self.needs_split(k)? {
|
||||
more += 1;
|
||||
}
|
||||
}
|
||||
return Ok(Refined::Over { level: level + 1, at_least: tree.leaf_count().saturating_add(26usize.saturating_mul(more)) });
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Refined::Within(tree))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if hints.is_empty()
|
||||
&& let Some(t) = self.closed.borrow_mut().take()
|
||||
&& t.leaf_count() <= max_leaves
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
let kept = self.refined.borrow().clone();
|
||||
let mut tree = match kept {
|
||||
Some(t) => t,
|
||||
None => match self.refine(max_leaves)? {
|
||||
Refined::Within(t) => t,
|
||||
Refined::Over { level, at_least } => {
|
||||
return Err(Error::Limit(format!("the grid needs more than {at_least} cells by level {level}; the limit is {max_leaves}")));
|
||||
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)?;
|
||||
}
|
||||
},
|
||||
};
|
||||
tree.set_max_leaves(max_leaves)?;
|
||||
}
|
||||
}
|
||||
if !hints.is_empty() {
|
||||
let near: Vec<HashSet<grid::Key>> = (0..tree.depth).map(|l| self.hint_cells(hints, l)).collect();
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
*self.refined_cells.borrow_mut() = tree.level_counts();
|
||||
tree.close(CLOSURE_ROUNDS, &|k| self.outside(k))?;
|
||||
Ok(tree)
|
||||
}
|
||||
@@ -757,11 +508,8 @@ impl Context<'_> {
|
||||
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)));
|
||||
}
|
||||
// A lattice coordinate over the root span is an exact ratio, rounded once, so a point's
|
||||
// position does not depend on how deep the lattice is.
|
||||
let span = 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 / span * self.h))).collect();
|
||||
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();
|
||||
@@ -781,6 +529,15 @@ impl Context<'_> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -990,12 +747,6 @@ impl Context<'_> {
|
||||
let report = OctreeReport {
|
||||
attempts: 0,
|
||||
grid_cells: tree.leaf_count(),
|
||||
root_size: self.h,
|
||||
levels: tree.level_counts().iter().rposition(|&n| n > 0).and_then(|l| u8::try_from(l).ok()).unwrap_or(0),
|
||||
thinnest_wall: self.needs.wall,
|
||||
narrowest_patch: self.needs.patch,
|
||||
refined_cells: self.refined_cells.borrow().clone(),
|
||||
closed_cells: tree.level_counts(),
|
||||
core_elements: w.core_count,
|
||||
layer_elements: w.hexes.len() - w.core_count,
|
||||
frame: self.frame,
|
||||
|
||||
@@ -96,89 +96,3 @@ fn a_sphere_meshes() {
|
||||
let r = &m.report;
|
||||
assert!(((r.volume - r.surface_volume) / r.surface_volume).abs() < 0.01, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slab_gets_the_levels_its_thickness_needs_up_front() {
|
||||
// 1 mm thick: three cells across need cells of 1/3, two levels below a size of 3.
|
||||
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
|
||||
let m = octree_hex_mesh(&s, &UniformSize(3.0), &OctreeOptions::default()).unwrap();
|
||||
let r = &m.report;
|
||||
assert_eq!(r.thinnest_wall, 1.0, "{r:?}");
|
||||
assert_eq!(r.root_size, 3.0, "{r:?}");
|
||||
assert_eq!(r.levels, 2, "{r:?}");
|
||||
assert_eq!(r.attempts, 1, "{r:?}");
|
||||
assert!((r.volume - 108.0).abs() < 1e-9 * 108.0, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walls_beyond_the_limits_fail_before_anything_is_built() {
|
||||
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
|
||||
// Too few levels: the requested size needs 2, the shrunk root 1.
|
||||
let shallow = OctreeOptions { max_depth: 0, curvature_levels: 0, ..OctreeOptions::default() };
|
||||
let e = octree_hex_mesh(&s, &UniformSize(3.0), &shallow).unwrap_err().to_string();
|
||||
assert!(e.contains("walls 1.0000 thick") && e.contains("levels"), "{e}");
|
||||
// Too few cells: refused with the count, at once.
|
||||
let small = OctreeOptions { max_elements: 50, ..OctreeOptions::default() };
|
||||
let start = std::time::Instant::now();
|
||||
let e = octree_hex_mesh(&s, &UniformSize(3.0), &small).unwrap_err().to_string();
|
||||
assert!(e.contains("walls 1.0000 thick") && e.contains("more than"), "{e}");
|
||||
assert!(start.elapsed().as_secs_f64() < 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_shrinks_when_the_requested_size_would_need_too_many_cells() {
|
||||
// A size of 8 needs 3 levels for a 1 mm wall (cells of 0.30); a root of 3 needs 2 levels
|
||||
// with cells of exactly 1/3. With 2 levels allowed only the shrunk root fits.
|
||||
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
|
||||
let o = OctreeOptions { max_depth: 2, ..OctreeOptions::default() };
|
||||
let m = octree_hex_mesh(&s, &UniformSize(8.0), &o).unwrap();
|
||||
assert!((m.report.root_size - 3.0).abs() < 1e-12, "{:?}", m.report);
|
||||
assert_eq!(m.report.levels, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_options_are_errors() {
|
||||
let s = box_surface([0.0; 3], [12.0, 9.0, 1.0]);
|
||||
for o in [
|
||||
OctreeOptions { max_depth: 9, ..OctreeOptions::default() },
|
||||
OctreeOptions { max_elements: 0, ..OctreeOptions::default() },
|
||||
OctreeOptions { max_elements: 1, ..OctreeOptions::default() },
|
||||
OctreeOptions { thickness_cells: f64::NAN, ..OctreeOptions::default() },
|
||||
] {
|
||||
assert!(octree_hex_mesh(&s, &UniformSize(3.0), &o).is_err(), "{o:?}");
|
||||
}
|
||||
// The deepest grid allowed on a size far below the part: refused by the cell limit.
|
||||
let deep = OctreeOptions { max_depth: 5, max_elements: 10_000, ..OctreeOptions::default() };
|
||||
assert!(octree_hex_mesh(&s, &UniformSize(0.05), &deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshing_is_deterministic() {
|
||||
let s = cylinder_surface(5.0, 8.0, 64);
|
||||
let a = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
|
||||
let b = octree_hex_mesh(&s, &UniformSize(2.0), &OctreeOptions::default()).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// A size box refines the grid inside it to the level its size needs (closure may carry that
|
||||
/// level further), and the root cells keep the global size; a size deeper than `max_depth` allows is refused before meshing.
|
||||
#[test]
|
||||
fn a_size_box_refines_the_grid_inside_it() {
|
||||
use crate::size::{LocalSize, Region, RegionSize};
|
||||
let s = box_surface([0.0; 3], [18.0, 18.0, 18.0]);
|
||||
let local = LocalSize { region: Region::Box { lo: [0.0; 3], hi: [6.0; 3] }, size: 2.0 };
|
||||
let field = RegionSize::new(6.0, 0.3, vec![local.clone()]).unwrap();
|
||||
let m = octree_hex_mesh(&s, &field, &OctreeOptions::default()).unwrap();
|
||||
let edge = |h: &[u32; 8]| {
|
||||
let (a, b) = (m.nodes[h[0] as usize], m.nodes[h[1] as usize]);
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
};
|
||||
let centre = |h: &[u32; 8]| [0, 1, 2].map(|k| h.iter().map(|&v| m.nodes[v as usize][k]).sum::<f64>() / 8.0);
|
||||
let inside: Vec<f64> = m.hexes.iter().filter(|h| centre(h).iter().all(|&c| (1.0..5.0).contains(&c))).map(edge).collect();
|
||||
assert!(!inside.is_empty() && inside.iter().all(|&e| e < 2.0 * 1.2), "{inside:?}");
|
||||
assert_eq!((m.report.root_size, m.report.levels), (6.0, 1), "{:?}", m.report);
|
||||
let shallow = OctreeOptions { max_depth: 1, curvature_levels: 1, ..OctreeOptions::default() };
|
||||
let deep = RegionSize::new(6.0, 0.3, vec![LocalSize { size: 0.5, ..local }]).unwrap();
|
||||
let e = octree_hex_mesh(&s, &deep, &shallow).unwrap_err().to_string();
|
||||
assert!(e.contains("max_depth"), "{e}");
|
||||
}
|
||||
@@ -266,7 +266,7 @@ pub fn face_boundary(positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &
|
||||
.map(|i| {
|
||||
let (p, q, r) = (at(i + n - 1), at(i), at(i + 1));
|
||||
let (u, w) = ([q[0] - p[0], q[1] - p[1]], [r[0] - q[0], r[1] - q[1]]);
|
||||
let turn = crate::vec3::atan2(u[0] * w[1] - u[1] * w[0], u[0] * w[0] + u[1] * w[1]).abs();
|
||||
let turn = (u[0] * w[1] - u[1] * w[0]).atan2(u[0] * w[0] + u[1] * w[1]).abs();
|
||||
ids[i] != ids[(i + n - 1) % n] || turn > CORNER_ANGLE
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -72,9 +72,8 @@ const MAX_STRETCH: f64 = 2.0;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProjectOptions {
|
||||
/// The element size: each class of parallel block edges gets `round(length / size)`
|
||||
/// divisions (at least one), `length / size` the geometric mean of its projected edges'
|
||||
/// lengths in sizes (measured by `field` when there is one), and enough that no element is
|
||||
/// longer than twice the size.
|
||||
/// divisions (at least one), `length` the geometric mean of its projected edges' lengths,
|
||||
/// and enough that no element is longer than twice the size.
|
||||
pub size: f64,
|
||||
/// The largest angle (radians) the surface may turn through across one element on a curved
|
||||
/// target face: a class takes enough divisions that its elements are no longer than the
|
||||
@@ -99,10 +98,6 @@ pub struct ProjectOptions {
|
||||
/// corners lie on one (within a tenth of the size) keeps its nodes on it through smoothing, so
|
||||
/// the mesh can be cut there into two meshes with planar faces.
|
||||
pub planes: Vec<(P, P)>,
|
||||
/// A graded size field (size controls) in place of the uniform `size` when the divisions are
|
||||
/// chosen: an edge's length is then measured in units of the field ([`crate::size::ideal_divisions`]).
|
||||
/// Its global size is `size`.
|
||||
pub field: Option<crate::size::RegionSize>,
|
||||
}
|
||||
|
||||
impl Default for ProjectOptions {
|
||||
@@ -118,7 +113,6 @@ impl Default for ProjectOptions {
|
||||
min_quality: DEFAULT_MIN_QUALITY,
|
||||
fixed: Vec::new(),
|
||||
planes: Vec::new(),
|
||||
field: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,8 +448,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
let edge_c = |e: [u32; 2]| Constraint::of(&as_vec(edge_on.get(&e)));
|
||||
|
||||
// 1. Vertices.
|
||||
fieldcraft_model::progress::stage("Projection: block vertices", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
let mut vpos: Vec<P> = t.vertices.clone();
|
||||
for (v, p) in vpos.iter_mut().enumerate() {
|
||||
*p = place(g, &targets, vertex_c(v as u32), *p, tol);
|
||||
@@ -516,17 +508,10 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
// lengths). A class runs through every block its edges are parallel in, so its edges can
|
||||
// differ a lot in length (a cup's wall, 4 mm thick, and its floor's radial edges, twice
|
||||
// that); the longest edge alone would cut the thin blocks into flat slivers. No element is
|
||||
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry. An edge
|
||||
// that bends with a curved target is the exception: its elements' sides are chords of the
|
||||
// surface, and one longer than the size cuts off more of the part than the size allows (a
|
||||
// butterfly's rim shares its class with the shorter sides of its middle square), so a class
|
||||
// takes at least the geometric mean of its bent edges' lengths in sizes (rounded,
|
||||
// as the mean; an annulus's rim and bore balance each other as its straight edges do).
|
||||
// longer than [`MAX_STRETCH`] sizes, so a coarse size still follows the geometry.
|
||||
let (classes, nclass) = edge_classes(t);
|
||||
let mut class_log = vec![(0.0f64, 0usize); nclass];
|
||||
// Per class, its longest edge in element sizes.
|
||||
let mut class_max = vec![0.0f64; nclass];
|
||||
let mut class_bent = vec![(0.0f64, 0usize); nclass];
|
||||
let mut seen: BTreeSet<[u32; 2]> = BTreeSet::new();
|
||||
for (block, cls) in t.blocks.iter().zip(&classes) {
|
||||
for d in 0..3 {
|
||||
@@ -543,29 +528,17 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
let (pa, pb) = (vpos.get(a as usize).copied().unwrap_or_default(), vpos.get(b as usize).copied().unwrap_or_default());
|
||||
let c = edge_c(edge_key(a, b));
|
||||
let mut len = 0.0;
|
||||
let mut line = Vec::with_capacity(17);
|
||||
line.push(pa);
|
||||
let mut prev = pa;
|
||||
for q in 1..=16 {
|
||||
let x = if q == 16 { pb } else { place(g, &targets, c, lerp(pa, pb, q as f64 / 16.0), tol) };
|
||||
len += dist(*line.last().unwrap_or(&pa), x);
|
||||
line.push(x);
|
||||
len += dist(prev, x);
|
||||
prev = x;
|
||||
}
|
||||
// The edge's length in element sizes.
|
||||
let measured = match &opts.field {
|
||||
Some(f) if len > tol => crate::size::ideal_divisions(f, &line)?,
|
||||
_ => len / opts.size,
|
||||
};
|
||||
if let Some(m) = class_max.get_mut(cls[d]) {
|
||||
*m = m.max(measured);
|
||||
}
|
||||
if len - dist(pa, pb) > tol
|
||||
&& let Some(l) = class_bent.get_mut(cls[d])
|
||||
{
|
||||
l.0 += libm::log(measured);
|
||||
l.1 += 1;
|
||||
*m = m.max(len);
|
||||
}
|
||||
if let Some(l) = class_log.get_mut(cls[d]).filter(|_| len > tol) {
|
||||
l.0 += libm::log(measured);
|
||||
l.0 += (len / opts.size).ln();
|
||||
l.1 += 1;
|
||||
}
|
||||
}
|
||||
@@ -612,7 +585,7 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
let Some(nx) = g.normal(fa, qx) else { continue };
|
||||
let h = dist(pa, qx);
|
||||
if h > 0.5 * probe {
|
||||
kappa = kappa.max(libm::acos(crate::vec3::dot(na, nx).clamp(-1.0, 1.0)) / h);
|
||||
kappa = kappa.max(crate::vec3::dot(na, nx).clamp(-1.0, 1.0).acos() / h);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -629,11 +602,9 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
.iter()
|
||||
.zip(&class_max)
|
||||
.zip(&class_arc)
|
||||
.zip(&class_bent)
|
||||
.map(|(((&(sum, k), &longest), &arc), &(bent_sum, bent_k))| {
|
||||
let mean = if k == 0 { 1.0 } else { libm::exp(sum / k as f64).round() };
|
||||
let bent = if bent_k == 0 { 0 } else { libm::exp(bent_sum / bent_k as f64).round().min(crate::block::MAX_DIVISIONS as f64) as usize };
|
||||
(mean.max((longest / MAX_STRETCH).ceil()) as usize).max(arc).max(bent).clamp(1, crate::block::MAX_DIVISIONS)
|
||||
.map(|((&(sum, k), &longest), &arc)| {
|
||||
let mean = if k == 0 { 1.0 } else { (sum / k as f64).exp().round() };
|
||||
(mean.max((longest / (MAX_STRETCH * opts.size)).ceil()) as usize).max(arc).clamp(1, crate::block::MAX_DIVISIONS)
|
||||
})
|
||||
.collect();
|
||||
for &(b, d, n) in &opts.divisions {
|
||||
@@ -699,8 +670,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
}
|
||||
}
|
||||
// 3. Edge nodes: even by arc length along their curve or surface.
|
||||
fieldcraft_model::progress::stage("Projection: edge nodes", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
for (b, block) in t.blocks.iter().enumerate() {
|
||||
let nd = lat.divisions[b];
|
||||
let bn = &lat.block_nodes[b];
|
||||
@@ -744,8 +713,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
}
|
||||
}
|
||||
// 4. Faces (projected when outside), then 5. the interior.
|
||||
fieldcraft_model::progress::stage("Projection: faces and interior", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
for (b, block) in t.blocks.iter().enumerate() {
|
||||
let nd = lat.divisions[b];
|
||||
let bn = &lat.block_nodes[b];
|
||||
@@ -804,8 +771,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
}
|
||||
// Nodes on a sheet slide along it among their neighbours on it, as boundary nodes do.
|
||||
let boundary_quads: Vec<[u32; 4]> = quads.iter().map(|q| q.0).chain(sheet_quads).collect();
|
||||
fieldcraft_model::progress::stage("Projection: smoothing", None, "");
|
||||
fieldcraft_model::progress::check()?;
|
||||
let smoothing = smooth::smooth(
|
||||
smooth::Mesh { nodes: &mut nodes, hexes: &lat.hexes, constraint: &constraint, held: &held, boundary: &boundary_quads },
|
||||
g,
|
||||
@@ -815,7 +780,6 @@ pub fn project_mesh(t: &Topology, surface: &BoundarySurface, g: &dyn Geometry, o
|
||||
1e-4 * opts.size,
|
||||
);
|
||||
|
||||
fieldcraft_model::progress::check()?;
|
||||
// Certified quality, the gap to the geometry and the tags.
|
||||
let (mut min_sj, mut sum_sj, mut volume) = (f64::INFINITY, 0.0, 0.0);
|
||||
let mut invalid: Vec<usize> = Vec::new();
|
||||
|
||||
@@ -199,6 +199,6 @@ pub(crate) fn pyramid_scaled_jacobian(p: [P; 5]) -> f64 {
|
||||
pub(crate) fn jacobian_metrics(a: P, b: P, c: P) -> (f64, f64) {
|
||||
let d = det(a, b, c);
|
||||
let frob = dot(a, a) + dot(b, b) + dot(c, c);
|
||||
let shape = if d > 0.0 { ratio(3.0 * crate::vec3::cbrt(d).powi(2), frob, 0.0) } else { 0.0 };
|
||||
let shape = if d > 0.0 { ratio(3.0 * d.cbrt().powi(2), frob, 0.0) } else { 0.0 };
|
||||
(corner_det(a, b, c), shape)
|
||||
}
|
||||
@@ -44,43 +44,6 @@ fn regular_tetrahedron_scores_one() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dihedral angles and the scaled Jacobian measure different things on the same corners
|
||||
/// (#547): a flat tetrahedron is low on both; a needle has every dihedral angle between 60° and
|
||||
/// 90° but a nearly flat apex corner, so only the scaled Jacobian sees it. A tet10 with straight
|
||||
/// edges scores what its corners score, and the tet mesher's report reads the same numbers.
|
||||
#[test]
|
||||
fn dihedral_angles_and_scaled_jacobian_on_the_same_tetrahedra() {
|
||||
let both = |p: &[[f64; 3]]| {
|
||||
let q = element_quality(ElementKind::Tet4, p).unwrap();
|
||||
(metric(&q, Metric::MinDihedral), metric(&q, Metric::MaxDihedral), metric(&q, Metric::ScaledJacobian))
|
||||
};
|
||||
let (lo, hi, sj) = both(®ular_tet());
|
||||
assert!((lo - 70.528_779).abs() < 1e-5 && (hi - lo).abs() < 1e-9 && close(sj, 1.0), "{lo} {hi} {sj}");
|
||||
|
||||
let flat = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.01]];
|
||||
let (lo, hi, sj) = both(&flat);
|
||||
assert!(lo < 2.0 && hi > 178.0 && sj > 0.0 && sj < 0.02, "{lo} {hi} {sj}");
|
||||
|
||||
// Equilateral base of side 1, apex 12 above its centre.
|
||||
let h = 3.0_f64.sqrt() / 2.0;
|
||||
let needle = [[0., 0., 0.], [1., 0., 0.], [0.5, h, 0.], [0.5, h / 3.0, 12.0]];
|
||||
let (lo, hi, sj) = both(&needle);
|
||||
assert!(lo > 59.0 && hi < 91.0, "{lo} {hi}");
|
||||
// Apex corner: 6V = (√3/2)·12, edges √(1/3 + 144) each.
|
||||
let edge = (1.0 / 3.0 + 144.0_f64).sqrt();
|
||||
assert!(close(sj, 2.0_f64.sqrt() * h * 12.0 / edge.powi(3)), "{sj}");
|
||||
assert!(sj < 0.01);
|
||||
|
||||
for p in [regular_tet(), flat, needle] {
|
||||
let tet10 = element_quality(ElementKind::Tet10, &midpoints(&p, &jacobian::TET_EDGES)).unwrap();
|
||||
let (lo, hi, sj) = both(&p);
|
||||
assert!(close(metric(&tet10, Metric::MinDihedral), lo) && close(metric(&tet10, Metric::MaxDihedral), hi));
|
||||
assert!(close(metric(&tet10, Metric::ScaledJacobian), sj));
|
||||
let r = crate::tet::tet_quality_stats(&p, &[[0, 1, 2, 3]]).unwrap();
|
||||
assert!(close(r.min_dihedral, lo) && close(r.max_dihedral, hi) && close(r.min_scaled_jacobian, sj), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_corner_tetrahedron() {
|
||||
let q = element_quality(ElementKind::Tet4, &RIGHT_TET).unwrap();
|
||||
|
||||
@@ -275,8 +275,7 @@ struct Elem {
|
||||
nodes: Vec<u32>,
|
||||
/// A shell's normal as it was: its side, which a move must keep.
|
||||
normal: Option<P>,
|
||||
/// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it (only the strong
|
||||
/// mode measures it, a certified check per element; 0 otherwise).
|
||||
/// Its size as it was ([`strong::size`]): the strong mode keeps sizes near it.
|
||||
size0: f64,
|
||||
}
|
||||
|
||||
@@ -571,13 +570,7 @@ impl<'a> State<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
state.elems.push(Elem {
|
||||
label,
|
||||
kind: e.kind,
|
||||
normal: shell_normal(e.kind, &x),
|
||||
size0: if pin_curves { strong::size(e.kind, &x) } else { 0.0 },
|
||||
nodes: e.nodes.clone(),
|
||||
});
|
||||
state.elems.push(Elem { label, kind: e.kind, normal: shell_normal(e.kind, &x), size0: strong::size(e.kind, &x), nodes: e.nodes.clone() });
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
@@ -945,13 +938,8 @@ pub fn apply_field(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
mod moving;
|
||||
mod strong;
|
||||
pub use moving::{
|
||||
DEFAULT_MIN_QUALITY, ElementChange, GUARD_STEPS, Guard, Influence, MIN_RELATIVE_RADIUS, MoveRun, Moved, Propagate, Unconstrained, centroid,
|
||||
move_nodes, move_nodes_with, move_weights, nearest_corner,
|
||||
};
|
||||
pub use strong::{DEFAULT_REACH_LENGTHS, Guards, MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
|
||||
pub use strong::{MAX_ROUNDS, MorphOptions, Morphed, Round, morph};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,431 +0,0 @@
|
||||
//! Moving nodes by hand (`mesh.node.move`): grabbed nodes are displaced by a vector, and the
|
||||
//! nodes round them follow, so an analyst can drag a node of a finished mesh and see what the
|
||||
//! move does to the elements around it.
|
||||
//!
|
||||
//! **Who follows.** Every node gets a share `w` in `[0, 1]` of the displacement ([`move_weights`]):
|
||||
//!
|
||||
//! - *Falloff*: the grabbed nodes move by all of it, a node within the radius of them by
|
||||
//! `strength × falloff(t)` (`t` its distance to the nearest grabbed node over the radius,
|
||||
//! [`Falloff`]); with a sphere and no grabbed nodes, every node inside it by
|
||||
//! `strength × falloff(t)` of its distance to the centre (a soft selection).
|
||||
//! - *Harmonic*: the share is the harmonic function on the graph of element edges that is 1 at
|
||||
//! the grabbed nodes and 0 beyond the radius and at the nodes that may not move (Gauss–Seidel
|
||||
//! on the graph Laplacian, as the strong relaxation's morph: [`super::strong`]). The
|
||||
//! displacement then spreads through the mesh as a membrane would carry it, following the
|
||||
//! mesh's connectivity rather than straight-line distance (M. Botsch, O. Sorkine, *On linear
|
||||
//! variational surface deformation methods*, IEEE TVCG 14, 2008, §3: harmonic deformation
|
||||
//! fields with fixed and handle regions).
|
||||
//!
|
||||
//! **Where a node may go** is the relaxation's [`Constraints`]: a node on a geometry face slides
|
||||
//! in the face's parameters, a node on a geometry edge along it, a node on a vertex stays; with
|
||||
//! the geometry off ([`Unconstrained`]) every node moves in space. Mid-side nodes follow their
|
||||
//! edge's corners, as in a relaxation.
|
||||
//!
|
||||
//! **Guard.** With [`Guard::NoInversion`] the displacement is scaled back (by bisection, at
|
||||
//! most [`GUARD_STEPS`] halvings) to the largest share of it with which no valid element becomes
|
||||
//! invalid or drops below the guard's smallest scaled Jacobian (default
|
||||
//! [`DEFAULT_MIN_QUALITY`]; or its own, when that was lower); the scale is reported.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use fieldcraft_model::Model;
|
||||
|
||||
use super::{Constraints, Falloff, Freedom, P, RegionQuality, State, add, corner_nodes, dist, dot, measure, scale, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// How the displacement reaches the nodes round the grabbed ones.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Propagate {
|
||||
/// `strength × falloff` of the distance.
|
||||
#[default]
|
||||
Falloff,
|
||||
/// The harmonic function of the element-edge graph.
|
||||
Harmonic,
|
||||
}
|
||||
|
||||
impl Propagate {
|
||||
pub fn from_name(name: &str) -> Option<Propagate> {
|
||||
match name {
|
||||
"falloff" => Some(Propagate::Falloff),
|
||||
"harmonic" => Some(Propagate::Harmonic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What keeps a move from breaking elements.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Guard {
|
||||
/// The move as asked, whatever it does to the elements.
|
||||
None,
|
||||
/// Scaled back until no valid element becomes invalid or falls below `min_quality` (scaled
|
||||
/// Jacobian; an element already below keeps at least its own).
|
||||
NoInversion { min_quality: f64 },
|
||||
}
|
||||
|
||||
impl Default for Guard {
|
||||
fn default() -> Self {
|
||||
Guard::NoInversion { min_quality: DEFAULT_MIN_QUALITY }
|
||||
}
|
||||
}
|
||||
|
||||
/// Halvings of the guard's bisection: the scale is found to within 2⁻²⁰.
|
||||
pub const GUARD_STEPS: usize = 20;
|
||||
|
||||
/// The default smallest scaled Jacobian the guard lets a move make: with 0 the bisection stops at
|
||||
/// the very edge of inversion, with an element of quality 0.
|
||||
pub const DEFAULT_MIN_QUALITY: f64 = 0.05;
|
||||
|
||||
/// The smallest radius of influence, relative to the mesh's size (its bounding box diagonal):
|
||||
/// below it no node but the grabbed ones is within reach, and the search grid would overflow.
|
||||
pub const MIN_RELATIVE_RADIUS: f64 = 1e-6;
|
||||
|
||||
/// Every node moves in space, on the skin too: a move with the geometry off.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Unconstrained;
|
||||
|
||||
impl Constraints for Unconstrained {
|
||||
fn freedom(&self, _: u32) -> Option<Freedom> {
|
||||
Some(Freedom::Free)
|
||||
}
|
||||
fn slide_face(&self, _: u32, _: P, _: P) -> Option<P> {
|
||||
None
|
||||
}
|
||||
fn slide_curve(&self, _: u32, _: P, _: P) -> Option<P> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Which nodes follow the grabbed ones, and how.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Influence {
|
||||
/// The sphere's centre: shares by the distance to it rather than to the grabbed nodes.
|
||||
pub centre: Option<P>,
|
||||
/// Nodes this close to the grabbed nodes (or the centre) follow; `None`: only the grabbed
|
||||
/// nodes move.
|
||||
pub radius: Option<f64>,
|
||||
pub propagate: Propagate,
|
||||
/// Falloff only: the largest share of a node that is not grabbed, in `(0, 1]`.
|
||||
pub strength: f64,
|
||||
pub falloff: Falloff,
|
||||
}
|
||||
|
||||
impl Default for Influence {
|
||||
fn default() -> Self {
|
||||
Influence { centre: None, radius: None, propagate: Propagate::Falloff, strength: 1.0, falloff: Falloff::Smooth }
|
||||
}
|
||||
}
|
||||
|
||||
/// For each candidate, its distance to the nearest of `from` when that is at most `r` (a uniform
|
||||
/// grid of cell `r` over `from`; deterministic).
|
||||
pub(super) fn nearest_within(from: &[P], candidates: &BTreeMap<u32, P>, r: f64) -> BTreeMap<u32, f64> {
|
||||
// Saturating casts and additions: no coordinate over radius overflows the index.
|
||||
let cell = |p: P| p.map(|x| (x / r).floor() as i64);
|
||||
let mut grid: BTreeMap<[i64; 3], Vec<P>> = BTreeMap::new();
|
||||
for &p in from {
|
||||
grid.entry(cell(p)).or_default().push(p);
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
for (&n, &p) in candidates {
|
||||
let [i, j, k] = cell(p);
|
||||
let mut best = f64::INFINITY;
|
||||
for di in -1..=1 {
|
||||
for dj in -1..=1 {
|
||||
for dk in -1..=1 {
|
||||
for &q in grid.get(&[i.saturating_add(di), j.saturating_add(dj), k.saturating_add(dk)]).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
best = best.min(dist(p, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best <= r {
|
||||
out.insert(n, best);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The share of the displacement each node takes (node → share in `(0, 1]`): the grabbed
|
||||
/// corner nodes `handles` all of it, the others as `influence` says. Nodes that may not move
|
||||
/// under `geometry` (a vertex, a skin node of unknown geometry) are anchors of the harmonic
|
||||
/// function and get no share. Only corner nodes of measured elements count (mid-side nodes
|
||||
/// follow them).
|
||||
pub fn move_weights(model: &Model, handles: &BTreeSet<u32>, influence: &Influence, geometry: &dyn Constraints) -> Result<BTreeMap<u32, f64>> {
|
||||
let Influence { centre, radius, propagate, strength, falloff } = *influence;
|
||||
if !(strength > 0.0 && strength <= 1.0) {
|
||||
return Err(Error::Invalid(format!("strength must be in (0, 1], got {strength}")));
|
||||
}
|
||||
if radius.is_some_and(|r| !(r > 0.0 && r.is_finite())) {
|
||||
return Err(Error::Invalid("the radius must be positive".into()));
|
||||
}
|
||||
if centre.is_some_and(|c| !c.iter().all(|x| x.is_finite())) {
|
||||
return Err(Error::Invalid("the centre must be finite".into()));
|
||||
}
|
||||
let corners = corner_nodes(model);
|
||||
let handles: BTreeSet<u32> = handles.iter().copied().filter(|n| corners.contains(n)).collect();
|
||||
if handles.len() > super::MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES)));
|
||||
}
|
||||
let positions: BTreeMap<u32, P> = corners.iter().filter_map(|&n| model.node(n).map(|p| (n, p.xyz))).collect();
|
||||
if let Some(r) = radius {
|
||||
let (lo, hi) = positions.values().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| {
|
||||
([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])])
|
||||
});
|
||||
let size = if positions.is_empty() { 0.0 } else { dist(lo, hi) };
|
||||
let min = MIN_RELATIVE_RADIUS * size;
|
||||
if r < min {
|
||||
return Err(Error::Invalid(format!("the radius must be at least {min:.3e} (a millionth of the mesh's size), got {r:.3e}")));
|
||||
}
|
||||
}
|
||||
// The nodes within the radius, with their distance to the nearest handle (or the centre).
|
||||
let near = match (radius, centre) {
|
||||
(None, _) => BTreeMap::new(),
|
||||
(Some(r), Some(c)) => nearest_within(&[c], &positions, r),
|
||||
(Some(r), None) => {
|
||||
let from: Vec<P> = handles.iter().filter_map(|n| positions.get(n).copied()).collect();
|
||||
nearest_within(&from, &positions, r)
|
||||
}
|
||||
};
|
||||
if near.len() > super::MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {} nodes within the radius; give a smaller one", super::MAX_NODES)));
|
||||
}
|
||||
let mut out: BTreeMap<u32, f64> = handles.iter().map(|&n| (n, 1.0)).collect();
|
||||
match propagate {
|
||||
Propagate::Falloff => {
|
||||
let r = radius.unwrap_or(1.0);
|
||||
for (&n, &d) in &near {
|
||||
let w = strength * falloff.weight(d / r);
|
||||
if w > 0.0 && !handles.contains(&n) {
|
||||
out.insert(n, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
Propagate::Harmonic => {
|
||||
if radius.is_none() {
|
||||
return Err(Error::Invalid("a harmonic move needs a radius".into()));
|
||||
}
|
||||
if handles.is_empty() {
|
||||
return Err(Error::Invalid("a harmonic move needs nodes to grab".into()));
|
||||
}
|
||||
let movers: BTreeSet<u32> = near.keys().chain(&handles).copied().collect();
|
||||
let state = State::new(model, &movers, geometry, false)?;
|
||||
let free: Vec<u32> = near.keys().copied().filter(|n| !handles.contains(n) && state.freedom(*n) != Freedom::Fixed).collect();
|
||||
let fixed: BTreeMap<u32, P> = handles.iter().map(|&n| (n, [1.0, 0.0, 0.0])).collect();
|
||||
let u = state.harmonic(&free, &fixed)?;
|
||||
for n in free {
|
||||
let w = u.get(&n).map_or(0.0, |v| v[0]).clamp(0.0, 1.0);
|
||||
if w > 1e-12 {
|
||||
out.insert(n, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One element's quality before and after a move.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElementChange {
|
||||
pub label: u32,
|
||||
/// Scaled Jacobian (a triangle's smallest angle over 60°).
|
||||
pub before: f64,
|
||||
pub after: f64,
|
||||
pub valid_before: bool,
|
||||
pub valid_after: bool,
|
||||
}
|
||||
|
||||
impl ElementChange {
|
||||
/// Its quality dropped, or it became invalid.
|
||||
pub fn worse(&self) -> bool {
|
||||
self.after < self.before - SAME || (self.valid_before && !self.valid_after)
|
||||
}
|
||||
/// Its quality rose.
|
||||
pub fn better(&self) -> bool {
|
||||
self.after > self.before + SAME && !self.worse()
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`move_nodes`] did.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Moved {
|
||||
/// The new position of every node that moved (corners and the mid-side nodes following them).
|
||||
pub positions: BTreeMap<u32, P>,
|
||||
/// Corner nodes that moved.
|
||||
pub moved: usize,
|
||||
pub max_displacement: f64,
|
||||
/// The quality of the elements with a corner that may move, before and after.
|
||||
pub before: RegionQuality,
|
||||
pub after: RegionQuality,
|
||||
pub mean_before: f64,
|
||||
pub mean_after: f64,
|
||||
/// Elements whose quality dropped, and rose.
|
||||
pub worse: usize,
|
||||
pub better: usize,
|
||||
/// The share of the asked displacement applied (below 1 when the guard scaled it back).
|
||||
pub scale: f64,
|
||||
/// Every element of the region, by label.
|
||||
pub elements: Vec<ElementChange>,
|
||||
}
|
||||
|
||||
/// A change in quality smaller than this is no change.
|
||||
const SAME: f64 = 1e-9;
|
||||
|
||||
/// Move each node of `weights` by its share of `d`, as `geometry` lets it, the displacement
|
||||
/// scaled back under `guard`. The model is not changed: the result has the new positions.
|
||||
pub fn move_nodes(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints) -> Result<Moved> {
|
||||
move_nodes_with(model, weights, d, guard, geometry, &MoveRun::default())
|
||||
}
|
||||
|
||||
/// How [`move_nodes_with`] runs: what a live preview reuses and how finely it guards.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MoveRun {
|
||||
/// The region's elements' quality and validity before any move, in label order (from an
|
||||
/// earlier run's [`Moved::elements`] with the same weights); measured again when absent or
|
||||
/// of another length.
|
||||
pub baseline: Option<Vec<(f64, bool)>>,
|
||||
/// Halvings of the guard's bisection; 0 is [`GUARD_STEPS`].
|
||||
pub guard_steps: usize,
|
||||
}
|
||||
|
||||
/// Each element's quality and validity at the state's positions, measured in parallel (fixed
|
||||
/// blocks: the same result on any thread count).
|
||||
fn measure_all(elems: &[super::Elem], pos: &BTreeMap<u32, P>) -> Vec<(f64, bool)> {
|
||||
fieldcraft_parallel::map(elems.len(), |i| {
|
||||
let Some(e) = elems.get(i) else { return (-1.0, false) };
|
||||
let x: Vec<P> = e.nodes.iter().map(|n| pos.get(n).copied().unwrap_or([0.0; 3])).collect();
|
||||
measure(e, &x)
|
||||
})
|
||||
}
|
||||
|
||||
/// The region's quality from its elements' measures.
|
||||
fn summary(state: &State<'_>, values: &[(f64, bool)]) -> RegionQuality {
|
||||
let mut r = RegionQuality { elements: state.elems.len(), min_quality: f64::INFINITY, max_edge_ratio: 0.0, invalid: 0, worst: Vec::new() };
|
||||
let mut all = Vec::with_capacity(values.len());
|
||||
for (e, &(q, valid)) in state.elems.iter().zip(values) {
|
||||
r.min_quality = r.min_quality.min(q);
|
||||
r.max_edge_ratio = r.max_edge_ratio.max(super::edge_ratio(e.kind, &state.coords(e)));
|
||||
r.invalid += usize::from(!valid);
|
||||
all.push((e.label, q));
|
||||
}
|
||||
all.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
all.truncate(super::WORST_LISTED);
|
||||
r.worst = all;
|
||||
if r.elements == 0 {
|
||||
r.min_quality = 0.0;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// [`move_nodes`], reusing what `run` carries.
|
||||
pub fn move_nodes_with(model: &Model, weights: &BTreeMap<u32, f64>, d: P, guard: Guard, geometry: &dyn Constraints, run: &MoveRun) -> Result<Moved> {
|
||||
if !d.iter().all(|x| x.is_finite()) || dot(d, d).sqrt() > fieldcraft_model::MAX_COORD {
|
||||
return Err(Error::Invalid("the displacement must be finite".into()));
|
||||
}
|
||||
if weights.len() > super::MAX_NODES {
|
||||
return Err(Error::Limit(format!("more than {} nodes to move", super::MAX_NODES)));
|
||||
}
|
||||
let floor = match guard {
|
||||
Guard::None => 0.0,
|
||||
Guard::NoInversion { min_quality } if (0.0..=1.0).contains(&min_quality) => min_quality,
|
||||
Guard::NoInversion { min_quality } => return Err(Error::Invalid(format!("the smallest quality must be in [0, 1], got {min_quality}"))),
|
||||
};
|
||||
if let Some((n, w)) = weights.iter().find(|(_, w)| !(**w > 0.0 && **w <= 1.0)) {
|
||||
return Err(Error::Invalid(format!("node {n} has share {w}; shares are in (0, 1]")));
|
||||
}
|
||||
let steps = if run.guard_steps == 0 { GUARD_STEPS } else { run.guard_steps.min(GUARD_STEPS) };
|
||||
let movers: BTreeSet<u32> = weights.keys().copied().collect();
|
||||
let mut state = State::new(model, &movers, geometry, false)?;
|
||||
let original = state.pos.clone();
|
||||
let start = match &run.baseline {
|
||||
Some(b) if b.len() == state.elems.len() => b.clone(),
|
||||
_ => measure_all(&state.elems, &state.pos),
|
||||
};
|
||||
let before = summary(&state, &start);
|
||||
let freedom: BTreeMap<u32, Freedom> = movers.iter().map(|&n| (n, state.freedom(n))).collect();
|
||||
// The state with the displacement applied at scale `t`.
|
||||
let apply = |state: &mut State<'_>, t: f64| {
|
||||
state.pos.clone_from(&original);
|
||||
for (&n, &w) in weights {
|
||||
let (Some(&f), Some(&p0)) = (freedom.get(&n), original.get(&n)) else { continue };
|
||||
if !state.around.contains_key(&n) {
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = state.moved(f, p0, scale(d, w * t)) {
|
||||
state.place(n, q);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Whether every element valid before is still valid, and no worse than the guard's floor
|
||||
// (or its own quality, when that was lower).
|
||||
let sound = |now: &[(f64, bool)]| now.iter().zip(&start).all(|(&(q, valid), &(q0, was))| !was || (valid && q >= floor.min(q0)));
|
||||
apply(&mut state, 1.0);
|
||||
let mut now = measure_all(&state.elems, &state.pos);
|
||||
let mut t = 1.0;
|
||||
if guard != Guard::None && !sound(&now) {
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
let mut at_lo = None;
|
||||
for _ in 0..steps {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let mid = 0.5 * (lo + hi);
|
||||
apply(&mut state, mid);
|
||||
let m = measure_all(&state.elems, &state.pos);
|
||||
if sound(&m) {
|
||||
lo = mid;
|
||||
at_lo = Some(m);
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
t = lo;
|
||||
apply(&mut state, t);
|
||||
now = at_lo.unwrap_or_else(|| start.clone());
|
||||
}
|
||||
let after = summary(&state, &now);
|
||||
let mut out = Moved { before, after, scale: t, ..Moved::default() };
|
||||
let (mut sum_before, mut sum_after) = (0.0, 0.0);
|
||||
for (e, (&(q0, v0), &(q1, v1))) in state.elems.iter().zip(start.iter().zip(&now)) {
|
||||
sum_before += q0;
|
||||
sum_after += q1;
|
||||
let change = ElementChange { label: e.label, before: q0, after: q1, valid_before: v0, valid_after: v1 };
|
||||
out.worse += usize::from(change.worse());
|
||||
out.better += usize::from(change.better());
|
||||
out.elements.push(change);
|
||||
}
|
||||
if !state.elems.is_empty() {
|
||||
out.mean_before = sum_before / state.elems.len() as f64;
|
||||
out.mean_after = sum_after / state.elems.len() as f64;
|
||||
}
|
||||
for (n, p) in &state.pos {
|
||||
let Some(o) = original.get(n) else { continue };
|
||||
let moved = dist(*o, *p);
|
||||
if moved > 0.0 {
|
||||
out.max_displacement = out.max_displacement.max(moved);
|
||||
out.moved += usize::from(movers.contains(n));
|
||||
out.positions.insert(*n, *p);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The centroid of `nodes`' positions (`None` when none has one): where a move `to` a point
|
||||
/// starts from.
|
||||
pub fn centroid(model: &Model, nodes: &BTreeSet<u32>) -> Option<P> {
|
||||
let pts: Vec<P> = nodes.iter().filter_map(|n| model.node(*n).map(|p| p.xyz)).collect();
|
||||
if pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(scale(pts.iter().fold([0.0; 3], |s, p| add(s, *p)), 1.0 / pts.len() as f64))
|
||||
}
|
||||
|
||||
/// The corner node of a measured element nearest `p` (the node a sphere's harmonic move grabs).
|
||||
pub fn nearest_corner(model: &Model, p: P) -> Option<u32> {
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for n in corner_nodes(model) {
|
||||
let Some(x) = model.node(n) else { continue };
|
||||
let d = dot(sub(x.xyz, p), sub(x.xyz, p));
|
||||
if best.is_none_or(|(b, _)| d < b) {
|
||||
best = Some((d, n));
|
||||
}
|
||||
}
|
||||
best.map(|(_, n)| n)
|
||||
}
|
||||
@@ -12,10 +12,9 @@
|
||||
//! B (2 − q) for an invalid element (B large),
|
||||
//! ```
|
||||
//!
|
||||
//! φ continuing as its tangent line below q = 0.3, so it rises steeply but stays finite as an
|
||||
//! element degenerates, while the invalid branch falls as an inverted element untangles and lies
|
||||
//! above every valid element's term (a barrier: no step that lowers `F` inverts an element unless
|
||||
//! it untangles worse ones; and passing through q → 0⁺ never costs more than staying inverted). The size term is there because the scaled Jacobian does not see an
|
||||
//! growing without bound as an element degenerates (a barrier: no step that lowers `F` inverts an
|
||||
//! element), while the invalid branch falls as an inverted element untangles and lies far above
|
||||
//! any valid configuration. The size term is there because the scaled Jacobian does not see an
|
||||
//! element flattening along one direction: without it the optimiser squeezed elements against
|
||||
//! the boundary to square up their neighbours (the Pi 5 base's largest edge ratio went from 5 to
|
||||
//! 34). For the same reason an element longer than the region's largest edge ratio before costs
|
||||
@@ -44,14 +43,8 @@ use fieldcraft_model::Model;
|
||||
use super::{Constraints, Elem, Freedom, P, RegionQuality, State, add, dist, dot, measure, region_quality, scale, sub};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Weight of an invalid element in the objective: above every valid element's term (the shape
|
||||
/// term is at most [`shape`] at 0, a few 1e9, plus the size term), so no step that untangles an
|
||||
/// element ever looks worse than staying inverted.
|
||||
const INVALID: f64 = 1e11;
|
||||
|
||||
/// Below this quality the shape term continues as its tangent line, so it stays finite (and
|
||||
/// below [`INVALID`]) as an element degenerates instead of growing without bound.
|
||||
const KNEE: f64 = 0.3;
|
||||
/// Weight of an invalid element in the objective.
|
||||
const INVALID: f64 = 1e8;
|
||||
|
||||
/// Weight of the size term.
|
||||
const SIZE_WEIGHT: f64 = 0.5;
|
||||
@@ -72,31 +65,17 @@ enum Phase {
|
||||
/// The objective's term for one element of quality `q` whose size went from `v0` to `v`.
|
||||
fn phi(phase: Phase, q: f64, valid: bool, v: f64, v0: f64) -> f64 {
|
||||
if valid && q > 0.0 {
|
||||
let g = if v > 0.0 && v0 > 0.0 { libm::log(v / v0) } else { 0.0 };
|
||||
shape(phase, q) + SIZE_WEIGHT * g * g
|
||||
let g = if v > 0.0 && v0 > 0.0 { (v / v0).ln() } else { 0.0 };
|
||||
let shape = match phase {
|
||||
Phase::Smooth => (1.0 / q - 1.0).powi(2),
|
||||
Phase::MinMax => q.powi(-MIN_MAX_POWER) - 1.0,
|
||||
};
|
||||
shape + SIZE_WEIGHT * g * g
|
||||
} else {
|
||||
INVALID * (2.0 - q.min(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape term of a valid element of quality `q > 0`: `φ(q)` from [`KNEE`] up, its tangent
|
||||
/// line below. Unbounded, `q⁻¹⁶` reached 1e96 as an element neared flat, so far above an
|
||||
/// invalid element's term that the descent kept elements inverted rather than pass through
|
||||
/// q → 0⁺ (#438: the distorted plate stayed tangled).
|
||||
fn shape(phase: Phase, q: f64) -> f64 {
|
||||
// φ and its derivative at `x`.
|
||||
let at = |x: f64| match phase {
|
||||
Phase::Smooth => ((1.0 / x - 1.0).powi(2), -2.0 * (1.0 / x - 1.0) / (x * x)),
|
||||
Phase::MinMax => (x.powi(-MIN_MAX_POWER) - 1.0, -f64::from(MIN_MAX_POWER) * x.powi(-MIN_MAX_POWER - 1)),
|
||||
};
|
||||
if q >= KNEE {
|
||||
at(q).0
|
||||
} else {
|
||||
let (v, d) = at(KNEE);
|
||||
v + d * (q - KNEE)
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear hexahedron's scaled Jacobian at its corners and centre (the samples
|
||||
/// [`crate::quality::element_quality`] takes), straight from the trilinear map: the sweeps'
|
||||
/// inner loop, where the certified check would dominate. A state is certified before it is kept.
|
||||
@@ -254,37 +233,21 @@ impl State<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// When [`morph`] keeps a cluster's morph.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Guards {
|
||||
/// Kept whenever no element it touches inverts (more invalid elements than before).
|
||||
#[default]
|
||||
Valid,
|
||||
/// Kept (at full or a halved scale) only when, besides, the worst element it touches is no
|
||||
/// worse, the smooth objective there drops and no element gets longer than the longest was.
|
||||
Strict,
|
||||
}
|
||||
|
||||
/// Default reach of [`morph`], in mean edge lengths of the bad elements.
|
||||
pub const DEFAULT_REACH_LENGTHS: f64 = 5.0;
|
||||
|
||||
/// What [`morph`] does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MorphOptions {
|
||||
/// Elements below this quality are the bad ones.
|
||||
pub below: f64,
|
||||
/// The radius round the bad nodes whose nodes follow them; `None`:
|
||||
/// [`DEFAULT_REACH_LENGTHS`] times the bad elements' mean edge length.
|
||||
/// The radius round the bad nodes whose nodes follow them; `None`: three times the bad
|
||||
/// elements' mean edge length.
|
||||
pub reach: Option<f64>,
|
||||
/// Most rounds (bad nodes moved, the region morphed after them).
|
||||
pub rounds: usize,
|
||||
/// When a cluster's morph is kept.
|
||||
pub guards: Guards,
|
||||
}
|
||||
|
||||
impl Default for MorphOptions {
|
||||
fn default() -> Self {
|
||||
MorphOptions { below: 0.7, reach: None, rounds: 20, guards: Guards::Valid }
|
||||
MorphOptions { below: 0.7, reach: None, rounds: 20 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,9 +359,8 @@ impl State<'_> {
|
||||
/// Morph round one cluster of bad nodes: their own optimisation (the rest held) gives their
|
||||
/// displacements, the harmonic extension carries them to the nodes within `reach`, applied at
|
||||
/// full or a halved scale when that improves the elements it touches. The region's size and
|
||||
/// whether it was kept. With [`Guards::Valid`] the full morph is kept unless an element
|
||||
/// it touches inverts; with [`Guards::Strict`] only a scale that improves the elements is.
|
||||
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, guards: Guards, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
|
||||
/// whether it was kept.
|
||||
fn morph_cluster(&mut self, bad: &BTreeSet<u32>, reach: f64, movable: &dyn Fn(&State<'_>, u32) -> bool) -> Result<(usize, bool)> {
|
||||
let start: BTreeMap<u32, P> = bad.iter().map(|&n| (n, self.at(n))).collect();
|
||||
let mut undo = Vec::new();
|
||||
let mut steps: BTreeMap<u32, f64> = bad.iter().map(|&n| (n, 0.1)).collect();
|
||||
@@ -435,11 +397,7 @@ impl State<'_> {
|
||||
}
|
||||
}
|
||||
let now = self.certified(&touched);
|
||||
let keep = match guards {
|
||||
Guards::Valid => now.invalid <= was.invalid,
|
||||
Guards::Strict => now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio),
|
||||
};
|
||||
if keep {
|
||||
if now.improves(&was) && now.ratio <= self.ratio_cap.max(was.ratio) {
|
||||
return Ok((region.len(), true));
|
||||
}
|
||||
self.restore(undo);
|
||||
@@ -479,7 +437,7 @@ impl State<'_> {
|
||||
|
||||
/// The harmonic extension over `free` of the displacements `fixed` (zero at every other
|
||||
/// node): Gauss–Seidel on the graph Laplacian of the element edges.
|
||||
pub(super) fn harmonic(&self, free: &[u32], fixed: &BTreeMap<u32, P>) -> Result<BTreeMap<u32, P>> {
|
||||
fn harmonic(&self, free: &[u32], fixed: &BTreeMap<u32, P>) -> Result<BTreeMap<u32, P>> {
|
||||
let mut u: BTreeMap<u32, P> = fixed.clone();
|
||||
for &n in free {
|
||||
u.insert(n, [0.0; 3]);
|
||||
@@ -515,11 +473,10 @@ impl State<'_> {
|
||||
/// the reach by harmonic interpolation (the graph Laplacian solved with the bad nodes'
|
||||
/// displacements and zero beyond the reach and at held nodes). Face nodes are put back on their
|
||||
/// face, edge and vertex nodes do not move, nodes outside the reach stay exactly as they are. A
|
||||
/// cluster's morph is kept as long as no element it touches inverts ([`Guards::Valid`]), or, with
|
||||
/// [`Guards::Strict`], at full or a halved scale only if besides no element gets longer than the
|
||||
/// longest one was, the worst element it touches is no worse and the smooth objective there drops.
|
||||
/// The result is the round with the highest smallest quality (never below the start). Only nodes
|
||||
/// of `nodes` move. The model is not changed: the result has the new positions.
|
||||
/// cluster's morph is kept, at full or a halved scale, only if no more elements are invalid, no element
|
||||
/// gets longer than the longest one was, the worst element it touches is no worse and the smooth
|
||||
/// objective there drops. Only nodes of `nodes`
|
||||
/// move. The model is not changed: the result has the new positions.
|
||||
pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geometry: &dyn Constraints) -> Result<Morphed> {
|
||||
if !(options.below > 0.0 && options.below <= 1.0) {
|
||||
return Err(Error::Invalid(format!("below must be in (0, 1], got {}", options.below)));
|
||||
@@ -540,14 +497,6 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
|
||||
let (_, _, mean_before) = state.qualities();
|
||||
let movable = |s: &State<'_>, n: u32| nodes.contains(&n) && s.around.contains_key(&n) && s.freedom(n) != Freedom::Fixed;
|
||||
let mut rounds = Vec::new();
|
||||
// The best state of the run: the highest smallest quality, then the fewest elements below
|
||||
// `below`, then the highest mean; the start until a round beats it.
|
||||
let score = |s: &State<'_>| {
|
||||
let (q, min, mean) = s.qualities();
|
||||
(min, q.iter().filter(|v| **v < options.below).count(), mean)
|
||||
};
|
||||
let better = |a: (f64, usize, f64), b: (f64, usize, f64)| a.0 > b.0 || (a.0 == b.0 && (a.1 < b.1 || (a.1 == b.1 && a.2 > b.2)));
|
||||
let mut best = (score(&state), original.clone());
|
||||
for round in 0..options.rounds {
|
||||
fieldcraft_model::progress::stage(&format!("morph round {}", round + 1), Some(options.rounds as f64), "rounds");
|
||||
fieldcraft_model::progress::count(round as f64);
|
||||
@@ -569,7 +518,7 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
|
||||
if bad.is_empty() {
|
||||
break;
|
||||
}
|
||||
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { DEFAULT_REACH_LENGTHS * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
|
||||
let reach = options.reach.unwrap_or(if edge_sum.1 > 0 { 3.0 * edge_sum.0 / edge_sum.1 as f64 } else { 0.0 });
|
||||
if !(reach > 0.0) {
|
||||
break;
|
||||
}
|
||||
@@ -578,22 +527,18 @@ pub fn morph(model: &Model, nodes: &BTreeSet<u32>, options: MorphOptions, geomet
|
||||
let mut round_out = Round { bad_elements: bad_elems.len(), bad_nodes: bad.len(), reach, clusters: clusters.len(), ..Round::default() };
|
||||
for cluster in &clusters {
|
||||
fieldcraft_model::progress::check()?;
|
||||
let (region, kept) = state.morph_cluster(cluster, reach, options.guards, &|s: &State<'_>, n| movable(s, n))?;
|
||||
let (region, kept) = state.morph_cluster(cluster, reach, &|s: &State<'_>, n| movable(s, n))?;
|
||||
round_out.region_nodes += region;
|
||||
round_out.kept += usize::from(kept);
|
||||
}
|
||||
let now = score(&state);
|
||||
(round_out.min_quality, round_out.mean_quality) = (now.0, now.2);
|
||||
if better(now, best.0) {
|
||||
best = (now, state.pos.clone());
|
||||
}
|
||||
let (_, min, mean) = state.qualities();
|
||||
(round_out.min_quality, round_out.mean_quality) = (min, mean);
|
||||
let any = round_out.kept > 0;
|
||||
rounds.push(round_out);
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.pos = best.1;
|
||||
let mut after = region_quality(&state);
|
||||
let (_, _, mut mean_after) = state.qualities();
|
||||
// Never worse than the start.
|
||||
|
||||
@@ -360,7 +360,7 @@ fn strong_relaxation_of_a_distorted_block_keeps_faces_and_edges() {
|
||||
let (original, m) = distorted_annulus(6, 0.06);
|
||||
let geo = Geo { model: original.clone() };
|
||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||
let options = MorphOptions { below: 0.9, reach: None, rounds: 40, ..MorphOptions::default() };
|
||||
let options = MorphOptions { below: 0.9, reach: None, rounds: 40 };
|
||||
let r = morph(&m, &nodes, options, &geo).unwrap();
|
||||
assert!(r.after.min_quality > r.before.min_quality + 0.2, "{:?} → {:?}: {:?}", r.before.min_quality, r.after.min_quality, r.rounds);
|
||||
assert!(r.mean_after > r.mean_before, "mean {} → {}", r.mean_before, r.mean_after);
|
||||
@@ -389,7 +389,7 @@ fn strong_relaxation_moves_only_nodes_within_the_reach() {
|
||||
let geo = Geo { model: original };
|
||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||
let reach = 0.4;
|
||||
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20, ..MorphOptions::default() }, &geo).unwrap();
|
||||
let r = morph(&m, &nodes, MorphOptions { below: 0.8, reach: Some(reach), rounds: 20 }, &geo).unwrap();
|
||||
assert!(r.after.min_quality > r.before.min_quality, "{:?}", r.rounds);
|
||||
for n in r.positions.keys() {
|
||||
assert!(dist(m.node(*n).unwrap().xyz, CENTRE) < 0.2 + 2.0 * reach, "node {n} far from the bad spot moved");
|
||||
@@ -413,22 +413,6 @@ fn strong_relaxation_untangles_inverted_elements() {
|
||||
assert!(r.after.min_quality > 0.5, "{:?}", r.after);
|
||||
}
|
||||
|
||||
/// Both guards keep the result valid and never below the start; the valid guards (keep a morph
|
||||
/// unless it inverts an element) lift the worst element at least as far as the strict ones.
|
||||
#[test]
|
||||
fn strong_relaxation_guards_valid_and_strict() {
|
||||
let (original, m) = distorted_annulus(6, 0.06);
|
||||
let geo = Geo { model: original };
|
||||
let nodes: BTreeSet<u32> = m.nodes().keys().copied().collect();
|
||||
let run = |guards| morph(&m, &nodes, MorphOptions { below: 0.9, rounds: 40, guards, ..MorphOptions::default() }, &geo).unwrap();
|
||||
let (valid, strict) = (run(Guards::Valid), run(Guards::Strict));
|
||||
for r in [&valid, &strict] {
|
||||
assert_eq!(r.after.invalid, 0, "{:?}", r.after);
|
||||
assert!(r.after.min_quality >= r.before.min_quality, "{:?} → {:?}", r.before.min_quality, r.after.min_quality);
|
||||
}
|
||||
assert!(valid.after.min_quality >= strict.after.min_quality - 1e-9, "valid {:?}, strict {:?}", valid.after, strict.after);
|
||||
}
|
||||
|
||||
/// A good mesh is left as it is; bad options are errors.
|
||||
#[test]
|
||||
fn strong_relaxation_keeps_a_good_mesh_and_refuses_bad_options() {
|
||||
@@ -465,139 +449,3 @@ fn guarded_relaxation_keeps_the_largest_edge_ratio() {
|
||||
assert!(r.after.invalid <= r.before.invalid);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Moving nodes by hand (`mesh.node.move`) ----
|
||||
|
||||
/// The annulus node `(i, j, k)` of [`hex_annulus`] with `n` divisions.
|
||||
fn node_of(n: usize, i: usize, j: usize, k: usize) -> u32 {
|
||||
(1 + i + (n + 1) * (j + (n + 1) * k)) as u32
|
||||
}
|
||||
|
||||
fn grab(n: u32) -> BTreeSet<u32> {
|
||||
BTreeSet::from([n])
|
||||
}
|
||||
|
||||
/// Falloff: the grabbed node moves by all of the displacement, the nodes round it by less the
|
||||
/// farther they are, none beyond the radius; the three falloffs differ.
|
||||
#[test]
|
||||
fn a_falloff_move_carries_the_grabbed_node_and_fades_to_the_radius() {
|
||||
let m = hex_annulus(6);
|
||||
let h = node_of(6, 3, 3, 3);
|
||||
let d = [0.02, -0.01, 0.015];
|
||||
let r = 0.6;
|
||||
let mut shares = Vec::new();
|
||||
for falloff in [Falloff::Smooth, Falloff::Linear, Falloff::None] {
|
||||
let inf = Influence { radius: Some(r), falloff, ..Influence::default() };
|
||||
let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap();
|
||||
assert_eq!(w.get(&h), Some(&1.0));
|
||||
let moved = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap();
|
||||
assert!(dist(moved.positions[&h], add(m.node(h).unwrap().xyz, d)) < 1e-12);
|
||||
let p0 = m.node(h).unwrap().xyz;
|
||||
for (n, p) in &moved.positions {
|
||||
let old = m.node(*n).unwrap().xyz;
|
||||
assert!(dist(old, p0) <= r + 1e-12, "node {n} beyond the radius moved");
|
||||
assert!(dist(old, *p) <= dist([0.0; 3], d) + 1e-12);
|
||||
}
|
||||
assert_eq!(moved.scale, 1.0);
|
||||
assert!(moved.elements.len() >= 8 && moved.worse + moved.better > 0);
|
||||
shares.push(w.get(&node_of(6, 4, 3, 3)).copied().unwrap_or(0.0));
|
||||
}
|
||||
// Smooth (1 − t²)², linear 1 − t, none 1, at t = one element over the radius.
|
||||
assert!(shares[0] > 0.0 && shares[0] < 1.0 && shares[1] > 0.0 && shares[1] < 1.0 && shares[2] == 1.0, "{shares:?}");
|
||||
assert_ne!(shares[0], shares[1]);
|
||||
// Strength scales the followers, never the grabbed node.
|
||||
let half = Influence { radius: Some(r), strength: 0.5, falloff: Falloff::None, ..Influence::default() };
|
||||
let w = move_weights(&m, &grab(h), &half, &Unconstrained).unwrap();
|
||||
assert_eq!((w.get(&h), w.get(&node_of(6, 4, 3, 3))), (Some(&1.0), Some(&0.5)));
|
||||
// A sphere without grabbed nodes is a soft selection round its centre.
|
||||
let c = m.node(h).unwrap().xyz;
|
||||
let w = move_weights(&m, &BTreeSet::new(), &Influence { centre: Some(c), radius: Some(r), ..Influence::default() }, &Unconstrained).unwrap();
|
||||
assert!(w.get(&h).is_some_and(|v| (*v - 1.0).abs() < 1e-12) && w.len() > 1);
|
||||
}
|
||||
|
||||
/// Harmonic: shares between 0 and 1, 1 at the grabbed node, falling with the graph distance,
|
||||
/// and zero beyond the radius (no node there moves).
|
||||
#[test]
|
||||
fn a_harmonic_move_is_zero_beyond_the_radius() {
|
||||
let m = hex_annulus(8);
|
||||
let h = node_of(8, 4, 4, 4);
|
||||
let r = 0.45;
|
||||
let inf = Influence { radius: Some(r), propagate: Propagate::Harmonic, ..Influence::default() };
|
||||
let w = move_weights(&m, &grab(h), &inf, &Unconstrained).unwrap();
|
||||
let p0 = m.node(h).unwrap().xyz;
|
||||
assert_eq!(w.get(&h), Some(&1.0));
|
||||
assert!(w.len() > 10);
|
||||
for (n, s) in &w {
|
||||
assert!(*s > 0.0 && *s <= 1.0);
|
||||
assert!(dist(m.node(*n).unwrap().xyz, p0) <= r + 1e-12, "node {n} beyond the radius has a share");
|
||||
}
|
||||
let (near, far) = (w[&node_of(8, 5, 4, 4)], w.get(&node_of(8, 6, 4, 4)).copied().unwrap_or(0.0));
|
||||
assert!(near < 1.0 && far < near, "{near} {far}");
|
||||
let moved = move_nodes(&m, &w, [0.0, 0.0, 0.03], Guard::default(), &Unconstrained).unwrap();
|
||||
assert!(moved.positions.keys().all(|n| w.contains_key(n)));
|
||||
assert_eq!(moved.after.invalid, 0);
|
||||
}
|
||||
|
||||
/// On the geometry: a node on the outer cylinder stays on it, a node on an edge of two faces
|
||||
/// stays on both, a vertex is held; followers on the faces stay on theirs.
|
||||
#[test]
|
||||
fn a_move_on_the_geometry_slides_on_faces_and_edges_and_holds_vertices() {
|
||||
let n = 6;
|
||||
let m = hex_annulus(n);
|
||||
let geo = Geo { model: m.clone() };
|
||||
let d = [0.05, 0.05, 0.05];
|
||||
let inf = Influence { radius: Some(0.5), ..Influence::default() };
|
||||
for h in [node_of(n, n, 3, 3), node_of(n, n, 3, 0), node_of(n, n, 0, 0)] {
|
||||
let w = move_weights(&m, &grab(h), &inf, &geo).unwrap();
|
||||
let moved = move_nodes(&m, &w, d, Guard::default(), &geo).unwrap();
|
||||
for (k, p) in &moved.positions {
|
||||
let faces = Annulus::faces(m.node(*k).unwrap().xyz);
|
||||
assert!(faces.len() < 3, "vertex {k} moved");
|
||||
assert!(Annulus::on(*p, &faces), "node {k} left its faces {faces:?}");
|
||||
}
|
||||
let moved_handle = moved.positions.contains_key(&h);
|
||||
assert_eq!(moved_handle, Annulus::faces(m.node(h).unwrap().xyz).len() < 3, "node {h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The guard scales a move that would invert elements back to the largest that inverts none.
|
||||
#[test]
|
||||
fn the_guard_scales_a_move_back_until_nothing_inverts() {
|
||||
let m = hex_annulus(6);
|
||||
let h = node_of(6, 3, 3, 3);
|
||||
let w = move_weights(&m, &grab(h), &Influence::default(), &Unconstrained).unwrap();
|
||||
assert_eq!(w.len(), 1);
|
||||
let d = [0.6, 0.0, 0.0];
|
||||
let wild = move_nodes(&m, &w, d, Guard::None, &Unconstrained).unwrap();
|
||||
assert!(wild.after.invalid > 0 && wild.scale == 1.0);
|
||||
let guarded = move_nodes(&m, &w, d, Guard::default(), &Unconstrained).unwrap();
|
||||
assert_eq!(guarded.after.invalid, 0);
|
||||
assert!(guarded.scale > 0.05 && guarded.scale < 1.0, "{}", guarded.scale);
|
||||
assert!(guarded.worse > 0 && guarded.after.min_quality < guarded.before.min_quality);
|
||||
assert!(guarded.after.min_quality >= DEFAULT_MIN_QUALITY, "the guard stops short of a degenerate element");
|
||||
let p = guarded.positions[&h];
|
||||
assert!((p[0] - m.node(h).unwrap().xyz[0] - 0.6 * guarded.scale).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_move_parameters_are_refused() {
|
||||
let m = hex_annulus(3);
|
||||
let h = grab(node_of(3, 1, 1, 1));
|
||||
let bad = |inf: Influence| move_weights(&m, &h, &inf, &Unconstrained).is_err();
|
||||
assert!(bad(Influence { strength: 0.0, ..Influence::default() }));
|
||||
assert!(bad(Influence { strength: f64::NAN, ..Influence::default() }));
|
||||
assert!(bad(Influence { radius: Some(-1.0), ..Influence::default() }));
|
||||
assert!(bad(Influence { radius: Some(f64::INFINITY), ..Influence::default() }));
|
||||
assert!(bad(Influence { centre: Some([f64::NAN, 0.0, 0.0]), radius: Some(1.0), ..Influence::default() }));
|
||||
assert!(bad(Influence { propagate: Propagate::Harmonic, ..Influence::default() }));
|
||||
let w = move_weights(&m, &h, &Influence::default(), &Unconstrained).unwrap();
|
||||
assert!(move_nodes(&m, &w, [f64::NAN, 0.0, 0.0], Guard::None, &Unconstrained).is_err());
|
||||
assert!(move_nodes(&m, &w, [1e300, 0.0, 0.0], Guard::None, &Unconstrained).is_err());
|
||||
assert!(move_nodes(&m, &BTreeMap::from([(1, 2.0)]), [0.0; 3], Guard::None, &Unconstrained).is_err());
|
||||
assert!(move_nodes(&m, &w, [0.1; 3], Guard::NoInversion { min_quality: 1.5 }, &Unconstrained).is_err());
|
||||
// A radius too small for the mesh is refused, never an overflow of the search grid.
|
||||
assert!(bad(Influence { radius: Some(1e-300), ..Influence::default() }));
|
||||
assert!(bad(Influence { centre: Some([-1e300, -1e300, 0.0]), radius: Some(1e-300), ..Influence::default() }));
|
||||
let near = |r: f64| moving::nearest_within(&[[-1e300, 0.0, 0.0]], &BTreeMap::from([(1, [1e300, 0.0, 0.0])]), r);
|
||||
assert!(near(1e-300).is_empty() && near(1.0).is_empty());
|
||||
}
|
||||
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user