Compare commits

...
51 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 1c2adfd592 App icon: just the fringe cube, no streamlines
The owner's final choice: the hex-meshed cube with its banded fringe,
drawn a little larger, with no streamlines at any size. The Icon Composer
document is back to the floor and cube groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:03:27 -04:00
jyvikoandClaude Opus 5.5 34d3247d2c App icon: speed-coloured streamlines with arrows that run into the mesh
The streamlines are now banded in the fringe colours along their length, as
flow speed would be, with a thin dark halo and arrowheads. Two sweep in from
the lower left, climb over the cube and run straight into the top face's
mesh lines, narrowing to them, so the flow becomes the structure; one wraps
around the cube. The small variant keeps the cube with one bold coloured
streamline and one arrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:59:49 -04:00
jyvikoandClaude Opus 5.5 d93c106143 App icon: a fringe cube with flow streamlines replaces the meshed bracket
The mark is now a hex-meshed cube in isometric view whose faces carry a
banded colour fringe (blue to red, hottest at the front corner), with three
tapered streamlines wrapping around it. The small variant keeps the fringe
cube and one bolder streamline. Icon Composer gets four groups (floor, flow
behind, cube with mesh, flow in front), its maximum. The title-bar mark is
redrawn as the fringe cube.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:53:10 -04:00
jyvikoandClaude Opus 5.5 2dacf5ea9e ci: build the web app with Trunk and deploy it to GitHub Pages from main
pages.yml builds apps/fieldcraft-web (trunk build --release, wasm-opt)
on pushes to main and develop and on manual runs, and deploys the site
to https://jyviko.github.io/fieldcraft/ only from main (releases).
Develop merges that touch code build without deploying, so a broken
web build shows up before a release. Trunk is a pinned release binary
checked against its SHA-256; actions are pinned by commit.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00
jyvikoandClaude Opus 5.5 ebd2493051 web: FieldCraft in the browser (apps/fieldcraft-web)
The desktop's ModelApp in a web page through eframe's web runner
(WebGPU, WebGL2 fallback, `?webgl` to force it), built with Trunk.
The browser services sit behind the UI's host-service seams: File >
Open uses the browser's file picker, dropped files are read
asynchronously, and both go into the page's files; Save, Save As and
Write Deck hand the written file out as a download. Autosaves go to the
recovery folder as on the desktop and are mirrored to local storage, so
File > Recover offers what a closed or crashed page left. `?sample=NAME`
opens a sample. A loading screen with the app icon, and a message
instead of the app when neither WebGPU nor WebGL2 is available.

`cargo xtask wasm` (and so `cargo xtask ci`) now checks fieldcraft-web
for wasm32 with the library crates. docs/web.md: running it locally,
what works in the browser and what does not.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00
jyvikoandClaude Opus 5.5 5011ae5896 ui: on the web, run a background job once its progress is shown
The browser build has no threads, so a background job (opening a file,
meshing from a form, solving) ran inline in the click and the page froze
before it said what it was doing. Now the work waits until the window
has drawn the job's progress, then runs on the window thread in the
next frame. Native jobs still start on a worker thread at once.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00
jyvikoandClaude Opus 5.5 0638f91f00 engine: read the files commands name through fieldcraft_io::fs
file.open, geometry.open, geometry.import.mesh and reopening a model's
geometry read through the io crate's file edge instead of SolidCraft's
vfs, so in the browser they see the files the page holds (the same
store saves write to). Natively it is the same capped regular-file read.
fieldcraft-geometry drops its path-based readers (read_file,
open_design_file, read_3mf_file): it works on bytes, and reading files
is the io crate's job.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00
jyvikoandClaude Opus 5.5 8f27ad225a io: files kept in memory in the browser build, recovery over them
The browser has no file system a page can call synchronously. On wasm32,
fieldcraft_io::fs now reads and writes files kept in memory (memory.rs,
after SolidCraft's vfs): the web host puts picked and dropped files in
and takes out what commands wrote. The recovery folder works over the
same calls (list, remove, is_file), with in-memory entry locks and a
host-provided clock on wasm, so autosave and File > Recover work in the
browser as on the desktop. Native behaviour is unchanged.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00
github-actions[bot] 7d942574fd deps: regenerate THIRD-PARTY-LICENSES.txt 2026-10-14 10:19:39 -04:00
dependabot[bot] bdd453e462 deps: bump sha2 from 0.10.9 to 0.11.0
Bumps [sha2](https://github.com/RustCrypto/hashes) from 0.10.9 to 0.11.0.
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 10:19:39 -04:00
jyvikoandClaude Opus 5.5 fb1f71e11d ci: the Dependabot licences commit skips CI (its tree was already tested)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:09:08 -04:00
jyvikoandClaude Opus 5.5 7942a7aea7 ci: Dependabot pull requests regenerate THIRD-PARTY-LICENSES.txt; num-bigint moves with num-rational
Every Cargo update changes the licences file, which Dependabot cannot regenerate, so all its pull
requests failed the licences check before testing anything. CI regenerates it for them and a new
workflow commits it. num-bigint 0.5 alone cannot build: the predicates' BigInt is num-rational's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:04:00 -04:00
jyvikoandClaude Opus 5.5 8c158b1e5c release: version 0.5.5
The releases before it (0.1.0 to 0.5.4) are milestones tagged on develop's history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:54:25 -04:00
jyvikoandClaude Opus 5.5 254acbb5c0 CONTRIBUTING: what FieldCraft is, getting it running, where help is wanted most; README: downloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:51:17 -04:00
jyvikoandClaude Opus 5.5 760c7fa7d3 ci: weekly build check for Linux arm64 and riscv64
Nothing checked Linux beyond x86_64. A weekly and on-demand workflow
builds fieldcraft and fieldcraft-cli on a native arm64 runner (release
profile, as shipped), runs both binaries and the CLI and engine tests
there, and cross-builds both for riscv64gc-unknown-linux-gnu with
Ubuntu's GNU toolchain, linked, then runs them under QEMU. The GUI opens
Wayland, X11 and GL at run time, so the cross build needs no target
system libraries beyond libc.

The RISC-V job is adapted from SolidCraft's CI.

Closes #122

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:45:57 -04:00
jyvikoandClaude Opus 5.5 557a950336 ci: packaging lint for the workflows, packaging scripts and desktop metadata
The release pipeline's scripts and metadata were exercised only by a
full release run. A workflow of a few seconds, no Rust build, now checks
them on every push to develop and pull request that touches
.github/workflows or packaging, and on demand: actionlint (pinned,
checksum-verified, with shellcheck on every run: script), shellcheck on
the packaging scripts and AppRun, a parse and PSScriptAnalyzer pass
(errors and warnings) over the Windows scripts, xmllint on the macOS
property lists, desktop-file-validate on the desktop entry, and the
entry's StartupWMClass against the app id the window sets.

Adapted from SolidCraft's packaging-lint.yml (itself from PhotoCraft's).

Closes #121

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:45:57 -04:00
jyvikoandClaude Opus 5.5 fed231910b packaging: let shellcheck follow env.sh
The scripts carry `# shellcheck source=../env.sh`, but shellcheck reads
that path from the working directory, so from the repository root it
could not open env.sh: SC1091 on every script, and SC2153 in
macos/package.sh because APP_ID, set in env.sh, looked unassigned.
packaging/.shellcheckrc resolves source paths from the script's own
directory (SolidCraft's setting); every packaging script now passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:45:57 -04:00
jyvikoandClaude Opus 5.5 85fa350b6b ci: weekly Dependabot updates for Cargo and GitHub Actions, tested by CI
Dependabot opens one grouped pull request a week per ecosystem against
develop: compatible Cargo updates together (a breaking release on its
own) and every action together, keeping the SHA pins and their version
comments. The SolidCraft pins, the truck and egui-wgpu patches taken
from that commit and craft-ui's rev move by hand; egui and eframe take
patch releases only, since their line follows SolidCraft's.

CI skips same-repo pull requests, so Dependabot's would merge untested:
the gate, the oracle and the advisories now also run on pull requests
opened by dependabot[bot]. The heavy job stays off pull requests.

Closes #120

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 09:45:57 -04:00
jyvikoandClaude Opus 5.5 90d5f9a771 ci: the oracle on macOS weekly and on demand, so platform differences show up in CI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:56:22 -04:00
jyvikoandClaude Opus 5.5 4445be0b48 mesh.quality: a one-line summary, shown in the status bar
Quality from the ribbon printed its whole JSON report into the status
bar, cut off after the first metric's histogram, so a user saw braces
and no answer. The report now carries a `summary` line ("30676 elements,
all valid; scaled Jacobian min 0.305, mean 0.963", with the invalid and
undetermined counts when there are any), and the status bar shows a
reply's summary instead of its JSON whenever it has one. The CLI and MCP
get the same line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:46:50 -04:00
jyvikoandClaude Opus 5.5 e4e3c9a699 ui: mesh in the background from the mesher forms
OK on a mesher's form ran the command on the window thread: octree hex
on the bolted assembly froze the window for over a minute, with no
progress and no way to stop it. The tet and hex meshers' forms now run
their command as a background job on a copy of the session, as opening
a file and solving do: the window keeps drawing with the job's name and
Cancel, the form closes when the mesh is in and shows the error when it
is not. ui.dialog {run} stays synchronous for programmatic callers.

The new test walks a first-time user's path: Open picks a SolidCraft
design, Tet Mesh opens its form, OK with the defaults meshes the part,
and a mapped hex mesh of a holed plate says why it does not fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:46:50 -04:00
jyvikoandClaude Opus 5.5 8a4209a20c control: ui.drop drops files on the window
The Open dialog is a native picker the control channel cannot click, so
nothing could walk a person's open path end to end: ui.start file.open
opens the command's JSON form instead. ui.drop {paths} hands the paths
to the next frame with the window system's drops, which open them
through the same route a picked file takes (open_path: by type, in the
background, the unsaved-changes prompt, the recent list). At most 16
paths of at most 4096 bytes each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:46:50 -04:00
jyvikoandClaude Opus 5.5 cf72503f76 mesh.tet.generate: mesh every body of the open design by default
The Tet Mesh form opened with {} and failed with "give either body (with
tolerance) or surface", so a first-time user could not mesh a part
without knowing its body name and a chordal tolerance. The tet mesher
now takes its bodies as the hex meshers do: every body of the open
design by default, one named body, or an imported surface, with the
tessellation tolerance defaulting to 1e-3 of each body's diagonal. With
several bodies an error names the body it is about. The reply keeps its
totals (kind, counts, first labels, volumes) and lists each body's
counts, size and quality report under `bodies`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:46:50 -04:00
jyvikoandClaude Opus 5.5 90729e4532 oracle: quantities of a generated mesh can be gated by their tolerance alone ("fixture": false)
The bracket's tet mesh is not bit-identical across platforms (#115): its fillet peak is 213.31 MPa
on macOS and 212.42 on Linux CI, which turned the oracle job red after #110. Those two quantities
are no longer frozen; the page names them mesh-dependent instead of printing one platform's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:42:31 -04:00
jyvikoandClaude Opus 5.5 fe7d536719 develop is the trunk; main holds released code and moves by fast-forward at a release
CI runs on merges to develop (and on fork pull requests); the release workflow builds the draft
from a push to main. AGENTS.md, CONTRIBUTING.md and docs/releasing.md follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:32:27 -04:00
jyvikoandClaude Opus 5.5 9469ac0853 Tet mesh quality: one validity criterion, Delaunay refinement, ODT, Klingner–Shewchuk, tet10
The end-to-end demo met a raw-CDT sliver that the validity gate accepted (exactly positive) and
the solver refused. Now:

- One validity criterion, `fieldcraft_model::admissible_jacobian` (normalized Jacobian above
  1e-12), used by the implicit solver (fem `positive_inverse`), the explicit tet4/hex8
  kinematics and the mesh gate, which proves it on the whole element (tet4: the solvers' own
  Jacobian; curved kinds: a Bernstein floor), so whatever passes the gate passes the solvers.
- CDT rounding repaired by flips on the rounded mesh (Diazzi et al. 2023 §5.1) instead of the
  ±2-ulp nudge.
- `fieldcraft-mesh::tet`: a mutable tetrahedral complex whose every change is a checked
  replacement (positive and admissible tetrahedra, faces matched with opposite orientation) with
  an exact undo log; edge removal (Klincsek), multi-face removal, Delaunay insertion with
  boundary faces, edge collapse; a curvature- and thickness-graded size field; constrained
  Delaunay refinement with encroachment splits; ODT smoothing; the Klingner–Shewchuk schedule;
  boundary vertices moved onto the exact B-rep surfaces at the end; tet10 with mid-edge nodes on
  the geometry.
- `mesh.tet.generate` gains size, order, quality, quality_target, curvature_angle, grading,
  max_passes, max_points and reports the CDT's and the mesh's quality; `solve.probe` takes a set
  and reports its largest von Mises stress.
- Oracle cases filleted-bracket-fillet-stress(-coarse): the fillet's peak von Mises stress on
  tet10 meshes of 2k and 13.5k elements, 213.3 MPa (a four-level study stays within 1.8%); the
  CDT cases keep the raw tetrahedralization.
- Corpus quality columns: all 20 valid parts mesh and pass the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:28:49 -04:00
jyvikoandClaude Opus 5.5 fed27d816d docs: the literature survey names FieldCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:20:45 -04:00
jyvikoandClaude Opus 5.5 e6688ef014 mcp: select and selection tools for the app's viewport selection
Bridged to a running app, select and selection call ui.select and
ui.selection; headless they are errors (selection belongs to the
window).

Closes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 f11be61ff3 control: ui.select on the viewport and ui.selection
ui.select now also drives the viewport's selection: the filter, box
mode, items by name and label as model.set.create takes them, a pick at
a window point or the visible items in a box, changed by a mode
(replace, add, remove, toggle). ui.selection reads it back with its set
members and the hover; ui.dialog takes use_selection (the form's Target
row). Selection stays in the window: no engine command. Documented in
docs/control-protocol.md, with a test that picks the cantilever's free
end by a simulated click and a box, makes sets of it and resolves them,
and hostile-input tests (bad points and boxes, labels, huge lists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 9e269a9f45 ui-egui: select faces, edges, vertices, bodies, nodes and elements by picking
A click in the viewport picks what is under the pointer through the
selection filter (the selection bar, top centre): geometry faces, edges,
vertices and bodies, or mesh nodes, element faces and elements. Shift
adds, Cmd/Ctrl adds or removes, a click on nothing clears, Escape
clears; with Box on, a left drag selects the visible nodes, element
faces or elements inside the box. What a click would pick is
highlighted under the pointer with its name beside it.

The selection is kept in the model's persistent terms: geometry by name
(GeometryRef, named through the mesh's geometry tags or the drawn
bodies' face names), so it survives a remesh, and the mesh by label
(labels the model loses drop out). It reads as a set's members.

Picking runs on the CPU against the triangles both renderers draw. The
selection and the hover are drawn as an overlay in the theme's new
pick_selected and pick_hover colours (light and dark), outlined; the GPU
draws it as a second small layer, so a hover change uploads only that.

Command forms that apply to part of the model (sets, boundary
conditions, loads, sections, couplings) get a Target row whose Use
selection fills the members, or makes a set of the selection and names
it. A right click offers Create set from selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 32c67d1988 render: watertight ray picking, element face ids and an overlay layer
The pick module tests rays against triangles with the watertight
ray-triangle intersection of Woop, Benthin and Wald (JCGT 2013): a ray
through an edge or vertex two triangles share hits one of them, the
nearest hit wins and ties go to the lower index, so picks are
deterministic. A Projector holds the camera's projection for
screen-space picks (Camera::to_screen and the CPU renderer use it too),
and a DepthMap of the drawn faces, rasterised by the renderer's own
triangle routine, tells which points are in front (box selection).

Display triangles now carry their element and face number (numbered as
ElementKind::faces numbers them; seen_face gives a shell's bottom side
from behind), line elements their element, and geometry surface
triangles their face tag, so a hit names what a set refers to.
DisplayMesh::pick (results probing) uses the new test.

render_layers draws a second scene over the first: its faces over the
scene faces they repeat (a small depth allowance), then its lines and
points. The viewport draws selection and hover highlights with it
without rebuilding the model's scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 08:16:26 -04:00
jyvikoandClaude Opus 5.5 bf42d182ff ci: release workflow drafting a GitHub Release, and docs/releasing.md
A push to the release branch (or workflow_dispatch) builds the macOS universal dmg and CLI zip,
the Linux tar.gz and AppImage and the Windows zip, with the version from Cargo.toml. The last
job checks the set, writes SHA256SUMS.txt and notes saying how the macOS app was signed, and
creates or updates a draft release; it never publishes. Off the release branch it only uploads
the files as a run artifact (dry run). macOS signing and notarization use the APPLE_* secrets
when they exist and fall back to ad-hoc signing (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 0d7c881eea packaging: macOS app and dmg, Linux tar.gz and AppImage, Windows zip
packaging/macos/package.sh builds FieldCraft.app (io.github.jyviko.fieldcraft, with icon.icns
and the layered Assets.car), signs it with a Developer ID and notarizes it when the
credentials are there and ad-hoc otherwise, and puts it on a drag-to-Applications dmg that
is mounted and checked; fieldcraft-cli goes in its own zip. packaging/linux/package.sh makes
an FHS tar.gz (binaries, .desktop entry, hicolor icons, licences) and an AppImage made with
appimagetool and the type 2 runtime at pinned, hash-checked releases.
packaging/windows/package.ps1 zips fieldcraft.exe and fieldcraft-cli.exe (static CRT,
checked PE headers and icon). Every package carries README.md, LICENSE-MIT, LICENSE-APACHE,
NOTICE and THIRD-PARTY-LICENSES.txt (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 858b6e9dee app: reverse-DNS app id and the Windows executable icon
The window's Wayland app_id / X11 class is io.github.jyviko.fieldcraft, the id the release
packages use for the .desktop entry and the macOS bundle (#72). Windows builds embed
assets/app-icon/icon.ico in fieldcraft.exe through embed-resource (MIT, a build dependency only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 8172af67e7 cli: fieldcraft-cli --version
The release packaging runs it to check each build (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:56:06 -04:00
jyvikoandClaude Opus 5.5 8caa0d15cb issue forms: observed failure, reproduction, evidence, expected behaviour, investigation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:52:59 -04:00
jyvikoandClaude Opus 5.5 ff6daa7cd6 README: the partitioned all-hex bracket with images, the nonlinear solver and materials, setup symbols, how to help
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:51:14 -04:00
jyvikoandClaude Opus 5.5 f1253b4010 AGENTS.md: main is protected; the core team merges with --admin, contributors wait for review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:47:56 -04:00
jyvikoandClaude Opus 5.5 152092cfb4 community: CODEOWNERS, issue and PR templates, code of conduct, claiming issues; CI on pull requests from forks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:45:14 -04:00
jyvikoandClaude Opus 5.5 71cc39213c testkit: octree hex column in the meshing corpus
The corpus runs mesh.hex.octree's mesher on every part at 1/25 of its diagonal (the size of
the structured hex column) and reports hexahedra, the smallest and mean scaled Jacobian, the
mesh volume against SolidCraft's and the time; a part it cannot mesh gets the reason. The
column caps the elements at 500 000 so a part whose grid explodes is refused in seconds.
Loaded parts carry their bounding-box diagonal for the stages. docs/meshing/corpus.md
regenerated: 15 of 21 parts mesh, including filleted-bracket, rounded-box, sphere-on-plate
and the analytic sphere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 a719ae7e5b oracle: filleted L-bracket on the octree all-hex mesh
A slow theory case: the corpus bracket meshed by mesh.hex.octree at 7, 5 and 4 mm, clamped
at the base and loaded by 2 kN. Checks equilibrium, that every element is certified valid,
the mesh volume against the CAD volume, the beam bending stress of the upright leg
(sigma = M c / I = 145.3 MPa, within 5%) and the convergence of the fillet von Mises stress
against the 4 mm mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 7d2696057f engine: mesh.hex.octree command
Replaces the mesh with the octree all-hex mesh of a body, an imported surface or every body
of the open design, and tags boundary element faces with their B-rep faces so sets by
geometry resolve. Size, tolerance, refinement depth, element cap, attempts and optimiser
sweeps are validated and capped; the element cap defaults to the session limit
(`Limits::max_hex_elements`), which the hostile-params test keeps small. Body input and face
tagging are shared with mesh.hex.generate. The catalogue's Automatic Hex entry is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 3587b0de74 mesh: unstructured all-hex octree mesher
`fieldcraft_mesh::octree::octree_hex_mesh` meshes any closed part with hex8 only:

- an adaptive 3-refinement grid (27-tree) with strong balance and closure, filled with
  conforming node-marking transition templates (Schneiders; checked over all 48 symmetries);
- a core of grid cells kept clear of the boundary (the HybridOctree buffer rule), with
  non-manifold cleanup and a genus check against the part;
- a feature graph from the B-rep (corners, curves, patches) and a partition-first map of the
  core's boundary onto it, then two boundary layers: a padding layer and a pillow per patch,
  so every face boundary and sharp edge is captured by element edges;
- a Garanzha-type untangling and fitting energy (shape term |J|^3/chi, a volume term and a
  soft fitting term, deterministic across platforms), a compass search on the worst
  elements and a certified repair; failures refine the grid near them and rebuild, within
  bounded attempts and growth.

The output passes the certified Jacobian check of every element and a gate that the
boundary encloses the elements' volume; boundary quads carry their B-rep faces. Quality is
reported with the same scaled Jacobian as mesh.quality. Literature notes for each step are in
docs/meshing/literature.md (B1.6-B1.10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:38:11 -04:00
jyvikoandClaude Opus 5.5 741a803785 oracle: docs/oracle.md reads the same after a blessing run, so main's freshness check passes
A value blessed in a run matches its new fixture by construction; the page said "blessed" and
CI's regenerated page said "matches", which turned main red after #68 and #69.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:36:49 -04:00
jyvikoandClaude Opus 5.5 d586d84859 verification: hyperelastic and thermal oracle cases and documentation
Five cases against closed forms: a Mooney-Rivlin block in Treloar's
uniaxial, equibiaxial and pure-shear modes and a Yeoh block in uniaxial
tension (the compressible energy's stresses, exact); a stretched
Mooney-Rivlin tube inflated by a follower pressure (Rivlin's incompressible
solution, 0.6 % at nu = 0.4995); free and fully held thermal expansion at a
thermal stretch of 1.2 (exact); a bar held at its ends heated past yield
(exact). docs/fem.md, the literature, ROADMAP (M15, M17), parity and the
oracle page follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 3f9e9e30d8 fem, solver: thermal strain in nonlinear static steps
Temperatures now strain the material in nonlinear steps instead of being
refused. At finite strain the deformation gradient splits multiplicatively,
F = F_m F_theta with F_theta = diag(1 + alpha_i dT) at each point: the material
sees F_m and the loops run on the thermally expanded volume J_theta dV0 (mixed
elements project det F_m), so the tangent stays consistent. At small strain the
split is additive, and elements on their linear stiffness take the equivalent
nodal forces as a dead load. The step's temperatures are its temperature loads,
or the steady conduction of its temperature conditions and heat loads (one-way
coupling, as the linear coupled step); both at once are refused. They ramp with
the load factor from each material's reference temperature. Johnson-Cook
plasticity softens at the point's temperature. Verified: free expansion and a
fully constrained neo-Hookean block at 20 % thermal stretch, full and hybrid
(exact); a bar held at its ends heated past yield (exact); the linear limit and
the conducted temperatures against the linear solvers; tangents by central
differences with a temperature field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 53adab54c2 fem, model: Mooney-Rivlin and Yeoh hyperelasticity in nonlinear steps
Two more large-strain options on the isotropic constants, decoupled like
neo-Hookean with the volume kappa/2 (J - 1)^2: Mooney-Rivlin with C10 = mu/2 - C01
(0 <= C01 <= mu/2) and Yeoh with C10 = mu/2 and the higher terms C20, C30, so the
small-strain material is the elastic one everywhere. MaterialModel::Polynomial
gives the isochoric stress and tangent from Holzapfel's fictitious quantities
(eqs. 6.168, 6.193), consistent by central differences, equal to neo-Hookean
with C10 alone; hybrid elements take it. Abaqus writes *HYPERELASTIC,
MOONEY-RIVLIN and YEOH; LS-DYNA keeps refusing hyperelastic materials in
nonlinear steps by name. Verified in uniaxial and equibiaxial tension and pure
shear against the compressible closed form (exact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 07:23:06 -04:00
jyvikoandClaude Opus 5.5 7a69fe5e2f verification: implicit plasticity oracle cases and documentation
Four cases, each against its closed form: uniaxial tension of a J2 cube to a
logarithmic strain of 0.2 (Kirchhoff stress exact; 0.13 % from the explicit
solver's hypoelastic Cauchy stress, the elastic volume change between the two
laws), the Bauschinger effect of linear kinematic hardening on a load path
back to zero strain (exact), Hill's elastic-perfectly plastic thick cylinder
(bore displacement at a plastic radius of 1.5a within 0.009 %, collapse
pressure within 2e-8; the pending case is now active) and a cantilever's
plastic collapse, converged 3.1 % above beam theory's hinge, the bound a
continuum exceeds. docs/fem.md, the literature, ROADMAP (M15), parity and the
oracle page follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 17b2c837f2 solver: J2 plasticity in nonlinear static steps, finite and small strain
Elements of a plastic material take the J2 material point: total Lagrangian
with nonlinear geometry, the small-strain loops without (elastic elements keep
their linear stiffness, so elastic results do not move). The plastic history is
committed per converged increment. Increments follow the step's load path,
ending at its points, the extrapolated predictor scaled by the change of the
load factor. Steps with path-dependent materials start an increment without a
predictor from the tangent predictor (the prescribed motion spread through the
converged tangent), and a first tangent that is not positive definite is a
cutback when the undeformed model's is. Refused by name: the Johnson-Cook rate
term (a static step has no time) and kinematic hardening at finite strain;
damage, erosion and adiabatic heating are left out with a warning. Results
carry the equivalent plastic strain at the integration points (solve.probe,
the step summary). LS-DYNA writes a load path as the step's curve; Abaqus
refuses it and a plastic deck of several general steps (Abaqus carries the
plastic state between them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 569b69c061 model: load paths of nonlinear static steps
NonlinearStatic::load_path gives the load factor against the step fraction,
piecewise linear from [0, 0] to fraction 1, so a step can unload or reverse
its loads and prescribed displacements (cyclic loading of path-dependent
materials). Validated (2 to 1000 finite points, increasing fractions), with
load_factor and next_turn for the solver's increments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
jyvikoandClaude Opus 5.5 72940fe78f fem: J2 plasticity at the implicit material point, finite and small strain
The radial return of the explicit update is now J2::radial_return, called by
both solvers, with the consistent tangent J2::tangent (Simo & Hughes 1998,
Box 3.2). The new elastoplastic module gives the implicit material point:
Simo's multiplicative plasticity with the exponential return map in the
principal logarithmic strains (CMAME 99, 1992) and its spatial tangent through
the Daleckii-Krein divided differences of the logarithm, and the additive
radial return of small strain with kinematic hardening. MaterialModel::Plastic
carries it, decoupled (deviatoric return, Hencky volume) so hybrid elements
take it. The element loops of finite.rs gain small-strain kinematics for
inelastic materials and pass history through the mixed path. Every tangent is
checked against central differences, element by element, from a plastic
history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 06:03:36 -04:00
240 changed files with 20323 additions and 1564 deletions

No files matched your search

+6
View File
@@ -1,2 +1,8 @@
[alias]
xtask = "run --package xtask --"
# Windows release binaries link the C runtime statically, so they run without the VC++
# redistributable (packaging/windows/package.ps1 builds with --target, which keeps the flag
# off build scripts and proc-macros).
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+2
View File
@@ -0,0 +1,2 @@
# Every change to FieldCraft needs the maintainer's review before it merges.
* @jyviko
+46
View File
@@ -0,0 +1,46 @@
name: Bug report
description: Something FieldCraft does wrong, a wrong result, or a crash
labels: [bug]
body:
- type: textarea
id: observed
attributes:
label: Observed failure
description: Exactly what happened. Quote error messages and wrong numbers as they appear.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Numbered steps, or a script (`fieldcraft-cli run script.json`, control-channel JSON lines), with the sample, corpus part or attached `.fieldcraft` / `.solidcraft` file that shows it.
placeholder: |
1. fieldcraft --sample cantilever
2. Mesh ▸ Tet ▸ OK
3. ...
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What should happen instead, and why — a closed form, a published benchmark, the documentation, or how the rest of the app behaves. Never output from a commercial CAE product.
validations:
required: true
- type: textarea
id: investigation
attributes:
label: Investigation
description: Anything you found about the cause — code you suspect, what you ruled out. Leave empty if you have not looked.
- type: input
id: version
attributes:
label: Version and platform
placeholder: "v0.5.5 (or commit), macOS 15 arm64"
validations:
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+28
View File
@@ -0,0 +1,28 @@
name: Feature request
description: A capability FieldCraft should have
labels: [enhancement]
body:
- type: textarea
id: current
attributes:
label: Current behaviour
description: What you can and cannot do today, and what happens when you try.
validations:
required: true
- type: textarea
id: need
attributes:
label: What you need and why
description: The analysis or workflow you are trying to do.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: What FieldCraft should do, as precisely as you can.
- type: textarea
id: reference
attributes:
label: References
description: Papers, textbooks, public specifications or published benchmarks that define the behaviour. Do not paste text, files or results from commercial CAE products.
+59
View File
@@ -0,0 +1,59 @@
# Weekly dependency updates against develop, the trunk, grouped so each ecosystem opens one pull
# request a week. Security updates are switched on in the repository settings and open their own
# pull requests as advisories appear. CI runs the full gate on Dependabot's pull requests
# (.github/workflows/ci.yml).
version: 2
updates:
- package-ecosystem: cargo
directory: /
target-branch: develop
schedule:
interval: weekly
day: monday
time: "05:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
commit-message:
prefix: deps
groups:
# Compatible updates (Cargo.lock only) in one pull request; a breaking release of a
# dependency comes on its own, since it usually needs code changes.
cargo-compatible:
update-types: [minor, patch]
ignore:
# Pinned to the SolidCraft commit FieldCraft builds against, together with the truck and
# egui-wgpu patches from that commit ([patch.crates-io] in Cargo.toml); moved by hand.
- dependency-name: "solidcraft-*"
- dependency-name: "truck-*"
- dependency-name: matext4cgmath
- dependency-name: egui-wgpu
# The egui line follows SolidCraft's (its egui-wgpu fixes are patched in): patch releases
# come with the group, a new line (0.36 -> 0.37) moves by hand with SolidCraft.
- dependency-name: egui
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: eframe
update-types: ["version-update:semver-major", "version-update:semver-minor"]
# The shared Craft components follow VectorCraft's rev.
- dependency-name: craft-ui
# The exact predicates' BigInt must be num-rational's (0.4 uses num-bigint 0.4): both move
# together, by hand.
- dependency-name: num-bigint
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: num-rational
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
day: monday
time: "05:00"
timezone: Etc/UTC
open-pull-requests-limit: 3
commit-message:
prefix: ci
groups:
# Actions are pinned by commit SHA with a version comment; Dependabot updates both.
actions:
patterns: ["*"]
+11
View File
@@ -0,0 +1,11 @@
Closes #
## What and why
## How it was verified
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
## Checklist
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
- [ ] No code, text or data from GPL-family projects or commercial CAE products
+27
View File
@@ -0,0 +1,27 @@
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
[README](https://github.com/jyviko/fieldcraft#readme) ·
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/jyviko/fieldcraft/issues)
**Which file?**
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
drag `FieldCraft.app` onto Applications. The command-line tool is in
`fieldcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
`fieldcraft-{version}-linux-x86_64.tar.gz` and run `bin/fieldcraft` (`bin/fieldcraft-cli` is the
command-line tool).
- **Windows 10+** (x64): unzip `fieldcraft-{version}-windows-x64.zip` and run `fieldcraft.exe`
(`fieldcraft-cli.exe` is the command-line tool).
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing` (macOS:
`shasum -a 256 -c SHA256SUMS.txt --ignore-missing`).
To start with a sample model, run the app with `--sample cantilever` (macOS:
`/Applications/FieldCraft.app/Contents/MacOS/FieldCraft --sample cantilever`).
**Signing.** {macos_signing} The Linux and Windows builds are not signed; on Windows, SmartScreen
may say "Windows protected your PC": choose **More info**, then **Run anyway**.
Every package carries `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `THIRD-PARTY-LICENSES.txt`.
+83
View File
@@ -0,0 +1,83 @@
# Linux on other architectures, weekly and on demand: CI and the release build x86_64 Linux,
# macOS and Windows only.
# - arm64: a native runner builds the shipped binaries (release profile) and runs the CLI and
# engine tests.
# - riscv64: cross-built with Ubuntu's GNU toolchain and linked; the CLI then runs under QEMU.
# The GUI needs no target system libraries at link time (Wayland, X11 and GL are opened at run
# time), so only the cross C toolchain and libc are installed.
# The RISC-V job is adapted from SolidCraft's CI (see ATTRIBUTION.md).
name: Linux arm64 and riscv64
on:
schedule:
- cron: '17 5 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: arch-check-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
arm64:
name: Linux arm64 (native)
runs-on: ubuntu-24.04-arm
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: arm64
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Build fieldcraft and fieldcraft-cli (release)
run: cargo build --locked --release -p fieldcraft -p fieldcraft-cli
- name: Run the binaries
run: |
file target/release/fieldcraft target/release/fieldcraft-cli
target/release/fieldcraft --version
target/release/fieldcraft-cli --version
target/release/fieldcraft-cli commands >/dev/null
- name: CLI and engine tests
run: cargo test --locked --profile ci -p fieldcraft-cli -p fieldcraft-engine
riscv64:
name: Linux riscv64 (cross)
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
TARGET: riscv64gc-unknown-linux-gnu
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUNNER: qemu-riscv64 -L /usr/riscv64-linux-gnu
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
PKG_CONFIG_ALLOW_CROSS: 1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: riscv64
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Install the cross toolchain and QEMU
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends gcc-riscv64-linux-gnu g++-riscv64-linux-gnu \
libc6-dev-riscv64-cross qemu-user
- name: Build fieldcraft and fieldcraft-cli
run: cargo build --locked --profile ci --target "$TARGET" -p fieldcraft -p fieldcraft-cli
- name: Run the CLI under QEMU
run: |
bin="target/$TARGET/ci"
file "$bin/fieldcraft" "$bin/fieldcraft-cli"
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft-cli" --version
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft-cli" commands >/dev/null
qemu-riscv64 -L /usr/riscv64-linux-gnu "$bin/fieldcraft" --version
+50 -8
View File
@@ -1,10 +1,21 @@
name: CI
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): pull requests are
# checked locally on the crates they touch and merged; a red main is fixed forward.
# CI runs once per merge on `develop`, the trunk (AGENTS.md, "Workflow for every change"): the core
# team's pull requests are checked locally on the crates they touch and merged; a red trunk is
# fixed forward. `main` holds released code only (docs/releasing.md).
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
# the run, so the review sees it; so do Dependabot's (.github/dependabot.yml), which nobody tests
# locally. The heavy job stays off pull requests.
on:
pull_request:
paths:
- '**'
- '!**/*.md'
- '!docs/**'
- 'docs/features.md'
- 'docs/oracle.md'
push:
branches: [main]
branches: [develop]
# A merge that only touches prose starts no run. The generated pages are checked by the jobs
# (features.md by `cargo xtask ci`, oracle.md by the oracle job), so they still count.
# (Later patterns override earlier ones.)
@@ -15,8 +26,11 @@ on:
- 'docs/features.md'
- 'docs/oracle.md'
schedule:
# Nightly: the heavy tests on main.
# Nightly, on the default branch (develop): the heavy tests.
- cron: '23 3 * * *'
# Weekly: the oracle on macOS too, so results that differ by platform show up in CI (the
# fixtures are often blessed on a Mac; #115).
- cron: '41 4 * * 0'
workflow_dispatch:
permissions:
@@ -26,7 +40,7 @@ permissions:
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
concurrency:
group: ci-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event_name == 'push' }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
@@ -34,6 +48,7 @@ env:
jobs:
ci:
name: cargo xtask ci
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -44,12 +59,19 @@ jobs:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
# A dependency update changes THIRD-PARTY-LICENSES.txt, which Dependabot cannot regenerate:
# regenerate it here so the gate checks everything else, and dependabot-licences.yml commits
# it to the pull request.
- name: Regenerate THIRD-PARTY-LICENSES.txt (Dependabot)
if: github.event.pull_request.user.login == 'dependabot[bot]'
run: cargo xtask licences
- run: cargo xtask ci
heavy:
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
name: cargo xtask test --heavy
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -57,7 +79,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask test --heavy
oracle:
@@ -65,6 +87,7 @@ jobs:
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
# run here too while the whole set stays within a few minutes; `--quick` skips them.
name: cargo xtask oracle
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
@@ -73,13 +96,32 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
oracle-macos:
# The same oracle on macOS, weekly and on demand: the solvers must agree across platforms
# to the fixture bands, and docs/oracle.md must read the same.
name: cargo xtask oracle (macOS)
if: github.event.schedule == '41 4 * * 0' || github.event_name == 'workflow_dispatch'
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle-macos
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
advisories:
name: Dependency advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
+40
View File
@@ -0,0 +1,40 @@
# Dependabot's Cargo updates change THIRD-PARTY-LICENSES.txt (`cargo xtask licences`), which
# Dependabot cannot regenerate: this commits the regenerated file to its pull request. CI's own
# run on the pull request regenerates it too, so its result covers everything else.
name: Dependabot licences
on:
pull_request:
branches: [develop]
paths: [Cargo.lock]
permissions:
contents: write
concurrency:
group: dependabot-licences-${{ github.head_ref }}
cancel-in-progress: true
jobs:
licences:
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.head_ref }}
- uses: dtolnay/rust-toolchain@stable
- run: cargo xtask licences
- name: Commit the regenerated file
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt; then
echo "THIRD-PARTY-LICENSES.txt is up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# [skip ci]: the pull request's CI run already regenerated this file and tested the
# tree with it; a bot-authored commit would otherwise wait for a maintainer's approval.
git commit -m "deps: regenerate THIRD-PARTY-LICENSES.txt [skip ci]" -- THIRD-PARTY-LICENSES.txt
git push origin "HEAD:${GITHUB_HEAD_REF}"
+79
View File
@@ -0,0 +1,79 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
# packaging shell and PowerShell scripts, the macOS property lists and the Linux desktop entry.
# These files are otherwise exercised only by a full release run (release.yml).
# Adapted from SolidCraft's packaging-lint.yml, itself adapted from PhotoCraft's (see ATTRIBUTION.md).
name: Packaging lint
on:
push:
branches: [develop]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Packaging lint
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
PSSCRIPTANALYZER_VERSION: 1.24.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils libxml2-utils
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "$ACTIONLINT_SHA256 $archive" | sha256sum -c -
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
- name: actionlint
# Also runs shellcheck on every `run:` script.
run: '"$RUNNER_TEMP/actionlint" -color'
- name: shellcheck
# packaging/.shellcheckrc lets the scripts' `source=../env.sh` directives resolve.
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: PowerShell
shell: pwsh
run: |
$failed = $false
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) {
$errors | ForEach-Object { Write-Output "::error file=$($f.FullName),line=$($_.Extent.StartLineNumber)::$($_.Message)" }
$failed = $true
} else {
Write-Output "parsed $($f.Name)"
}
}
if ($failed) { exit 1 }
if (-not (Get-Module -ListAvailable PSScriptAnalyzer | Where-Object Version -eq $env:PSSCRIPTANALYZER_VERSION)) {
Install-Module PSScriptAnalyzer -RequiredVersion $env:PSSCRIPTANALYZER_VERSION -Scope CurrentUser -Force
}
Import-Module PSScriptAnalyzer -RequiredVersion $env:PSSCRIPTANALYZER_VERSION
$issues = Invoke-ScriptAnalyzer -Path packaging/windows -Recurse -Severity Error, Warning
$issues | ForEach-Object { Write-Output "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" }
if ($issues) { exit 1 }
- name: Property lists and desktop entry
run: |
app_id="$(sed -n 's/^APP_ID=//p' packaging/env.sh)"
test -n "$app_id"
xmllint --noout packaging/macos/*.plist*
desktop="packaging/linux/$app_id.desktop"
desktop-file-validate "$desktop"
# The window's X11 class / Wayland app_id is packaging's APP_ID (apps/fieldcraft/src/main.rs).
grep -qx "StartupWMClass=$app_id" "$desktop"
grep -qF "const APP_ID: &str = \"$app_id\";" apps/fieldcraft/src/main.rs
+84
View File
@@ -0,0 +1,84 @@
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://jyviko.github.io/fieldcraft/
# After SolidCraft's pages.yml (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `main` (a release; main holds released code only) builds the app with Trunk (release,
# wasm-opt) and deploys it. A merge to `develop` that touches code builds it without deploying, so a
# broken web build shows up before the release. A manual run builds the branch it runs on and
# deploys only from `main`. Needs Settings › Pages › Source = GitHub Actions, and `main` allowed
# by the github-pages environment's deployment branches.
name: Pages
on:
push:
branches: [main, develop]
# As ci.yml: a merge that only touches prose builds nothing.
paths:
- '**'
- '!**/*.md'
- '!docs/**'
workflow_dispatch:
permissions:
contents: read
# One deployment at a time; a newer push waits its turn instead of cancelling a deploy part way.
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
TRUNK_VERSION: v0.21.14
TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: web
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Install Trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL -o trunk.tar.gz "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
echo "${TRUNK_SHA256} trunk.tar.gz" | sha256sum -c -
tar -xzf trunk.tar.gz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the web app
working-directory: apps/fieldcraft-web
run: trunk build --release --public-url /fieldcraft/
- name: Bundle size
run: |
{
echo "### Web app bundle"
echo '| file | bytes | gzip |'
echo '|---|---:|---:|'
for f in dist/web/*.wasm dist/web/*.js; do
echo "| $(basename "$f") | $(stat -c %s "$f") | $(gzip -9 -c "$f" | wc -c) |"
done
} >> "$GITHUB_STEP_SUMMARY"
- if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/web
deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+253
View File
@@ -0,0 +1,253 @@
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `main` (fast-forwarded from a verified `develop` commit; main holds released code only)
# builds every package and creates or updates the
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt and generated notes. The
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
# pre-release. A person verifies the draft and publishes it; this workflow never publishes,
# and pushes to develop or tags never start it.
#
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
# Linux x86_64 (glibc 2.35+) .tar.gz and .AppImage packaging/linux
# Windows x64 .zip with fieldcraft.exe and fieldcraft-cli.exe packaging/windows
#
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
# the draft.
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
draft:
description: "Create/update the draft release (main only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
prerelease: ${{ steps.v.outputs.prerelease }}
draft: ${{ steps.v.outputs.draft }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve the version
env:
REQUEST_DRAFT: ${{ github.event_name == 'push' || inputs.draft }}
run: |
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1, not '$VERSION'"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/main ]; then
echo "::error::Draft releases are built from main only"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-14
timeout-minutes: 120
outputs:
signing: ${{ steps.package.outputs.signing }}
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize and package (the dmg is mounted and checked)
id: package
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove the temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
linux:
name: Linux x86_64
needs: version
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-x86_64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build and package (tar.gz, AppImage)
run: packaging/linux/package.sh
- name: Check the packages
run: |
. packaging/env.sh
list="$(tar -tzf dist/release/*.tar.gz)"
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
grep -Fx -- "fieldcraft-$VERSION-linux-x86_64/$f" <<<"$list"
done
ldd target/release/fieldcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-x86_64
path: dist/release/*
if-no-files-found: error
windows:
name: Windows x64
needs: version
runs-on: windows-latest
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-x64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build and package (zip)
shell: pwsh
run: ./packaging/windows/package.ps1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-x64
path: dist/release/*
if-no-files-found: error
release:
name: Draft release (or dry-run artifacts)
needs: [version, macos, linux, windows]
runs-on: ubuntu-24.04
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
MACOS_SIGNING: ${{ needs.macos.outputs.signing }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Check the set, checksums and notes
run: |
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
"fieldcraft-$VERSION-windows-x64.zip"; do
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
done
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
case "$MACOS_SIGNING" in
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
esac
awk -v v="$VERSION" -v m="$macos" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); print }' \
.github/release-notes.md >notes.md
cat notes.md >>"$GITHUB_STEP_SUMMARY"
- name: Upload the dry-run artifacts
if: needs.version.outputs.draft != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: |
artifacts/*
notes.md
if-no-files-found: error
- name: Create or update the draft release
if: needs.version.outputs.draft == 'true'
working-directory: artifacts
run: |
tag="v$VERSION"
title="FieldCraft v$VERSION"
if gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" --jq '.[].ref' | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# A rebuild of the same version replaces the draft's assets (removing names it no longer
# makes); its notes are kept.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" -- *
else
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes --prerelease="$PRERELEASE" -- *
fi
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`: $(gh release view "$tag" --json url --jq .url)" >>"$GITHUB_STEP_SUMMARY"
+16 -11
View File
@@ -99,7 +99,7 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
1. Work in your own worktree on a branch, with your own `CARGO_TARGET_DIR`. While you work:
`cargo check` and the tests of the crates you touch (`cargo test -p <crate>`). Commit after
each working step (a layer that compiles with its tests passing), not one big commit per PR.
Every commit lands on `main` as it is, so each one must build and pass its tests and say
Every commit lands on `develop` as it is, so each one must build and pass its tests and say
what it does: `git bisect` walks them one by one.
2. When the change is done: review it once with `/simplify`, then `cargo fmt --all` and
`cargo clippy -p <crate> --all-targets -- -D warnings` for each crate you touched, once.
@@ -108,29 +108,34 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
oracle cases or solver numerics change (`cargo xtask oracle`).
3. Fold every fix into the commit it fixes, never a separate "fmt", "clippy", "review fixes" or
"regenerate features.md" commit: `git commit --fixup <sha>`, then
`git rebase -i --autosquash origin/main` (`GIT_SEQUENCE_EDITOR=true` keeps it
`git rebase -i --autosquash origin/develop` (`GIT_SEQUENCE_EDITOR=true` keeps it
non-interactive). A regenerated page goes into the commit that changed what it lists.
4. Rebase onto the latest `origin/main` (never merge `main` into a branch), rerun the tests of
the touched crates, push, open the PR and merge it yourself with a rebase merge, which keeps
`main` linear and every commit bisectable: `gh pr merge <N> --rebase --delete-branch`, run as
its own command. Never squash. Then remove your worktree and delete your local branch.
4. Branch from and rebase onto the latest `origin/develop`, the trunk (never merge `develop` into
a branch), rerun the tests of
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `develop`
linear and every commit bisectable. `develop` and `main` are protected (CODEOWNERS review, linear history,
rebase merges only); the core team merges as the maintainer with
`gh pr merge <N> --rebase --admin --delete-branch`, run as its own command. Outside
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
Then remove your worktree and delete your local branch.
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
oracle, the heavy tests and the advisories once per merge on `main`; the coordinator
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
watches it and sends a failure back to the change that caused it, which is fixed forward
at once.
- **Keep the main gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
- **Keep the trunk gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
(convergence studies, the whole corpus, cross-checks between solvers) is
`#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative
test left in the main gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
test, benchmarks included, and the doc tests) on every merge to `main`, nightly and on
test left in the trunk gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
test, benchmarks included, and the doc tests) on every merge to `develop`, nightly and on
demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
test.
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
as the workflow above describes; the coordinator reads every merged diff and sends findings
back as fix-forward work. Never force-push `main`. Never commit `plan/`, `.agent-files/`,
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
`worktrees/` or `target*/`; check `git ls-files` before pushing.
## Running
+14 -4
View File
@@ -15,6 +15,7 @@ is committed and listed here with its exports.
| File | Author | Source | Licence |
|---|---|---|---|
| docs/media/ibeam-analysis.mp4, docs/media/ibeam-analysis.gif | FieldCraft contributors | Original: the FieldCraft window captured during an MCP session, generated by `tools/demo-video/make.py` | MIT OR Apache-2.0 |
| docs/media/bracket-partition.png, docs/media/bracket-hex.png, docs/media/bracket-stress.png, docs/media/setup-symbols.png | FieldCraft contributors | Original: FieldCraft renders (headless MCP screenshots and a window capture) of corpus parts and the cantilever sample | MIT OR Apache-2.0 |
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -133,12 +134,11 @@ is committed and listed here with its exports.
| assets/icons/ui.sun.svg, assets/icons/ui.sun.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.sun.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/ui.views.svg, assets/icons/ui.views.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.views.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/ui.zoom.svg, assets/icons/ui.zoom.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.zoom.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (an L bracket with a bolt hole under a structured hexahedral mesh, on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (a hexahedral-meshed cube with a banded colour fringe on its faces on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 |
| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (the fringe cube alone; no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/layers/01-floor-dark.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor-dark.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Dark appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/layers/02-part.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/02-part.svg | FieldCraft contributors | Original bracket faces with transparent gaps at the shared edges, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/layers/03-mesh.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/03-mesh.svg | FieldCraft contributors | Original hexahedral mesh lines (O-grid around the hole) clipped to the bracket's faces, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/layers/02-part.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/02-part.svg | FieldCraft contributors | Original cube faces with a banded colour fringe and transparent gaps at the shared edges, and the hexahedral mesh lines clipped to them, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/FieldCraft.icon/icon.json | FieldCraft contributors | Icon Composer composition of the layers above (Default and Dark floor grid), written by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 |
| assets/app-icon/Assets.car | FieldCraft contributors | Apple actool compilation of assets/app-icon/FieldCraft.icon; contains original artwork only | MIT OR Apache-2.0 |
| assets/app-icon/icon.icns | FieldCraft contributors | iconutil compilation of assets/app-icon/icon.iconset | MIT OR Apache-2.0 |
@@ -153,6 +153,7 @@ is committed and listed here with its exports.
| assets/app-icon/icon.iconset/icon_256x256@2x.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| assets/app-icon/icon.iconset/icon_512x512.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| assets/app-icon/icon.iconset/icon_512x512@2x.png | FieldCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 |
| packaging/macos/README.txt, packaging/linux/io.github.jyviko.fieldcraft.desktop, packaging/macos/entitlements.plist | FieldCraft contributors | Original packaging text: the disk image's install note, the Linux desktop entry and the (empty) hardened-runtime entitlements | MIT OR Apache-2.0 |
| crates/ui-egui/fonts/Inter-SemiBold.ttf | The Inter Project Authors | https://github.com/rsms/inter release v4.1 (Inter-4.1.zip, extras/ttf/Inter-SemiBold.ttf), unmodified; licence text in crates/ui-egui/fonts/OFL.txt; the interface's caps labels (theme::LABEL_FAMILY) | SIL OFL 1.1 |
## Adapted code
@@ -170,6 +171,15 @@ is committed and listed here with its exports.
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
| crates/io/src/memory.rs (files kept in memory for the browser build) | SolidCraft `crates/io/src/vfs.rs` | MIT OR Apache-2.0 |
| .github/workflows/pages.yml | SolidCraft `.github/workflows/pages.yml` | MIT OR Apache-2.0 |
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
## Test oracles
+16
View File
@@ -0,0 +1,16 @@
# Code of conduct
FieldCraft is built in the open by people with different backgrounds and levels of experience.
Everyone taking part — in issues, pull requests, reviews and discussions — is expected to:
- be respectful and assume good intent; criticise work, never people;
- keep discussion technical, specific and on topic;
- not harass, insult, demean or discriminate against anyone, publicly or privately;
- respect the project's decisions on scope, licences and clean-room rules.
The maintainer may edit or remove comments, close threads, and block anyone who does not follow
this code. Report problems privately to the maintainer through the contact on the GitHub
profile of [@jyviko](https://github.com/jyviko).
This code is our own, written in the spirit of the
[Contributor Covenant](https://www.contributor-covenant.org/).
+80 -5
View File
@@ -1,10 +1,80 @@
# Contributing
# Contributing to FieldCraft
Thanks for helping. Read [AGENTS.md](AGENTS.md) first: it holds the rules every change follows,
whether a person or an agent writes it.
FieldCraft is one open-source modeler for structures, heat and fluids, written in pure Rust: open a
parametric SolidCraft part, mesh it (tet or all-hex), define the analysis, solve it with our own
implicit, explicit, thermal or lattice Boltzmann solvers, and look at verified results — from the
desktop app, the command line, or an AI agent over MCP.
1. Branch from `main`, keep a pull request to one topic.
2. Run `cargo xtask ci` before pushing; CI runs the same gates.
Help is welcome, whether that's a one-line fix, a verification benchmark, a file format or owning a
whole solver. This page covers how to build it, the few rules that aren't negotiable, where help is
needed most, and how to claim work. The rules every change follows, by a person or an agent, are in
[AGENTS.md](AGENTS.md).
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer.
```sh
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo test -p fieldcraft-solver # the tests of one crate
cargo xtask oracle --quick # the verification cases (docs/oracle.md)
```
`develop` is the trunk and `main` holds released code. The layers and crates are in
[docs/architecture.md](docs/architecture.md); every user-visible capability is a command in
`crates/engine/src/cmd/` ([docs/features.md](docs/features.md) lists them).
## Where help is wanted most
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
the `area:` labels for your field. In order of how much they would help users:
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
must give the same tet mesh on every platform and every run, as the solvers already do.
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
are next. Hex meshing is the centre of gravity of the project.
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
can add it as an oracle case without touching solver code.
5. **Nonlinear elements and materials**: incompatible modes at finite strain
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
propagation, turbulence — are open to anyone who wants to own one: start a thread in
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
## Before you start: claim an issue
Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
for 14 days goes back to the pool.
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
## Making the change
1. Branch from `develop` (the trunk; `main` holds released code only) and open the pull request
against `develop`. Keep it to one topic.
2. Before pushing: `cargo fmt --all`, and `cargo clippy -p <crate> --all-targets -- -D warnings`
and `cargo test -p <crate>` for the crates you touched. CI runs the full gate on your pull
request once the maintainer approves the run.
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
parameters.
4. Meshing and solver algorithms cite the paper they come from in the module docs.
@@ -12,3 +82,8 @@ whether a person or an agent writes it.
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
By contributing you agree your work is licensed under MIT OR Apache-2.0.
`develop` and `main` are protected: changes land on `develop` only through pull requests that the
maintainer has reviewed,
as rebase merges (keep each commit building and passing its tests). Be kind: see the
[code of conduct](CODE_OF_CONDUCT.md).
Generated
+190 -25
View File
@@ -425,6 +425,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -461,6 +467,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -694,6 +709,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -749,6 +770,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -807,6 +837,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -849,8 +888,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@@ -1035,6 +1085,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1173,10 +1237,11 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fieldcraft"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"eframe",
"egui",
"embed-resource",
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-ui-egui",
@@ -1187,7 +1252,7 @@ dependencies = [
[[package]]
name = "fieldcraft-cli"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
@@ -1197,7 +1262,7 @@ dependencies = [
[[package]]
name = "fieldcraft-decks"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"serde_json",
@@ -1206,7 +1271,7 @@ dependencies = [
[[package]]
name = "fieldcraft-engine"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-decks",
"fieldcraft-explicit",
@@ -1228,7 +1293,7 @@ dependencies = [
[[package]]
name = "fieldcraft-explicit"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-fem",
"fieldcraft-model",
@@ -1239,7 +1304,7 @@ dependencies = [
[[package]]
name = "fieldcraft-fem"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"thiserror 2.0.21",
@@ -1247,7 +1312,7 @@ dependencies = [
[[package]]
name = "fieldcraft-geometry"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"quick-xml",
@@ -1263,20 +1328,20 @@ dependencies = [
[[package]]
name = "fieldcraft-io"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"log",
"serde",
"serde_json",
"sha2",
"sha2 0.11.1",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "fieldcraft-lbm"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-mesh",
"fieldcraft-model",
@@ -1286,7 +1351,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mcp"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
@@ -1297,7 +1362,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mesh"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"num-bigint",
@@ -1309,7 +1374,7 @@ dependencies = [
[[package]]
name = "fieldcraft-model"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"serde",
"serde_json",
@@ -1318,14 +1383,14 @@ dependencies = [
[[package]]
name = "fieldcraft-parallel"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"rayon",
]
[[package]]
name = "fieldcraft-render"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"png",
@@ -1335,9 +1400,10 @@ dependencies = [
[[package]]
name = "fieldcraft-solver"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-fem",
"fieldcraft-mesh",
"fieldcraft-model",
"fieldcraft-parallel",
"serde",
@@ -1346,7 +1412,7 @@ dependencies = [
[[package]]
name = "fieldcraft-testkit"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-geometry",
@@ -1357,7 +1423,7 @@ dependencies = [
[[package]]
name = "fieldcraft-ui-egui"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"craft-ui",
"egui",
@@ -1374,6 +1440,25 @@ dependencies = [
"serde_json",
]
[[package]]
name = "fieldcraft-web"
version = "0.5.5"
dependencies = [
"base64",
"eframe",
"egui",
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-ui-egui",
"js-sys",
"log",
"rfd",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1734,6 +1819,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3206,6 +3300,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3213,8 +3316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3381,7 +3495,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-sketch",
@@ -3423,7 +3537,7 @@ dependencies = [
"quick-xml",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -3658,6 +3772,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -3688,6 +3817,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -3981,6 +4116,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4750,6 +4905,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@@ -4821,7 +4986,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"serde_json",
]
+16 -2
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.5.5"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -39,7 +39,7 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# content hashes and part checksums of .fieldcraft files (the version SolidCraft uses)
sha2 = "0.10"
sha2 = "0.11"
# images (PNG snapshots and screenshots)
png = "0.18"
@@ -88,6 +88,14 @@ opt-level = 1
[profile.dev.package.fieldcraft-lbm]
opt-level = 3
# Meshing and the sparse solver are compute loops too: the tet quality stage and the
# mesh-convergence oracle cases (tests/oracle) run in tests.
[profile.dev.package.fieldcraft-mesh]
opt-level = 3
[profile.dev.package.fieldcraft-solver]
opt-level = 3
# Tests and lints (`cargo xtask ci`, GitHub CI). Debug assertions are off because truck's
# debug-only checks reject valid geometry (SolidCraft builds its tests the same way).
[profile.ci]
@@ -104,6 +112,12 @@ opt-level = 2
[profile.ci.package.fieldcraft-lbm]
opt-level = 3
[profile.ci.package.fieldcraft-mesh]
opt-level = 3
[profile.ci.package.fieldcraft-solver]
opt-level = 3
[profile.release]
opt-level = 3
lto = "thin"
+51 -9
View File
@@ -6,8 +6,13 @@ explicit, thermal and lattice Boltzmann solvers, and decks for Abaqus, Nastran a
desktop app, the command line and AI agents (over MCP) drive the same commands.
**Status:** early but working end to end — open a part, mesh it, define the analysis, solve,
see the results. Hex meshing is the centre of gravity; the next large step is the nonlinear
implicit solver that contact, elastomers and composites need. See [ROADMAP.md](ROADMAP.md).
see the results — with a nonlinear implicit solver (large deformation, plasticity, rubber) now
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
**Want to help?** Pick an issue labelled
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
[CONTRIBUTING.md](CONTRIBUTING.md).
![An AI agent analyses an I-beam cantilever in FieldCraft over MCP: open, hex mesh, steel, clamp and tip load, solve, von Mises contours, the deformed shape and an orbit round it](docs/media/ibeam-analysis.gif)
@@ -17,34 +22,70 @@ solves, and shows the von Mises stress and the deformed shape (tip deflection 0.
theory with shear 0.177 mm).
[MP4](docs/media/ibeam-analysis.mp4) · regenerated by [tools/demo-video](tools/demo-video/README.md).
## Download
[![Download for macOS](https://img.shields.io/badge/macOS-Download-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Linux](https://img.shields.io/badge/Linux-Download-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/jyviko/fieldcraft/releases/latest)
- **macOS:** open the `.dmg` and drag FieldCraft into Applications (Apple silicon and Intel).
- **Windows:** unzip and run `fieldcraft.exe`.
- **Linux:** the `.AppImage` (allow it to run as a program), or the `.tar.gz`.
- **Command line:** `fieldcraft-cli` ships in every package; AI agents connect to it over MCP
([docs/mcp.md](docs/mcp.md)).
Every release lists its checksums in `SHA256SUMS.txt`. Building from source is in
[CONTRIBUTING.md](CONTRIBUTING.md#get-it-running-about-10-minutes-on-a-fast-machine).
## Hex meshing a part that does not sweep
A filleted L-bracket with a hole through each leg cannot be swept in one piece. Split into 30
cells in SolidCraft, FieldCraft meshes it all-hex as one conforming mesh — O-grids round the holes,
graded through the fillet — and its hex20 tip deflection matches a fine tet10 solution to 0.1 %.
The same part also meshes all-hex automatically with the octree mesher (`mesh.hex.octree`).
| Cells | Conforming hex mesh | Von Mises (clamp and load drawn) |
|---|---|---|
| ![The bracket split into 30 coloured cells](docs/media/bracket-partition.png) | ![The all-hex mesh with O-grids round the holes](docs/media/bracket-hex.png) | ![Von Mises stress peaking at the hole, with the clamp cones and load arrows](docs/media/bracket-stress.png) |
## What it does today
- **Geometry in:** SolidCraft designs (`.solidcraft`) with persistent face, edge and body names,
and face-tagged 3MF meshes; surface checks (weld, manifold, orientation, self-intersection).
- **Meshing:** constrained Delaunay tetrahedra (tet4, tet10) with exact predicates and a
certified validity gate; structured hex by sweeping, revolving and mapping (hex8, hex20; 16 of
the 21 corpus parts mesh all-hex); 2D quad and tri meshes; quality metrics per element and set.
certified validity gate; Delaunay refinement, smoothing and sliver removal; structured hex by
sweeping, revolving and mapping (hex8, hex20), and conforming multi-block hex across a part
split into cells (`mesh.hex.blocks`); unstructured octree all-hex for any part
(`mesh.hex.octree`); 2D quad and tri meshes; quality metrics per element and set.
- **Elements:** solids (tet4, tet10, wedge6, hex8 with incompatible modes, hex20 full and
reduced, hybrid u–p for near-incompressible materials), plane stress, plane strain and
axisymmetric, trusses, Timoshenko beams, MITC3/MITC4 shells, springs, dashpots and point masses.
- **Model:** sets bound to geometry names, so they survive remeshing; isotropic and orthotropic
elasticity, J2 plasticity, Johnson–Cook damage, thermal properties; fixed, displacement,
elasticity, J2 plasticity (isotropic and kinematic hardening, Johnson–Cook), Johnson–Cook
damage, neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, thermal properties; fixed, displacement,
symmetry and temperature conditions; forces, moments, pressure, gravity, centrifugal and heat
loads; reference points with kinematic and distributing couplings; undo, redo and a
text-diffable `.fieldcraft` file.
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); steady and
- **Solvers:** linear static and frequency (supernodal Cholesky, block Lanczos); nonlinear static
by Newton–Raphson with automatic increments — large deformation (total Lagrangian) on every
solid element, follower pressure, finite- and small-strain plasticity sharing one return map
with the explicit solver, rubber and thermal strain, with measured quadratic convergence; steady and
transient heat transfer and coupled thermal stress; explicit dynamics with plasticity, damage,
erosion and rigid walls; D3Q19 lattice Boltzmann flow with forces on the part's faces. Results
never depend on the thread count.
- **Decks:** Abaqus, Nastran and LS-DYNA writers, each refusing by name what it cannot express.
- **Results:** contours of stress, strain and displacement with a legend, deformed shapes, mode
shapes, explicit frames, probes and reaction sums; the analysis setup drawn in the viewport.
shapes, explicit and nonlinear frames, probes and reaction sums; supports, loads, reference
points and couplings drawn in the viewport.
- **Verification:** every capability is checked against closed forms and published benchmarks
(NAFEMS, MacNeal–Harder, Scordelis–Lo) in [`tests/oracle`](tests/oracle), with results frozen
so they cannot drift: [docs/oracle.md](docs/oracle.md).
(NAFEMS, MacNeal–Harder, Scordelis–Lo, the elastica, Hill's plastic cylinder, Rivlin's tube) in [`tests/oracle`](tests/oracle), with results frozen
so they cannot drift: 170 of 176 quantities pass in 50 cases, the 6 others documented known
failures ([docs/oracle.md](docs/oracle.md)).
- **Driving it:** a desktop app in the Craft family's layout, a CLI, a JSON control channel and an
MCP server; every capability is a command ([docs/features.md](docs/features.md)).
![The FieldCraft window: a clamped cantilever with its supports, pressure, gravity and a coupled reference point drawn in the viewport](docs/media/setup-symbols.png)
## Build
```sh
@@ -65,6 +106,7 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
- [Libraries and licences](docs/adr/0001-libraries.md)
- [AI agents (MCP)](docs/mcp.md): `claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp`
- [Control protocol](docs/control-protocol.md): driving the desktop app
- [Releasing](docs/releasing.md): how a release is built, verified and published
- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md)
## License
+8 -8
View File
@@ -28,11 +28,11 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|---|---|---|
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, few-ulp rounding repair checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress |
| M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); sweepable cells in a conforming mesh and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection with a bounding-volume search shared with explicit contact; friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | planned |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
@@ -41,9 +41,9 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| 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). Next: J2 plasticity and Mooney–Rivlin on the interface, thermal strain, incompatible modes (EAS), shells and beams, arc-length) | started |
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. Next: incompatible modes (EAS), kinematic hardening at finite strain, shells and beams, linear buckling, arc-length with an indefinite factorization) | started |
| M16 Structural dynamics | Implicit transient (Newmark, HHT-α, generalised-α); modal superposition transient; steady-state harmonic (direct and modal), with Rayleigh, modal, structural and material damping; random vibration from PSDs and response spectrum (SRSS, CQC); complex eigenvalues with damping; rotating machinery (Campbell diagrams, gyroscopic terms); substructures (Craig–Bampton) | planned |
| M17 Constitutive library and material library | Elastic: transversely isotropic, fully anisotropic (21 constants), temperature-dependent. Elastomers: hyperelastic neo-Hookean, Mooney–Rivlin, Yeoh, Ogden, Arruda–Boyce, Gent, Marlow, with Mullins softening and finite-strain viscoelasticity (Prony series; parallel networks after Bergström–Boyce), foams. Viscoelasticity: small- and finite-strain Prony series, time–temperature superposition (WLF, Arrhenius), storage and loss moduli for harmonic steps. Viscoplasticity and creep: Norton power law, time and strain hardening, Perzyna, Anand, Chaboche combined hardening. Plasticity: Hill anisotropic, Drucker–Prager, Mohr–Coulomb, Cam-clay, crushable foam, concrete damaged plasticity, Gurson. Fluids: Newtonian and non-Newtonian viscosity shared with M12. Material library: curated, cited data sets (steels, aluminium and titanium alloys, polymers, elastomers, composites, tissues) with units and temperature dependence; calibration from test data (uniaxial, biaxial, shear, relaxation, DMA) by least squares with Drucker stability checks; user materials as a safe Rust trait | planned |
| M17 Constitutive library and material library | Elastic: transversely isotropic, fully anisotropic (21 constants), temperature-dependent. Elastomers: hyperelastic neo-Hookean, Mooney–Rivlin, Yeoh (these three done, in nonlinear steps), Ogden, Arruda–Boyce, Gent, Marlow, with Mullins softening and finite-strain viscoelasticity (Prony series; parallel networks after Bergström–Boyce), foams. Viscoelasticity: small- and finite-strain Prony series, time–temperature superposition (WLF, Arrhenius), storage and loss moduli for harmonic steps. Viscoplasticity and creep: Norton power law, time and strain hardening, Perzyna, Anand, Chaboche combined hardening. Plasticity: Hill anisotropic, Drucker–Prager, Mohr–Coulomb, Cam-clay, crushable foam, concrete damaged plasticity, Gurson. Fluids: Newtonian and non-Newtonian viscosity shared with M12. Material library: curated, cited data sets (steels, aluminium and titanium alloys, polymers, elastomers, composites, tissues) with units and temperature dependence; calibration from test data (uniaxial, biaxial, shear, relaxation, DMA) by least squares with Drucker stability checks; user materials as a safe Rust trait | planned |
| M18 Composites and anisotropy | Material orientations (rectangular, cylindrical, spherical, from geometry edges and faces, per-element fields, draping of fibres over curved parts); layups on shells and continuum shells, ply stacks on swept hex; classical lamination theory checks; ply stresses and strains through the thickness; failure indices (Tsai–Wu, Tsai–Hill, Hashin, Puck, LaRC) and progressive damage; homogenisation (rule of mixtures, Mori–Tanaka, RVEs with periodic boundary conditions); delamination through M20's cohesive zones. Verified by laminate closed forms and the NAFEMS composite benchmarks | planned |
| M19 Coupled fields | On the DOF framework, each a degree of freedom beside the displacements: piezoelectricity (electric potential, d/e coupling and permittivity matrices, voltage and charge conditions, resonance and impedance through M16 harmonic steps); poroelasticity (Biot, u–p, Darcy flow, consolidation; Terzaghi and Mandel verification; soils and biphasic tissue); electro-thermal (Joule heating) and thermo-electro-mechanical coupling; acoustics (Helmholtz elements, structure–acoustic coupling, absorbing boundaries); low-frequency electromagnetics later | planned |
| M20 Damage, fracture, fatigue and adhesion | Continuum damage beyond Johnson–Cook (Lemaitre, ductile and shear criteria) for implicit and explicit; fracture mechanics (J-integral and stress intensity factors, crack-front meshes from the hex mesher; XFEM later); fatigue (S–N and ε–N with mean-stress corrections, rainflow counting, Paris crack growth); adhesion and debonding by cohesive zones (bilinear and exponential traction–separation, mixed mode with Benzeggagh–Kenane) as cohesive elements and cohesive contact; adhesive joints. Verified by DCB, ENF and MMB delamination tests and published fatigue data | planned |
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
## Next
@@ -60,7 +60,7 @@ In progress, in this order of landing:
1. Tet quality: sliver repair and Delaunay refinement to a size field, so curved and filleted parts
(the filleted bracket) solve on tets (M2).
2. Octree all-hex for any part (M6), the project's main meshing goal.
2. Octree all-hex (M6): concave transitions (Schneiders' modified templates or Livesu's dual schemes) so curved and thin-walled parts mesh without filling their hull with fine cells.
3. The analysis video regenerated with the setup's symbols (done: support, load, reference-point
and coupling symbols in the viewport and headless renders), captions and an orbit (screenshot
camera and the ribbon following the command: done).
@@ -76,9 +76,9 @@ What exists and what is missing, by area; each gap is in a milestone above.
| Area | Today | Missing |
|---|---|---|
| Solution procedures | Linear static, nonlinear static (solids with geometric nonlinearity), frequency (Lanczos), steady and transient heat, coupled temperature–displacement, explicit dynamics, LBM flow | Nonlinear shells and beams, plasticity in implicit steps, arc-length, buckling (M15); implicit, harmonic, random and spectrum dynamics (M16) |
| Solution procedures | Linear static, nonlinear static (solids with geometric and material nonlinearity: hyperelasticity, J2 plasticity), frequency (Lanczos), steady and transient heat, coupled temperature–displacement, explicit dynamics, LBM flow | Nonlinear shells and beams, arc-length, buckling (M15); implicit, harmonic, random and spectrum dynamics (M16) |
| Contact, friction, adhesion | Explicit planar rigid walls (frictionless); couplings, springs, dashpots | Implicit and explicit deformable contact, tie, friction laws, thermal contact, wear (M7); cohesive adhesion and debonding (M20) |
| Materials | Isotropic and orthotropic elasticity, density, thermal, J2 plasticity (bilinear, table, Johnson–Cook), Johnson–Cook damage and erosion, compressible neo-Hookean hyperelasticity, Newtonian fluids | Elastomers beyond neo-Hookean (Mooney–Rivlin and the rest), viscoelasticity, viscoplasticity and creep, anisotropic and pressure-dependent plasticity, non-Newtonian viscosity, a cited material library and calibration (M17) |
| Materials | Isotropic and orthotropic elasticity, density, thermal, J2 plasticity (bilinear, table, Johnson–Cook), Johnson–Cook damage and erosion, compressible neo-Hookean, Mooney–Rivlin and Yeoh hyperelasticity, Newtonian fluids | Elastomers beyond Yeoh (Ogden, Arruda–Boyce and the rest), viscoelasticity, viscoplasticity and creep, anisotropic and pressure-dependent plasticity, non-Newtonian viscosity, a cited material library and calibration (M17) |
| Composites and anisotropy | Orthotropic constants | Orientations, layups, ply results, failure criteria, homogenisation (M18) |
| Coupled fields | Thermal–structural (one way and coupled) | Piezoelectric, poroelastic, electro-thermal, acoustics (M19) |
| Failure | Johnson–Cook damage and element erosion (explicit) | Fracture mechanics, fatigue, cohesive zones, composite progressive damage (M18, M20) |
+230 -10
View File
@@ -1,6 +1,6 @@
Third-party software in FieldCraft
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 444 crates below.
FieldCraft (the desktop app, fieldcraft-cli and the web app) is built from the 452 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
FieldCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -41,11 +41,13 @@ atomic-waker 1.1.2 (Apache-2.0 OR MIT)
atspi 0.29.0 (Apache-2.0 OR MIT)
atspi-common 0.13.0 (Apache-2.0 OR MIT)
atspi-proxies 0.13.0 (Apache-2.0 OR MIT)
base64 0.22.1 (MIT OR Apache-2.0)
bit-set 0.10.0 (Apache-2.0 OR MIT)
bit-vec 0.9.1 (Apache-2.0 OR MIT)
bitflags 1.3.2 (MIT/Apache-2.0)
bitflags 2.13.2 (MIT OR Apache-2.0)
block-buffer 0.10.4 (MIT OR Apache-2.0)
block-buffer 0.12.1 (MIT OR Apache-2.0)
block2 0.5.1 (MIT)
block2 0.6.2 (MIT)
blocking 1.7.0 (Apache-2.0 OR MIT)
@@ -68,12 +70,14 @@ codespan-reporting 0.13.1 (Apache-2.0)
color 0.3.3 (Apache-2.0 OR MIT)
combine 4.6.8 (MIT)
concurrent-queue 2.5.0 (Apache-2.0 OR MIT)
const-oid 0.10.2 (Apache-2.0 OR MIT)
convert_case 0.4.0 (MIT)
core-foundation 0.9.4 (MIT OR Apache-2.0)
core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
core-graphics 0.23.2 (MIT OR Apache-2.0)
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
cpufeatures 0.2.17 (MIT OR Apache-2.0)
cpufeatures 0.3.1 (MIT OR Apache-2.0)
craft-ui 0.1.0 (MIT OR Apache-2.0)
crc32fast 1.5.2 (MIT OR Apache-2.0)
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
@@ -81,11 +85,13 @@ crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
crossbeam-utils 0.8.23 (MIT OR Apache-2.0)
crunchy 0.2.4 (MIT)
crypto-common 0.1.7 (MIT OR Apache-2.0)
crypto-common 0.2.2 (MIT OR Apache-2.0)
cursor-icon 1.2.0 (MIT OR Apache-2.0 OR Zlib)
data-url 0.3.2 (MIT OR Apache-2.0)
derive_arbitrary 1.5.0 (MIT OR Apache-2.0)
derive_more 0.99.20 (MIT)
digest 0.10.7 (MIT OR Apache-2.0)
digest 0.11.3 (MIT OR Apache-2.0)
dispatch 0.2.0 (MIT)
dispatch2 0.3.1 (Zlib OR Apache-2.0 OR MIT)
dlib 0.5.3 (MIT)
@@ -148,6 +154,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0)
hashbrown 0.17.1 (MIT OR Apache-2.0)
hermit-abi 0.5.3 (MIT OR Apache-2.0)
hex 0.4.3 (MIT OR Apache-2.0)
hybrid-array 0.4.15 (MIT OR Apache-2.0)
iana-time-zone 0.1.65 (MIT OR Apache-2.0)
iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0)
image 0.25.10 (MIT OR Apache-2.0)
@@ -290,6 +297,7 @@ serde_derive 1.0.229 (MIT OR Apache-2.0)
serde_json 1.0.154 (MIT OR Apache-2.0)
serde_repr 0.1.21 (MIT OR Apache-2.0)
sha2 0.10.9 (MIT OR Apache-2.0)
sha2 0.11.1 (MIT OR Apache-2.0)
signal-hook-registry 1.4.8 (MIT OR Apache-2.0)
simd-adler32 0.3.10 (MIT)
simd_cesu8 1.2.0 (Apache-2.0 OR MIT)
@@ -746,14 +754,14 @@ DEALINGS IN THE SOFTWARE.
================================================================================
ahash 0.8.12, arbitrary 1.5.0, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-io
2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14, async-task
4.7.1, atomic-waker 1.1.2, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo 3.20.3, cfg-if
1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys 0.8.7,
core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21,
crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent 1.0.2,
errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0,
flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1, hashbrown
0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
4.7.1, atomic-waker 1.1.2, base64 0.22.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.9.4, core-foundation-sys
0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, crossbeam-deque 0.8.8, crossbeam-epoch
0.9.21, crossbeam-utils 0.8.23, data-url 0.3.2, derive_arbitrary 1.5.0, either 1.19.0, equivalent
1.0.2, errno 0.3.14, euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand
2.5.0, flate2 1.1.10, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, hashbrown 0.16.1,
hashbrown 0.17.1, hermit-abi 0.5.3, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
0.3.106, khronos-egl 6.0.0, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
num-complex 0.4.6, num-traits 0.2.19, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png
0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
@@ -2219,6 +2227,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
base64 0.22.1
================================================================================
The MIT License (MIT)
Copyright (c) 2015 Alice Maz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
bit-set 0.10.0
================================================================================
@@ -2546,7 +2580,9 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
block-buffer 0.10.4, cpufeatures 0.2.17, crypto-common 0.1.7, digest 0.10.7, sha2 0.10.9
block-buffer 0.10.4, block-buffer 0.12.1, const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1,
crypto-common 0.1.7, crypto-common 0.2.2, digest 0.10.7, digest 0.11.3, hybrid-array 0.4.15, sha2
0.10.9, sha2 0.11.1
================================================================================
Apache License
@@ -2751,6 +2787,36 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
block-buffer 0.12.1
================================================================================
Copyright (c) 2018-2025 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
branches 0.3.0, rclite 0.2.8
================================================================================
@@ -3379,6 +3445,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
const-oid 0.10.2, cpufeatures 0.3.1
================================================================================
Copyright (c) 2020-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpufeatures 0.2.17
================================================================================
@@ -3731,6 +3827,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
crypto-common 0.2.2
================================================================================
Copyright (c) 2021-2026 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cursor-icon 1.2.0
================================================================================
@@ -4063,6 +4189,37 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
digest 0.11.3
================================================================================
Copyright (c) 2017-2025 RustCrypto Developers
Copyright (c) 2017 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
dlib 0.5.3
================================================================================
@@ -6093,6 +6250,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
hybrid-array 0.4.15
================================================================================
Copyright (c) 2022-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2
================================================================================
@@ -9890,6 +10077,39 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
signal-hook-registry 1.4.8
================================================================================
+6
View File
@@ -2,6 +2,7 @@
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli --version
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -26,6 +27,7 @@ usage:
--automation-read-root DIR files may be read only inside DIR
--automation-write-root DIR files may be written only inside DIR (with either root,
the access a root left out is not granted)
fieldcraft-cli --version print the version
";
/// Largest script accepted (bytes).
@@ -46,6 +48,10 @@ fn main() -> ExitCode {
}
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
+12
View File
@@ -0,0 +1,12 @@
//! `fieldcraft-cli --version`, which the release packaging runs to check each build.
use std::process::Command;
#[test]
fn version_prints_the_workspace_version() {
for flag in ["--version", "-V"] {
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).arg(flag).output().unwrap();
assert!(out.status.success(), "{flag}: {out:?}");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), format!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION")));
}
}
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "fieldcraft-web"
description = "FieldCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "fieldcraft-web"
path = "src/main.rs"
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-ui-egui = { workspace = true }
fieldcraft-io = { workspace = true }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
# `file-handle-inner`: a picked file's size, checked before it is read.
rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage"] }
# Autosaves kept in the browser's local storage, which holds text.
base64 = "0.22"
+10
View File
@@ -0,0 +1,10 @@
# Build with: trunk build --release (from apps/fieldcraft-web; docs/web.md)
[build]
target = "index.html"
dist = "../../dist/web"
public_url = "./"
filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8790
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>FieldCraft</title>
<meta name="description" content="Open-source CAE modeling in Rust: mesh a part, set up the analysis and solve it, in your browser.">
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
<link rel="apple-touch-icon" href="icons/icon_128x128@2x.png">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
#fieldcraft_loading img { width: 96px; height: 96px; }
#fieldcraft_loading p { margin: 0; max-width: 32em; }
#fieldcraft_loading.failed { pointer-events: auto; }
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
</style>
</head>
<body>
<canvas id="fieldcraft_canvas"></canvas>
<div id="fieldcraft_loading">
<img src="icons/icon_128x128@2x.png" alt="">
<div class="spinner"></div>
<p>Loading FieldCraft&hellip;</p>
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
</div>
<script>
// The page could not load or start the app (no WebAssembly, a failed download): say so
// instead of loading forever. Once the app runs it removes the loading screen.
(function () {
function fail(why) {
var el = document.getElementById("fieldcraft_loading");
if (!el || el.classList.contains("failed")) return;
el.classList.add("failed");
el.textContent = "";
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
var p = document.createElement("p");
p.textContent = t;
el.appendChild(p);
});
}
if (typeof WebAssembly !== "object") fail("this browser has no WebAssembly.");
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
})();
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
//! FieldCraft in the browser.
//!
//! Runs the same [`fieldcraft_ui_egui::ModelApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`); docs/web.md says what works in the browser.
//!
//! Differences from the desktop app, all behind the UI's host services:
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
//! a download;
//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
//! - no control channel (a page can't listen on a socket);
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
//! that they run.
//!
//! Address flags: `?sample` or `?sample=cantilever` opens a sample, `?webgl` forces WebGL2.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod storage;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("fieldcraft-web only runs in the browser: build it with `trunk build --release` in apps/fieldcraft-web (docs/web.md)");
}
/// The sample an address asks for (`?sample` opens the first sample, `?sample=NAME` that one).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn sample_in_query(query: &str) -> Option<String> {
let first = fieldcraft_engine::sample::SAMPLES.first().map_or("", |s| s.name);
query.trim_start_matches('?').split('&').find_map(|kv| {
let (key, value) = kv.split_once('=').unwrap_or((kv, ""));
(key == "sample").then(|| if value.is_empty() { first } else { value }.to_string())
})
}
/// The name a file from the computer is kept under in the page: its last path part, without
/// characters a path would read as folders.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn safe_name(name: &str) -> String {
let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
let clean: String = last.chars().filter(|c| !c.is_control()).take(200).collect();
if clean.is_empty() || clean == "." || clean == ".." { "file".to_string() } else { clean }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn samples_from_the_address() {
assert_eq!(sample_in_query(""), None);
assert_eq!(sample_in_query("?sample").as_deref(), Some("parts"));
assert_eq!(sample_in_query("?webgl&sample=cantilever").as_deref(), Some("cantilever"));
assert_eq!(sample_in_query("?sample=").as_deref(), Some("parts"));
assert_eq!(sample_in_query("?samples=x"), None);
}
#[test]
fn file_names_stay_in_their_folder() {
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
assert_eq!(safe_name("../../etc/passwd"), "passwd");
assert_eq!(safe_name("C:\\work\\plate.solidcraft"), "plate.solidcraft");
assert_eq!(safe_name(".."), "file");
assert_eq!(safe_name(""), "file");
assert_eq!(safe_name("a\u{0}b.3mf"), "ab.3mf");
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Autosaves kept in the browser.
//!
//! The engine's recovery folder is [`fieldcraft_io::recovery::BROWSER_DIR`] among the page's
//! files, which last only as long as the page. This mirrors it to the browser's local storage
//! (kept across visits, shared by the site's tabs): each file as base64 under
//! [`PREFIX`]`<file name>`. At start the stored files go back into the folder, so the desktop's
//! recovery flow (File › Recover…, `file.recovery.*`) offers what a closed or crashed page left.
//!
//! Local storage holds a few megabytes per site: an autosave larger than [`MAX_AUTOSAVE_BYTES`]
//! (or one the browser refuses) is not kept, and the status bar says so.
use std::collections::BTreeSet;
use std::path::Path;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use fieldcraft_io::recovery::BROWSER_DIR;
const PREFIX: &str = "fieldcraft-recovery/";
/// Largest stored file taken back (local storage itself holds less).
const MAX_STORED_CHARS: usize = 64 << 20;
/// Largest autosave kept: base64 makes it a third larger, and browsers give a site about 5 MB.
pub const MAX_AUTOSAVE_BYTES: usize = 3 << 20;
fn local_storage() -> Result<web_sys::Storage, String> {
web_sys::window().ok_or("no window")?.local_storage().map_err(crate::web::js_err)?.ok_or_else(|| "browser storage is turned off".into())
}
/// The mirror: the stored files this page knows of (took back or wrote), by file name.
#[derive(Default)]
pub struct Store {
known: BTreeSet<String>,
}
pub fn dir() -> &'static Path {
Path::new(BROWSER_DIR)
}
/// A file name of the recovery folder that is safe as a key (the engine's ids and extensions).
fn valid(name: &str) -> bool {
!name.is_empty() && name.len() <= 100 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') && !name.starts_with('.')
}
impl Store {
/// Put the stored files back into the recovery folder.
pub fn load() -> Store {
let mut store = Store::default();
let s = match local_storage() {
Ok(s) => s,
Err(e) => {
log::warn!("autosaves: {e}");
return store;
}
};
let keys: Vec<String> = (0..s.length().unwrap_or(0)).filter_map(|i| s.key(i).ok().flatten()).collect();
for key in keys {
let Some(name) = key.strip_prefix(PREFIX).filter(|n| valid(n)) else { continue };
let Some(text) = s.get_item(&key).ok().flatten().filter(|t| t.len() <= MAX_STORED_CHARS) else { continue };
let Ok(bytes) = STANDARD.decode(text.as_bytes()).map_err(|e| log::warn!("autosave {name} is damaged: {e}")) else { continue };
match fieldcraft_io::fs::insert(&dir().join(name), bytes) {
Ok(()) => {
store.known.insert(name.to_string());
}
Err(e) => log::warn!("autosave {name}: {e}"),
}
}
store
}
/// Keep a file the engine wrote in the recovery folder.
pub fn put(&mut self, path: &Path, bytes: &[u8]) -> Result<(), String> {
let name =
path.file_name().and_then(|n| n.to_str()).filter(|n| valid(n)).ok_or_else(|| format!("{}: not a recovery file", path.display()))?;
if bytes.len() > MAX_AUTOSAVE_BYTES {
return Err(format!("the model ({} MB) is too large for the browser's storage", bytes.len() >> 20));
}
self.known.insert(name.to_string());
local_storage()?
.set_item(&format!("{PREFIX}{name}"), &STANDARD.encode(bytes))
.map_err(|e| format!("browser storage is full or turned off ({})", crate::web::js_err(e)))
}
/// Drop the stored files whose entries left the recovery folder (cleared once saved,
/// recovered and discarded). Only files this page knows of: another tab's stay.
pub fn sync_removals(&mut self) {
let present: BTreeSet<String> =
fieldcraft_io::fs::list(dir()).iter().filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)).collect();
let gone: Vec<String> = self.known.difference(&present).cloned().collect();
if gone.is_empty() {
return;
}
let Ok(s) = local_storage() else { return };
for name in gone {
if s.remove_item(&format!("{PREFIX}{name}")).is_ok() {
self.known.remove(&name);
}
}
}
}
+326
View File
@@ -0,0 +1,326 @@
//! The browser shell: the web services of the UI (file pickers, downloads), dropped files, the
//! autosave, and eframe's web runner.
//!
//! Structure follows VectorCraft's `apps/vectorcraft-web/src/web.rs` and SolidCraft's
//! `apps/solidcraft-web/src/main.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::rc::Rc;
use fieldcraft_engine::Session;
use fieldcraft_engine::autosave::Autosaver;
use fieldcraft_ui_egui::files::Kind;
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
use wasm_bindgen::JsCast as _;
use crate::storage::{self, Store};
const CANVAS_ID: &str = "fieldcraft_canvas";
const LOADING_ID: &str = "fieldcraft_loading";
const DEPTH_BITS: u8 = 24;
/// The page's folder for files from the computer and the files commands write.
const FILES_DIR: &str = "/files";
/// How often unsaved changes are autosaved for recovery (seconds), as on the desktop.
const AUTOSAVE_SECONDS: f64 = 30.0;
/// Files read from the computer (picked together or dropped together), waiting for the next
/// frame: (name, bytes or why they could not be read) each.
type Batch = Vec<(String, Result<Vec<u8>, String>)>;
type Inbox = Rc<RefCell<Vec<Batch>>>;
/// Largest file read from the computer: what the page can hold.
const MAX_READ_BYTES: f64 = fieldcraft_io::memory::MAX_TOTAL_BYTES as f64;
/// Why a file of `size` bytes is not read, if it is too large.
fn too_large(size: f64) -> Option<String> {
(size > MAX_READ_BYTES)
.then(|| format!("{} MB is more than this page holds ({} MB)", (size / 1048576.0).round(), fieldcraft_io::memory::MAX_TOTAL_BYTES >> 20))
}
/// Hand files read from the computer to the next frame.
fn deliver(inbox: &Inbox, ctx: &egui::Context, batch: Batch) {
inbox.borrow_mut().push(batch);
ctx.request_repaint();
}
/// Where a file named `name` is kept in the page.
fn page_path(name: &str) -> String {
format!("{FILES_DIR}/{}", crate::safe_name(name))
}
thread_local! {
/// The model has unsaved changes: leaving the page asks first.
static UNSAVED: Cell<bool> = const { Cell::new(false) };
}
pub fn js_err(e: wasm_bindgen::JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn element(id: &str) -> Option<web_sys::Element> {
web_sys::window()?.document()?.get_element_by_id(id)
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
// The browser's clock for the recovery entries (the plain wasm target has none).
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
wasm_bindgen_futures::spawn_local(async move {
let result = eframe::WebRunner::new()
.start(
canvas,
web_options(),
Box::new(|cc| {
let inbox = Inbox::default();
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info();
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
}
// The autosaves a closed or crashed page left, back in the recovery folder.
let store = Store::load();
let listed = app.session.execute("file.recovery.list", &serde_json::json!({}));
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
let autosaver = Autosaver::new(storage::dir()).map_err(|e| log::warn!("autosave unavailable: {e}")).ok();
if let Some(name) = crate::sample_in_query(&query()) {
app.open_sample(&name);
}
if recoverable > 0 {
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
}
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false }))
}),
)
.await;
match result {
Ok(()) => {
if let Some(el) = element(LOADING_ID) {
el.remove();
}
}
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
}
});
}
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
fn web_options() -> eframe::WebOptions {
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
if query().split(['?', '&']).any(|kv| kv == "webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
options
}
/// Replace the loading screen with why the app could not start.
fn failed(why: &str) {
log::error!("{why}");
let Some(el) = element(LOADING_ID) else { return };
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
el.set_text_content(None);
// Best effort: the console has the message too.
let _ = el.class_list().add_1("failed");
for line in
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
{
if let Ok(p) = document.create_element("p") {
p.set_text_content(Some(line));
let _ = el.append_child(&p);
}
}
}
/// The browser's "leave site? changes you made may not be saved" prompt while the model has
/// unsaved changes (a page that is closing can't show its own dialog).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
if let Err(e) = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()) {
log::warn!("couldn't ask before leaving: {}", js_err(e));
}
// The listener lives as long as the page.
on_unload.forget();
}
/// The UI's host services in the browser.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
Services {
// The browser's file picker: the files arrive in a later frame (the picker and the reads
// are asynchronous), so the call itself picks nothing.
pick_open: Some(Box::new(move |filters: &[Filter]| {
let (inbox, ctx) = (inbox.clone(), ctx.clone());
let dialog = filters.iter().fold(rfd::AsyncFileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
wasm_bindgen_futures::spawn_local(async move {
let Some(files) = dialog.pick_files().await else { return };
let mut batch = Vec::with_capacity(files.len());
for f in files {
let read = match too_large(f.inner().size()) {
Some(why) => Err(why),
None => Ok(f.read().await),
};
batch.push((f.file_name(), read));
}
deliver(&inbox, &ctx, batch);
});
None
})),
// Saving writes into the page; the shell hands each written file out as a download.
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
// The recent list holds the page's files, which go with the page.
recent_path: None,
}
}
/// The app with the browser's files around it.
struct Shell {
app: ModelApp,
inbox: Inbox,
store: Store,
/// `None` when autosave could not start.
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
/// The last autosave did not fit in the browser's storage (said once until one fits).
storage_error: bool,
}
impl Shell {
/// Files read from the computer go into the page's folder, then open: a model with the
/// design and meshes it refers to picked or dropped with it, else each file in turn.
fn open_arrived(&mut self) {
let batches: Vec<_> = self.inbox.borrow_mut().drain(..).collect();
for batch in batches {
let mut paths = Vec::with_capacity(batch.len());
for (name, read) in batch {
let path = page_path(&name);
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
Ok(()) => paths.push(path),
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
}
}
let model = paths.iter().find(|p| Kind::of(p) == Some(Kind::Model)).cloned();
match model {
// Its geometry files open with it, from the same folder.
Some(model) => self.app.open_path(&model),
None => {
for p in &paths {
self.app.open_path(p);
}
}
}
}
}
/// What commands wrote: autosaves to the browser's storage, everything else downloaded.
fn hand_out_writes(&mut self) {
for (path, bytes) in fieldcraft_io::fs::take_writes() {
if path.parent() == Some(storage::dir()) {
match self.store.put(&path, &bytes) {
Ok(()) => self.storage_error = false,
Err(e) if !self.storage_error => {
self.storage_error = true;
self.app.set_status(format!("Autosave: {e}. Save the model to keep it (File › Save downloads it)"), true);
}
Err(e) => log::warn!("autosave: {e}"),
}
} else if let Err(e) = download(&path, &bytes) {
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
}
}
self.store.sync_removals();
}
fn autosave(&mut self, ctx: &egui::Context) {
let Some(a) = &mut self.autosaver else { return };
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
self.autosaved_at = self.app.now;
if let Err(e) = a.save(&self.app.session) {
log::warn!("autosave: {e}");
}
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS));
}
}
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.open_arrived();
self.app.logic(ctx);
self.autosave(ctx);
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
}
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
/// inbox; the app never sees their names as paths.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let dropped = std::mem::take(&mut raw.dropped_files);
if !dropped.is_empty() {
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let mut batch = Vec::with_capacity(dropped.len());
for f in dropped {
let name = f.path().to_string_lossy().to_string();
let read = match f.web_file().and_then(|w| too_large(w.size())) {
Some(why) => Err(why),
None => f.bytes_async().await,
};
batch.push((name, read));
}
deliver(&inbox, &ctx, batch);
});
}
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
// Once a frame, after everything that writes: autosaves, saves and decks.
self.hand_out_writes();
}
}
/// Hand `bytes` out as a download named after the last part of `path`.
fn download(path: &Path, bytes: &[u8]) -> Result<(), String> {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("fieldcraft").to_string();
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_err)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js_err)?.dyn_into().map_err(|_| "not a link")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js_err)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js_err)?;
a.click();
a.remove();
// Revoked once the click has been dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
// The download has started: a timer that can't be set leaves the URL allocated, no more.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
Ok(())
}
+4
View File
@@ -18,3 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[build-dependencies]
# The Windows executable's icon resource (build.rs).
embed-resource = "3.0.12"
+20
View File
@@ -0,0 +1,20 @@
//! Windows builds embed the application icon (assets/app-icon/icon.ico) in `fieldcraft.exe`, so
//! Explorer, the taskbar and shortcuts show it. Other targets need nothing here.
#![forbid(unsafe_code)]
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let icon = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?).join("../../assets/app-icon/icon.ico");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={}", icon.display());
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = PathBuf::from(std::env::var("OUT_DIR")?);
let rc = out.join("fieldcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
// Missing resource tools are a build error, rather than silently shipping a generic icon.
embed_resource::compile_for(&rc, ["fieldcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+4
View File
@@ -22,6 +22,9 @@ const DEPTH_BITS: u8 = 24;
/// The window draws its own caption buttons where the OS ones would sit apart from the app bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const MSAA: u16 = 4;
/// The reverse-DNS application id: the Wayland app_id / X11 class, matched to the packaged
/// `.desktop` entry (packaging/linux), and the macOS bundle id (packaging/macos/Info.plist.in).
const APP_ID: &str = "io.github.jyviko.fieldcraft";
/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon).
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png");
@@ -148,6 +151,7 @@ fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
.with_app_id(APP_ID)
.with_icon(app_icon())
.with_inner_size([1400.0, 900.0])
.with_min_inner_size([960.0, 600.0])
Binary file not shown.
@@ -4,15 +4,15 @@
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4f8a82"/>
<stop offset="0.3" stop-color="#4f8a82" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#4f8a82" stop-opacity="0.35"/>
<stop offset="1" stop-color="#4f8a82" stop-opacity="0"/>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4f6a86"/>
<stop offset="0.3" stop-color="#4f6a86" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#4f6a86" stop-opacity="0.35"/>
<stop offset="1" stop-color="#4f6a86" stop-opacity="0"/>
</radialGradient>
</defs>
<title>FieldCraft icon floor grid (Dark)</title>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -4,15 +4,15 @@
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7fa8a2"/>
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8aa0b4"/>
<stop offset="0.3" stop-color="#8aa0b4" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#8aa0b4" stop-opacity="0.35"/>
<stop offset="1" stop-color="#8aa0b4" stop-opacity="0"/>
</radialGradient>
</defs>
<title>FieldCraft icon floor grid</title>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,38 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="linear-gradient-1" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
</linearGradient>
<clipPath id="clip-path-1">
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z"/>
</clipPath>
<clipPath id="clip-path-2">
<path d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z"/>
</clipPath>
<clipPath id="clip-path-3">
<path d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
</clipPath>
<clipPath id="clip-path-4">
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
</clipPath>
</defs>
<title>FieldCraft icon part</title>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-1)"/>
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-2)"/>
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-3)"/>
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-4)"/>
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-5)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-6)"/>
<title>FieldCraft icon cube</title>
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-1)">
<path id="top" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#2c4fbf"/>
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
<path id="top_shade" data-name="top shade" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#ffffff" opacity="0.1"/>
</g>
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-2)">
<path id="left" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#2c4fbf"/>
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
<path id="left_shade" data-name="left shade" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#000000" opacity="0.12"/>
</g>
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-3)">
<path id="right" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#2c4fbf"/>
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
<path id="right_shade" data-name="right shade" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#000000" opacity="0.3"/>
</g>
<g id="Mesh" clip-path="url(#clip-path-4)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M576.95 237.5 L317.14 387.5 M447.05 237.5 L706.86 387.5 M317.14 687.5 L317.14 387.5 M252.19 575 L512 725 M706.86 687.5 L706.86 387.5 M771.81 575 L512 725 M641.9 275 L382.1 425 M382.1 275 L641.9 425 M382.1 725 L382.1 425 M252.19 500 L512 650 M641.9 725 L641.9 425 M771.81 500 L512 650 M706.86 312.5 L447.05 462.5 M317.14 312.5 L576.95 462.5 M447.05 762.5 L447.05 462.5 M252.19 425 L512 575 M576.95 762.5 L576.95 462.5 M771.81 425 L512 575" fill="none" stroke="#0b2236" stroke-width="3.5" stroke-linejoin="round" opacity="0.45"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<clipPath id="clip-path-1">
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
</clipPath>
</defs>
<title>FieldCraft icon mesh</title>
<g id="Mesh" clip-path="url(#clip-path-1)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

-31
View File
@@ -1,37 +1,6 @@
{
"fill": "system-light",
"groups": [
{
"layers": [
{
"glass-specializations": [
{
"appearance": "tinted",
"value": true
}
],
"image-name": "03-mesh.svg",
"name": "03-mesh",
"position": {
"scale": 0.9,
"translation-in-points": [
0,
0
]
}
}
],
"name": "03 Mesh",
"shadow": {
"kind": "none",
"opacity": 0
},
"specular": false,
"translucency": {
"enabled": false,
"value": 0.3
}
},
{
"layers": [
{
+11 -8
View File
@@ -1,10 +1,11 @@
# FieldCraft application icon
FieldCraft's mark is an L-shaped bracket with a bolt hole, drawn in isometric view and covered
by a structured hexahedral mesh with an O-grid around the hole. Its faces are separated by
FieldCraft's mark is a cube in isometric view, covered by a structured hexahedral mesh, whose
faces carry a banded colour fringe (blue to red, hottest at the front corner) as a stress or
temperature result would. Its faces are separated by
transparent gaps rather than painted edge lines, so the backdrop shows through them in every
appearance. It is original artwork, attributed in `ATTRIBUTION.md`, and shares the Craft
family's layout (rounded-square backdrop, isometric floor grid, lit top faces) in its own teal.
family's layout (rounded-square backdrop, isometric floor grid, lit top faces).
Everything here is generated by `cargo xtask app-icon` (`xtask/src/app_icon.rs`), which computes
the geometry and draws it with VectorCraft's command-line tool (`VECTORCRAFT_CLI`, else
@@ -14,14 +15,16 @@ hand. It runs on macOS (sips, iconutil, `xcrun actool`) with ImageMagick (`magic
| File | What it is |
|---|---|
| `icon.vectorcraft`, `icon.svg`, `icon.png` | The full icon on its backdrop, 1024 × 1024 |
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: no mesh or floor grid, faces edge to edge |
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: the fringe cube alone, no mesh or floor grid, faces edge to edge |
| `icon.iconset/`, `icon.icns` | macOS iconset (sips) and its iconutil compilation |
| `icon.ico` | Windows icon, 16–256 px |
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), part and mesh layers, each scaled to 90% |
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), and cube (faces and mesh) layers, each scaled to 90% |
| `layers/*.vectorcraft` | Editable sources of the Icon Composer layers |
| `Assets.car` | `actool` compilation of `FieldCraft.icon` for macOS 26 and later (needs Xcode 26 or later; the task skips it with a note otherwise) |
The desktop app embeds `icon.iconset/icon_128x128@2x.png` as its window and Dock icon
(`apps/fieldcraft/src/main.rs`). There is no macOS bundle yet; packaging will copy
`Assets.car` and `icon.icns` into `Contents/Resources` and name `FieldCraft` in
`CFBundleIconName` / `CFBundleIconFile`.
(`apps/fieldcraft/src/main.rs`). The release packages (docs/releasing.md) use the rest:
`packaging/macos/package.sh` copies `Assets.car` and `icon.icns` (as `FieldCraft.icns`) into
`FieldCraft.app/Contents/Resources`, named by `CFBundleIconName` / `CFBundleIconFile`;
`apps/fieldcraft/build.rs` embeds `icon.ico` in `fieldcraft.exe`; and
`packaging/linux/package.sh` installs the iconset PNGs as the hicolor icons.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+41 -34
View File
@@ -1,35 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d2b3b" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fbfdfd"/>
<stop offset="1" stop-color="#dbe5e4"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="385.86" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="452.64" x2="512" y2="764.28" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="200.36" x2="512" y2="638.14" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="578.78" x2="512" y2="883" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="326.5" x2="512" y2="883" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
<stop offset="0" stop-color="#fbfcfe"/>
<stop offset="1" stop-color="#dce3ec"/>
</linearGradient>
<clipPath id="clip-path-1">
<path d="M512 200 L771.81 350 L512 500 L252.19 350 Z"/>
</clipPath>
<clipPath id="clip-path-2">
<path d="M252.19 350 L512 500 L512 800 L252.19 650 Z"/>
</clipPath>
<clipPath id="clip-path-3">
<path d="M771.81 650 L512 800 L512 500 L771.81 350 Z"/>
</clipPath>
</defs>
<title>FieldCraft application icon, small sizes</title>
<g id="Backdrop">
@@ -37,12 +22,34 @@
<path id="backdrop" d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z" fill="url(#linear-gradient-1)"/>
</g>
</g>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L833.3 326.5 L730.48 385.86 L409.19 200.36 Z" fill="url(#linear-gradient-2)"/>
<path id="base_top" data-name="base top" d="M409.19 452.64 L730.48 638.14 L512 764.28 L190.7 578.78 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
<path id="wall_front" data-name="wall front" d="M409.19 200.36 L730.48 385.86 L730.48 638.14 L409.19 452.64 Z" fill="url(#linear-gradient-4)"/>
<path id="base_front" data-name="base front" d="M190.7 578.78 L512 764.28 L512 883 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
<path id="end" d="M833.3 697.5 L512 883 L512 764.28 L730.48 638.14 L730.48 385.86 L833.3 326.5 Z" fill="url(#linear-gradient-6)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-1)">
<path id="top" d="M512 200 L771.81 350 L512 500 L252.19 350 Z" fill="#2c4fbf"/>
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
<path id="top_shade" data-name="top shade" d="M512 200 L771.81 350 L512 500 L252.19 350 Z" fill="#ffffff" opacity="0.1"/>
</g>
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-2)">
<path id="left" d="M252.19 350 L512 500 L512 800 L252.19 650 Z" fill="#2c4fbf"/>
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
<path id="left_shade" data-name="left shade" d="M252.19 350 L512 500 L512 800 L252.19 650 Z" fill="#000000" opacity="0.12"/>
</g>
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-3)">
<path id="right" d="M771.81 650 L512 800 L512 500 L771.81 350 Z" fill="#2c4fbf"/>
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
<path id="right_shade" data-name="right shade" d="M771.81 650 L512 800 L512 500 L771.81 350 Z" fill="#000000" opacity="0.3"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 171 KiB

+50 -43
View File
@@ -1,46 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d2b3b" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fbfdfd"/>
<stop offset="1" stop-color="#dbe5e4"/>
<stop offset="0" stop-color="#fbfcfe"/>
<stop offset="1" stop-color="#dce3ec"/>
</linearGradient>
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7fa8a2"/>
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="560" gradientTransform="matrix(1 0 0 0.5 512 650)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8aa0b4"/>
<stop offset="0.3" stop-color="#8aa0b4" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#8aa0b4" stop-opacity="0.35"/>
<stop offset="1" stop-color="#8aa0b4" stop-opacity="0"/>
</radialGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
</linearGradient>
<clipPath id="clip-path-2">
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z"/>
</clipPath>
<clipPath id="clip-path-3">
<path d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z"/>
</clipPath>
<clipPath id="clip-path-4">
<path d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
</clipPath>
<clipPath id="clip-path-5">
<path d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z"/>
</clipPath>
</defs>
<title>FieldCraft application icon</title>
@@ -50,17 +35,39 @@
</g>
</g>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
<path id="floor_grid" data-name="floor grid" d="M165.59 100 L100 137.87 M858.41 100 L924 137.87 M295.49 100 L100 212.87 M728.51 100 L924 212.87 M425.4 100 L100 287.87 M598.6 100 L924 287.87 M555.3 100 L100 362.87 M468.7 100 L924 362.87 M685.21 100 L100 437.87 M338.79 100 L924 437.87 M815.11 100 L100 512.87 M208.89 100 L924 512.87 M924 112.13 L100 587.87 M100 112.13 L924 587.87 M924 187.13 L100 662.87 M100 187.13 L924 662.87 M924 262.13 L100 737.87 M100 262.13 L924 737.87 M924 337.13 L100 812.87 M100 337.13 L924 812.87 M924 412.13 L100 887.87 M100 412.13 L924 887.87 M924 487.13 L167.32 924 M100 487.13 L856.68 924 M924 562.13 L297.23 924 M100 562.13 L726.77 924 M924 637.13 L427.13 924 M100 637.13 L596.87 924 M924 712.13 L557.03 924 M100 712.13 L466.97 924 M924 787.13 L686.94 924 M100 787.13 L337.06 924 M924 862.13 L816.84 924 M100 862.13 L207.16 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-2)"/>
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-4)"/>
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-6)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
<g id="Top_face" data-name="Top face" clip-path="url(#clip-path-2)">
<path id="top" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#2c4fbf"/>
<path id="top_band_1" data-name="top band 1" d="M844.55 692 C660.89 798.04 363.11 798.04 179.45 692 C-4.22 585.96 -4.22 414.04 179.45 308 C363.11 201.96 660.89 201.96 844.55 308 C1028.22 414.04 1028.22 585.96 844.55 692 Z" fill="#2f86e0"/>
<path id="top_band_2" data-name="top band 2" d="M789.99 660.5 C636.46 749.14 387.54 749.14 234.01 660.5 C80.47 571.86 80.47 428.14 234.01 339.5 C387.54 250.86 636.46 250.86 789.99 339.5 C943.53 428.14 943.53 571.86 789.99 660.5 Z" fill="#22bcd8"/>
<path id="top_band_3" data-name="top band 3" d="M738.03 630.5 C613.2 702.57 410.8 702.57 285.97 630.5 C161.13 558.43 161.13 441.57 285.97 369.5 C410.8 297.43 613.2 297.43 738.03 369.5 C862.87 441.57 862.87 558.43 738.03 630.5 Z" fill="#3fc584"/>
<path id="top_band_4" data-name="top band 4" d="M686.07 600.5 C589.93 656 434.07 656 337.93 600.5 C241.79 545 241.79 455 337.93 399.5 C434.07 344 589.93 344 686.07 399.5 C782.21 455 782.21 545 686.07 600.5 Z" fill="#f0d13a"/>
<path id="top_band_5" data-name="top band 5" d="M634.11 570.5 C566.67 609.44 457.33 609.44 389.89 570.5 C322.45 531.56 322.45 468.44 389.89 429.5 C457.33 390.56 566.67 390.56 634.11 429.5 C701.55 468.44 701.55 531.56 634.11 570.5 Z" fill="#f28a2b"/>
<path id="top_band_6" data-name="top band 6" d="M582.15 540.5 C543.41 562.87 480.59 562.87 441.85 540.5 C403.11 518.13 403.11 481.87 441.85 459.5 C480.59 437.13 543.41 437.13 582.15 459.5 C620.89 481.87 620.89 518.13 582.15 540.5 Z" fill="#de3b2f"/>
<path id="top_shade" data-name="top shade" d="M512 200 L764.81 345.96 L512 491.92 L259.19 345.96 Z" fill="#ffffff" opacity="0.1"/>
</g>
<g id="Mesh" clip-path="url(#clip-path-2)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
<g id="Left_face" data-name="Left face" clip-path="url(#clip-path-3)">
<path id="left" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#2c4fbf"/>
<path id="left_band_1" data-name="left band 1" d="M844.55 692 C844.55 479.92 695.66 222.04 512 116 C328.34 9.96 179.45 95.92 179.45 308 C179.45 520.08 328.34 777.96 512 884 C695.66 990.04 844.55 904.08 844.55 692 Z" fill="#2f86e0"/>
<path id="left_band_2" data-name="left band 2" d="M789.99 660.5 C789.99 483.22 665.53 267.64 512 179 C358.47 90.36 234.01 162.22 234.01 339.5 C234.01 516.78 358.47 732.36 512 821 C665.53 909.64 789.99 837.78 789.99 660.5 Z" fill="#22bcd8"/>
<path id="left_band_3" data-name="left band 3" d="M738.03 630.5 C738.03 486.35 636.83 311.07 512 239 C387.17 166.93 285.97 225.35 285.97 369.5 C285.97 513.65 387.17 688.93 512 761 C636.83 833.07 738.03 774.65 738.03 630.5 Z" fill="#3fc584"/>
<path id="left_band_4" data-name="left band 4" d="M686.07 600.5 C686.07 489.49 608.14 354.5 512 299 C415.86 243.5 337.93 288.49 337.93 399.5 C337.93 510.51 415.86 645.5 512 701 C608.14 756.5 686.07 711.51 686.07 600.5 Z" fill="#f0d13a"/>
<path id="left_band_5" data-name="left band 5" d="M634.11 570.5 C634.11 492.63 579.44 397.94 512 359 C444.56 320.06 389.89 351.63 389.89 429.5 C389.89 507.37 444.56 602.06 512 641 C579.44 679.94 634.11 648.37 634.11 570.5 Z" fill="#f28a2b"/>
<path id="left_band_6" data-name="left band 6" d="M582.15 540.5 C582.15 495.76 550.74 441.37 512 419 C473.26 396.63 441.85 414.76 441.85 459.5 C441.85 504.24 473.26 558.63 512 581 C550.74 603.37 582.15 585.24 582.15 540.5 Z" fill="#de3b2f"/>
<path id="left_shade" data-name="left shade" d="M252.19 358.08 L505 504.04 L505 795.96 L252.19 650 Z" fill="#000000" opacity="0.12"/>
</g>
<g id="Right_face" data-name="Right face" clip-path="url(#clip-path-4)">
<path id="right" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#2c4fbf"/>
<path id="right_band_1" data-name="right band 1" d="M179.45 692 C179.45 479.92 328.34 222.04 512 116 C695.66 9.96 844.55 95.92 844.55 308 C844.55 520.08 695.66 777.96 512 884 C328.34 990.04 179.45 904.08 179.45 692 Z" fill="#2f86e0"/>
<path id="right_band_2" data-name="right band 2" d="M234.01 660.5 C234.01 483.22 358.47 267.64 512 179 C665.53 90.36 789.99 162.22 789.99 339.5 C789.99 516.78 665.53 732.36 512 821 C358.47 909.64 234.01 837.78 234.01 660.5 Z" fill="#22bcd8"/>
<path id="right_band_3" data-name="right band 3" d="M285.97 630.5 C285.97 486.35 387.17 311.07 512 239 C636.83 166.93 738.03 225.35 738.03 369.5 C738.03 513.65 636.83 688.93 512 761 C387.17 833.07 285.97 774.65 285.97 630.5 Z" fill="#3fc584"/>
<path id="right_band_4" data-name="right band 4" d="M337.93 600.5 C337.93 489.49 415.86 354.5 512 299 C608.14 243.5 686.07 288.49 686.07 399.5 C686.07 510.51 608.14 645.5 512 701 C415.86 756.5 337.93 711.51 337.93 600.5 Z" fill="#f0d13a"/>
<path id="right_band_5" data-name="right band 5" d="M389.89 570.5 C389.89 492.63 444.56 397.94 512 359 C579.44 320.06 634.11 351.63 634.11 429.5 C634.11 507.37 579.44 602.06 512 641 C444.56 679.94 389.89 648.37 389.89 570.5 Z" fill="#f28a2b"/>
<path id="right_band_6" data-name="right band 6" d="M441.85 540.5 C441.85 495.76 473.26 441.37 512 419 C550.74 396.63 582.15 414.76 582.15 459.5 C582.15 504.24 550.74 558.63 512 581 C473.26 603.37 441.85 585.24 441.85 540.5 Z" fill="#de3b2f"/>
<path id="right_shade" data-name="right shade" d="M771.81 650 L519 795.96 L519 504.04 L771.81 358.08 Z" fill="#000000" opacity="0.3"/>
</g>
<g id="Mesh" clip-path="url(#clip-path-5)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M576.95 237.5 L317.14 387.5 M447.05 237.5 L706.86 387.5 M317.14 687.5 L317.14 387.5 M252.19 575 L512 725 M706.86 687.5 L706.86 387.5 M771.81 575 L512 725 M641.9 275 L382.1 425 M382.1 275 L641.9 425 M382.1 725 L382.1 425 M252.19 500 L512 650 M641.9 725 L641.9 425 M771.81 500 L512 650 M706.86 312.5 L447.05 462.5 M317.14 312.5 L576.95 462.5 M447.05 762.5 L447.05 462.5 M252.19 425 L512 575 M576.95 762.5 L576.95 462.5 M771.81 425 L512 575" fill="none" stroke="#0b2236" stroke-width="3.5" stroke-linejoin="round" opacity="0.45"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -9
View File
@@ -37,7 +37,10 @@
//! and tolerances are left to Abaqus's controls, whose residual norms are defined differently.
//! Abaqus starts each general step from the end of the previous one, while every model step
//! starts from the undeformed state; for elastic materials the end state depends on the final
//! loads alone, so the two agree.
//! loads alone, so the two agree. A plastic material's state depends on the path, so a deck
//! with nonlinear steps, a plastic material and more than one general step is refused, as is
//! a nonlinear step's load path (an amplitude on every load and boundary condition, not
//! written yet).
//!
//! # Loads
//!
@@ -105,7 +108,8 @@
//!
//! Isotropic elasticity is `*ELASTIC, TYPE=ISOTROPIC` (or, for a neo-Hookean material in a deck
//! with nonlinear steps, `*HYPERELASTIC, NEO HOOKE` with C₁₀ = μ/2 and D₁ = 2/κ, the same strain
//! energy; a linear static step is then refused, since Abaqus's general static step would apply
//! energy, and likewise `MOONEY-RIVLIN` (C₁₀, C₀₁, D₁) and `YEOH` (C₁₀, C₂₀, C₃₀, D₁, the higher
//! volumetric terms zero); a linear static step is then refused, since Abaqus's general static step would apply
//! the hyperelastic law), engineering constants
//! `TYPE=ENGINEERING CONSTANTS`, density `*DENSITY`. Von Mises plasticity is `*PLASTIC`: a
//! hardening table as its (yield stress, plastic strain) lines; bilinear isotropic hardening as
@@ -126,8 +130,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use fieldcraft_model::{
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Hardening, Integration, Load, Model, OutputVariable, Plastic,
Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
BeamProfile, BoundaryCondition, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, Integration, Load, Model, OutputVariable,
Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
};
use crate::{Error, Result, unsupported};
@@ -313,6 +317,27 @@ fn check_expressible(model: &Model) -> Result<()> {
));
}
}
for step in model.steps() {
if let Procedure::NonlinearStatic(n) = &step.procedure
&& n.load_path.is_some()
{
bad.insert(format!(
"step `{}`: a load path (it would be an amplitude on every load and boundary condition of the step; not written yet)",
step.name
));
}
}
let general = model.steps().iter().filter(|s| matches!(s.procedure, Procedure::Static {} | Procedure::NonlinearStatic(_))).count();
let plastic_in_use = model.sections().values().find(|s| model.material(&s.material).is_some_and(|m| m.plastic.is_some()));
if has_nonlinear_step(model)
&& general > 1
&& let Some(section) = plastic_in_use
{
bad.insert(format!(
"plastic material `{}` in a deck of {general} general steps (Abaqus carries the plastic state from one general step to the next, while every model step starts from the undeformed state; write each step to its own deck)",
section.material
));
}
for (name, m) in model.materials() {
if m.damage.is_some() || m.erosion.is_some() {
bad.insert(format!(
@@ -785,11 +810,20 @@ impl Deck {
for (name, m) in model.materials() {
self.line(&format!("*MATERIAL, NAME={name}"));
match m.elastic {
Some(Elastic::Isotropic { young, poisson }) if m.hyperelastic.is_some() && has_nonlinear_step(model) => {
// W = C10 (Ī1 − 3) + (J − 1)²/D1 with C10 = μ/2 and D1 = 2/κ.
let (shear, bulk) = fieldcraft_model::Hyperelastic::moduli(young, poisson);
self.line("*HYPERELASTIC, NEO HOOKE");
self.numbers_line("", &[shear / 2.0, 2.0 / bulk])?;
Some(Elastic::Isotropic { young, poisson })
if has_nonlinear_step(model)
&& let Some(h) = m.hyperelastic =>
{
// The polynomial constants of the option and U = (J − 1)²/D1 with D1 = 2/κ.
let (shear, bulk) = Hyperelastic::moduli(young, poisson);
let [c10, c01, c20, c30] = h.constants(shear);
let (keyword, values) = match h {
Hyperelastic::NeoHookean {} => ("NEO HOOKE", vec![c10, 2.0 / bulk]),
Hyperelastic::MooneyRivlin { .. } => ("MOONEY-RIVLIN", vec![c10, c01, 2.0 / bulk]),
Hyperelastic::Yeoh { .. } => ("YEOH", vec![c10, c20, c30, 2.0 / bulk]),
};
self.line(&format!("*HYPERELASTIC, {keyword}"));
self.numbers_line("", &values)?;
}
Some(Elastic::Isotropic { young, poisson }) => {
self.line("*ELASTIC, TYPE=ISOTROPIC");
+9 -3
View File
@@ -25,7 +25,8 @@
//! - per step: `*CONTROL_IMPLICIT_GENERAL`, `*CONTROL_IMPLICIT_SOLUTION` (linear) or
//! `*CONTROL_IMPLICIT_EIGENVALUE`, `*CONTROL_TERMINATION` (a static step is one implicit step
//! from time 0 to 1 with every load ramped by one curve, `*DEFINE_CURVE`); a nonlinear static
//! step runs over the same time with the same ramp, its first increment as `dt0`, the nonlinear
//! step runs over the same time with the same ramp (or its load path as the curve), its first
//! increment as `dt0`, the nonlinear
//! solver with a stiffness reformation every iteration (`ilimit` 1, full Newton) and its
//! iteration cap as `maxref`, and `*CONTROL_IMPLICIT_AUTO` with its minimum and maximum
//! increments (its increment cap and tolerances have no LS-DYNA counterpart and are left to
@@ -872,8 +873,13 @@ fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Ste
d.header(&["lcid"]);
d.card([Field::from(n)]);
d.header(&["a1", "o1"]);
d.card([Field::from(0.0), Field::from(0.0)]);
d.card([Field::from(1.0), Field::from(1.0)]);
let path = match &step.procedure {
Procedure::NonlinearStatic(s) => s.load_path.clone(),
_ => None,
};
for [t, f] in path.unwrap_or_else(|| vec![[0.0, 0.0], [1.0, 1.0]]) {
d.card([Field::from(t), Field::from(f)]);
}
}
for b in &step.boundary_conditions {
+33
View File
@@ -790,8 +790,41 @@ fn nonlinear_steps_and_hyperelastic_materials_are_written() {
assert!((values[0] - c10).abs() < 1e-9 * c10 && (values[1] - d1).abs() < 1e-9 * d1, "{line}");
assert!(!deck.contains("*ELASTIC"), "{deck}");
// Mooney–Rivlin and Yeoh: C10 = μ/2 − C01, C01, D1 and C10, C20, C30, D1.
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::MooneyRivlin { c01: 10000.0 }), ..steel() }).unwrap();
let deck = abaqus::write(&m).unwrap();
let line = deck.split("*HYPERELASTIC, MOONEY-RIVLIN\n").nth(1).and_then(|s| s.lines().next()).unwrap();
let values: Vec<f64> = line.split(", ").map(|v| v.parse().unwrap()).collect();
assert!((values[0] - (c10 - 10000.0)).abs() < 1e-9 * c10 && values[1] == 10000.0 && (values[2] - d1).abs() < 1e-9 * d1, "{line}");
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::Yeoh { c20: -50.0, c30: 2.0 }), ..steel() }).unwrap();
let deck = abaqus::write(&m).unwrap();
let line = deck.split("*HYPERELASTIC, YEOH\n").nth(1).and_then(|s| s.lines().next()).unwrap();
let values: Vec<f64> = line.split(", ").map(|v| v.parse().unwrap()).collect();
assert!((values[0] - c10).abs() < 1e-9 * c10 && values[1..3] == [-50.0, 2.0] && (values[3] - d1).abs() < 1e-9 * d1, "{line}");
m.replace_material("Steel", Material { hyperelastic: Some(Hyperelastic::NeoHookean {}), ..steel() }).unwrap();
m.add_step(step("Linear", Procedure::Static {}, &["clamp"], &["end"]), None).unwrap();
assert!(unsupported(&m).contains("step `Linear`: a linear static step beside nonlinear steps of hyperelastic material `Steel`"));
m.remove_step("Bend").unwrap();
assert!(abaqus::write(&m).unwrap().contains("*ELASTIC, TYPE=ISOTROPIC\n"), "with linear steps only, the elastic constants are the whole of it");
}
/// A plastic material in nonlinear steps is `*PLASTIC` in a general step; what Abaqus would
/// read differently is refused: a load path, and several general steps (Abaqus carries the
/// plastic state between them).
#[test]
fn nonlinear_plastic_steps_and_what_is_refused() {
use fieldcraft_model::{Hardening, NonlinearStatic, Plastic};
let mut m = cantilever();
let plastic = Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: 2000.0, hardening: Hardening::Isotropic };
m.replace_material("Steel", Material { plastic: Some(plastic), ..steel() }).unwrap();
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"])).unwrap();
let deck = abaqus::write(&m).unwrap();
assert!(deck.contains("*PLASTIC\n250.0, 0.0\n") && deck.contains("NLGEOM=YES"), "{deck}");
let path = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0]]), ..NonlinearStatic::default() };
m.replace_step(step("Bend", Procedure::NonlinearStatic(path), &["clamp"], &["end"])).unwrap();
assert!(unsupported(&m).contains("step `Bend`: a load path"));
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"])).unwrap();
m.add_step(step("Again", Procedure::NonlinearStatic(NonlinearStatic::default()), &["clamp"], &["end"]), None).unwrap();
assert!(unsupported(&m).contains("plastic material `Steel` in a deck of 2 general steps"));
}
+13
View File
@@ -760,6 +760,19 @@ fn nonlinear_static_steps() {
assert_eq!(auto.iter().map(num).collect::<Vec<_>>(), [1.0, 1e-4, 0.5], "blank fields are skipped: {auto:?}");
assert!(deck.contains("*DEFINE_CURVE_TITLE\nBend ramp\n"), "the loads follow the ramp");
assert!(deck.contains("*LOAD_NODE"), "{deck}");
// A load path is the curve.
let path = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.5, 1.0], [1.0, -1.0]]), ..controls.clone() };
m.replace_step(step("Bend", Procedure::NonlinearStatic(path), &["clamp"], &["tip_load"])).unwrap();
let deck = lsdyna::write(&m).unwrap().text;
let curve: Vec<Vec<f64>> = deck
.lines()
.skip_while(|l| *l != "Bend ramp")
.filter(|l| !l.starts_with('$'))
.skip(2)
.take(3)
.map(|l| l.split_whitespace().map(|v| v.parse().unwrap()).collect())
.collect();
assert_eq!(curve, [vec![0.0, 0.0], vec![0.5, 1.0], vec![1.0, -1.0]], "{deck}");
m.replace_step(step("Bend", Procedure::NonlinearStatic(NonlinearStatic { nonlinear_geometry: false, ..controls }), &["clamp"], &["tip_load"]))
.unwrap();
+8 -2
View File
@@ -244,7 +244,7 @@ pub static CATALOGUE: &[Capability] = &[
"Tet Mesh",
Mesh,
"Tet",
"Constrained Delaunay tet mesh of a body: every boundary triangle kept, faces tagged (refinement comes next)",
"Quality tet4 or tet10 mesh of a body to a size: refined, smoothed and improved, boundary on the B-rep faces, faces tagged",
),
soon("mesh.order.quadratic", "Quadratic", Mesh, "Tet", "M2", "Second-order elements with mid-side nodes on the geometry"),
ready("mesh.delaunay.points", "Delaunay Points", Mesh, "Tet", "Delaunay tetrahedralization of a point list (debugging aid)"),
@@ -281,7 +281,13 @@ pub static CATALOGUE: &[Capability] = &[
),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
soon("mesh.hex.octree", "All-Hex", Mesh, "Automatic Hex", "M6", "Octree dual all-hex mesh of any part"),
ready(
"mesh.hex.octree",
"Octree Hex",
Mesh,
"Automatic Hex",
"Unstructured all-hex mesh of any part: adaptive grid, boundary layers on every sharp edge, untangled and proven valid",
),
soon("mesh.hex.dominant", "Hex-Dominant", Mesh, "Automatic Hex", "M6", "Hex-dominant mesh by recombining tets"),
soon("mesh.hex.polycube", "Polycube", Mesh, "Automatic Hex", "M6", "Polycube hex mesh of blocky parts"),
soon("mesh.smooth", "Smooth", Mesh, "Improve", "M6", "Smooth node positions, keeping every element valid"),
+2 -2
View File
@@ -81,7 +81,7 @@ definition_commands!(
Material,
"material",
"Material",
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there)} — in the model's unit system (model.units), never converted"#,
r#"{"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3} | {"type": "engineering_constants", "young": [E1, E2, E3], "poisson": [ν12, ν13, ν23], "shear": [G12, G13, G23]}, "density": 7.85e-9, "fluid": {"type": "newtonian", "viscosity": 1.0e-9} (dynamic viscosity; a fluid needs a density), "plastic": {…} (see model.material.plasticity), "damage": {…}, "erosion": {…} (see model.material.damage), "hyperelastic": {"type": "neo_hookean"} | {"type": "mooney_rivlin", "c01": 0.1} (C10 = μ/2 − C01) | {"type": "yeoh", "c20": -0.01, "c30": 0.001} (C10 = μ/2) (large strain in nonlinear steps, from the isotropic constants: μ = E/(2(1 + ν)), κ = E/(3(1 − 2ν)); without it an elastic material is St Venant–Kirchhoff there)} — in the model's unit system (model.units), never converted"#,
add_material,
replace_material,
remove_material,
@@ -266,7 +266,7 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
pub const STEP_CREATE: CommandSpec = CommandSpec {
id: "model.step.create",
label: "New Step",
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false} (loads and prescribed displacements ramp with the load factor from 0 to 1) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal"}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
kind: CommandKind::Model,
run: step_create,
};
+11 -6
View File
@@ -89,6 +89,12 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
Ok(reply)
}
/// A file of at most `max` bytes, through FieldCraft's file edge ([`fieldcraft_io::fs`]: the disk,
/// or the page's files in the browser).
pub(crate) fn read_file(path: &str, max: u64) -> Result<Vec<u8>> {
Ok(fieldcraft_io::fs::read_capped(Path::new(path), max)?)
}
/// `path` made absolute against the working folder (as given where there is none).
pub(crate) fn absolute(path: &str) -> String {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
@@ -119,7 +125,7 @@ fn reopen<T>(
stored: &SourceFile,
base: Option<&Path>,
roots: &Roots,
max: usize,
max: u64,
open: impl FnOnce(&[u8]) -> fieldcraft_geometry::Result<T>,
warnings: &mut Vec<String>,
) -> (Option<T>, SourceFile) {
@@ -132,7 +138,7 @@ fn reopen<T>(
return (None, kept);
}
};
let bytes = match fieldcraft_geometry::read_file(&checked, max) {
let bytes = match read_file(&checked, max) {
Ok(b) => b,
Err(e) => {
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
@@ -173,13 +179,13 @@ impl Session {
let mut sources = Geometry::default();
let mut design: Option<Arc<Design>> = None;
if let Some(stored) = &file.geometry.design {
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings);
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES as u64, open_design, &mut warnings);
design = opened.map(Arc::new);
sources.design = Some(source);
}
let mut surfaces = Vec::new();
for stored in &file.geometry.meshes {
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES as u64, tagged_mesh::read_3mf, &mut warnings);
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source);
}
@@ -226,8 +232,7 @@ fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Res
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = absolute(&file_param(s, p, "path", Access::Read)?);
let max = usize::try_from(fieldcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
let bytes = fieldcraft_geometry::read_file(&path, max)?;
let bytes = read_file(&path, fieldcraft_io::MAX_FILE_BYTES)?;
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
}
+2 -2
View File
@@ -42,7 +42,7 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::file_param(s, p, "path", Access::Read)?;
let bytes = fieldcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?;
let bytes = super::file::read_file(&path, MAX_DESIGN_BYTES as u64)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let design = open_design(&bytes)?;
let out = json!({
@@ -101,7 +101,7 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
if s.sources.meshes.len() >= fieldcraft_io::MAX_MESH_SOURCES {
return Err(EngineError::Params(format!("a model imports at most {} meshes", fieldcraft_io::MAX_MESH_SOURCES)));
}
let bytes = fieldcraft_geometry::read_file(&path, tagged_mesh::MAX_FILE_BYTES)?;
let bytes = super::file::read_file(&path, tagged_mesh::MAX_FILE_BYTES as u64)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let surfaces = tagged_mesh::read_3mf(&bytes)?;
let first = s.surfaces.len();
+19 -13
View File
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use fieldcraft_geometry::{FaceSurface, Shape, SurfaceMesh};
use fieldcraft_mesh::hex::{Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::hex::{BoundaryFace, Cell, HexMesh, HexMethod, HexOptions, MAX_CELLS, Surface, hex_mesh, hex_mesh_cells};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{GeometryRef, Model};
@@ -28,13 +28,13 @@ const MIN_RELATIVE_TOLERANCE: f64 = 1e-5;
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
const DEFAULT_RELATIVE_TOLERANCE: f64 = 1e-3;
/// One body to mesh.
struct Input<'a> {
name: String,
mesh: Cow<'a, SurfaceMesh>,
/// One body to mesh (also the input of `mesh.hex.octree`).
pub(super) struct Input<'a> {
pub(super) name: String,
pub(super) mesh: Cow<'a, SurfaceMesh>,
surfaces: Vec<Option<Surface>>,
face_names: Vec<String>,
diagonal: f64,
pub(super) face_names: Vec<String>,
pub(super) diagonal: f64,
}
fn analytic(s: FaceSurface) -> Surface {
@@ -47,7 +47,7 @@ fn analytic(s: FaceSurface) -> Surface {
}
}
fn number(p: &Value, key: &str) -> Result<Option<f64>> {
pub(super) fn number(p: &Value, key: &str) -> Result<Option<f64>> {
match p.get(key) {
None => Ok(None),
Some(v) => v
@@ -73,7 +73,7 @@ fn shape_input(shape: &Shape, tolerance: Option<f64>) -> Result<Input<'static>>
Ok(Input { name: shape.name.clone(), mesh: Cow::Owned(shape.tessellate(tol)?), surfaces, face_names, diagonal: diag })
}
fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
pub(super) fn inputs<'a>(s: &'a Session, p: &Value) -> Result<Vec<Input<'a>>> {
let tolerance = number(p, "tolerance")?;
match (p.get("body"), p.get("surface")) {
(Some(b), None) => {
@@ -220,7 +220,7 @@ fn hex_blocks(s: &mut Session, p: &Value) -> Result<Value> {
model.set_element(label(e)?, m.kind, nodes)?;
model.tag_element(label(e)?, body_tags.get(cell).copied().ok_or_else(|| EngineError::Params("element without a cell".into()))?)?;
}
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), m, 1)?;
tag_faces(&mut model, &inputs, |e| cm.element_cell.get(e as usize).map_or(0, |&c| c as usize), &m.boundary, 1)?;
s.model = model;
let blocks: Vec<Value> =
inputs.iter().zip(&cm.blocks).map(|(b, d)| json!({"body": b.name, "divisions": d, "elements": d.iter().product::<usize>()})).collect();
@@ -318,7 +318,7 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, m, first_element)?;
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
report.push(json!({
"body": b.name,
"method": m.report.method.name(),
@@ -338,9 +338,15 @@ fn generate(s: &mut Session, p: &Value, default: HexMethod, allowed: &[HexMethod
/// Tag every boundary element face with the B-rep face it lies on, of the body
/// (`bodies[cell(element)]`) its element meshes.
fn tag_faces(model: &mut Model, bodies: &[Input<'_>], cell: impl Fn(u32) -> usize, m: &HexMesh, first_element: u32) -> Result<()> {
pub(super) fn tag_faces(
model: &mut Model,
bodies: &[Input<'_>],
cell: impl Fn(u32) -> usize,
boundary: &[BoundaryFace],
first_element: u32,
) -> Result<()> {
let mut tags: std::collections::BTreeMap<(usize, u32), u32> = std::collections::BTreeMap::new();
for f in &m.boundary {
for f in boundary {
let c = cell(f.element);
let tag = match tags.get(&(c, f.brep_face)) {
Some(&t) => t,
+326 -54
View File
@@ -6,6 +6,9 @@ use fieldcraft_mesh::cdt::{CdtOptions, tetrahedralize};
use fieldcraft_mesh::delaunay::Triangulation;
use fieldcraft_mesh::quality::{self, QualityReport};
use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
use fieldcraft_mesh::tet::improve::ImproveOptions;
use fieldcraft_mesh::tet::{Order, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
@@ -83,7 +86,7 @@ pub const SURFACE_CHECK: CommandSpec = CommandSpec {
pub const TET_GENERATE: CommandSpec = CommandSpec {
id: "mesh.tet.generate",
label: "Generate Tet Mesh",
params: r#"{"body": "Body1", "tolerance": 0.05} or {"surface": 0}, the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "max_steiner": 50000 (0..=10000000), "max_tets": 20000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary: every boundary triangle is a union of tet faces, Steiner points only on boundary edges, no quality refinement yet. Adds tet4 elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh rounded to doubles does not pass the validity gate"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
kind: CommandKind::Model,
run: tet_generate,
};
@@ -91,9 +94,11 @@ pub const TET_GENERATE: CommandSpec = CommandSpec {
/// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for.
const TET_MAX_STEINER: u64 = 10_000_000;
const TET_MAX_TETS: u64 = 50_000_000;
const TET_MAX_POINTS: u64 = 20_000_000;
const TET_MAX_PASSES: u64 = 200;
/// An optional whole-number parameter in `range`.
fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
pub(super) fn optional_count(p: &Value, key: &str, default: u64, range: std::ops::RangeInclusive<u64>) -> Result<usize> {
let v = match p.get(key) {
None => default,
Some(v) => v
@@ -114,59 +119,216 @@ pub(super) fn surface_options(p: &Value, mesh: &SurfaceMesh) -> Result<SurfaceOp
})
}
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let cdt_options = CdtOptions {
max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?,
max_tets: optional_count(p, "max_tets", s.limits.max_tets as u64, 1..=TET_MAX_TETS)?,
};
let (surface, body, face_names) = {
let Input { mesh, body, faces } = surface_input(s, p)?;
let options = surface_options(p, &mesh)?;
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces)
};
let cdt = tetrahedralize(&surface, &cdt_options)?;
let gate = cdt.gate(surface.volume())?.into_result()?;
/// An optional number in `range`.
fn number_in(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
let v = optional_number(p, key, default)?;
if range.contains(&v) { Ok(v) } else { Err(EngineError::Params(format!("`{key}` must be a number in {}..={}", range.start(), range.end()))) }
}
let (first_node, first_element) = super::fresh_labels(&s.model, cdt.points.len(), cdt.tets.len())?;
for (i, xyz) in cdt.points.iter().enumerate() {
/// The quality options of `mesh.tet.generate`, and whether the quality stage runs at all.
fn quality_options(p: &Value, max_tets: u64) -> Result<(QualityOptions, bool)> {
let d = QualityOptions::default();
let size = match p.get("size") {
None => None,
Some(v) => Some(
v.as_f64()
.filter(|h| *h > 0.0 && h.is_finite() && *h <= fieldcraft_model::MAX_COORD)
.ok_or_else(|| EngineError::Params("`size` must be a positive number (mm)".into()))?,
),
};
let order = match p.get("order") {
None => Order::Linear,
Some(v) => match v.as_str() {
Some("linear") => Order::Linear,
Some("quadratic") => Order::Quadratic,
_ => return Err(EngineError::Params("`order` must be \"linear\" or \"quadratic\"".into())),
},
};
let target = number_in(p, "quality_target", 40.0, 5.0..=60.0)?;
let improve_options = ImproveOptions {
max_passes: optional_count(p, "max_passes", d.improve_options.max_passes as u64, 0..=TET_MAX_PASSES)?,
good: fieldcraft_mesh::tet::geometry::sin_cos(target.to_radians()).0,
..d.improve_options
};
let quality = super::opt::<bool>(p, "quality")?.unwrap_or(true);
if !quality && order == Order::Quadratic {
return Err(EngineError::Params("quadratic elements need the quality stage (`quality`: true)".into()));
}
let options = QualityOptions {
size,
curvature_angle: number_in(p, "curvature_angle", d.curvature_angle.to_degrees(), 0.0..=90.0)?.to_radians(),
grading: number_in(p, "grading", d.grading, 0.05..=2.0)?,
improve_options,
order,
max_tets: optional_count(p, "max_tets", max_tets, 1..=TET_MAX_TETS)?,
max_points: optional_count(p, "max_points", d.max_points as u64, 1..=TET_MAX_POINTS)?,
..d
};
Ok((options, quality))
}
/// The exact surfaces of a body's faces, for the quality stage's projections (none when the
/// kernel cannot give them: the stage then keeps to the tessellation).
pub fn analytic_surfaces(shape: &fieldcraft_geometry::Shape) -> Vec<Option<AnalyticSurface>> {
use fieldcraft_geometry::FaceSurface as F;
shape
.face_surfaces()
.unwrap_or_default()
.into_iter()
.map(|f| {
f.map(|f| match f {
F::Plane { normal, point } => AnalyticSurface::Plane { normal, point },
F::Cylinder { origin, axis, radius } => AnalyticSurface::Cylinder { origin, axis, radius },
F::Cone { apex, axis, half_angle } => AnalyticSurface::Cone { apex, axis, half_angle },
F::Sphere { center, radius } => AnalyticSurface::Sphere { center, radius },
F::Torus { center, axis, major, minor } => AnalyticSurface::Torus { center, axis, major, minor },
})
})
.collect()
}
fn quality_json(q: &TetQuality) -> Value {
json!({
"tets": q.tets,
"min_dihedral": q.min_dihedral,
"max_dihedral": q.max_dihedral,
"min_radius_ratio": q.min_radius_ratio,
"mean_radius_ratio": q.mean_radius_ratio,
"slivers": q.slivers,
"inadmissible": q.inadmissible,
})
}
/// One body's tetrahedra, ready to add, with what its report needs of the surface and the CDT.
struct BodyTets {
name: String,
face_names: Vec<String>,
mesh: QualityMesh,
steiner: usize,
cavities: usize,
surface_volume: f64,
}
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
/// names the body.
fn mesh_body(
s: &Session,
p: &Value,
input: super::hex::Input,
cdt: &CdtOptions,
options: &QualityOptions,
quality: bool,
several: bool,
) -> Result<BodyTets> {
let mesh = || -> Result<BodyTets> {
// The exact faces of a design body; an imported surface keeps to its triangles.
let analytic = match p.get("surface") {
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
Some(_) => Vec::new(),
};
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
let tets = tetrahedralize(&surface, cdt)?;
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
Ok(BodyTets {
name: input.name.clone(),
face_names: input.face_names.clone(),
mesh,
steiner: tets.steiner.len(),
cavities: tets.stats.cavities,
surface_volume: surface.volume(),
})
};
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
}
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let (options, quality) = quality_options(p, s.limits.max_tets as u64)?;
let cdt_options =
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
// Every body is meshed before the model changes; with several, an error names its body.
let inputs = super::hex::inputs(s, p)?;
let several = inputs.len() > 1;
let mut meshed = Vec::with_capacity(inputs.len());
for input in inputs {
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
}
let mut bodies = Vec::with_capacity(meshed.len());
for b in &meshed {
let (first_node, first_element) = add_tets(s, &b.name, &b.face_names, &b.mesh)?;
let r = &b.mesh.report;
bodies.push(json!({
"body": b.name,
"nodes": b.mesh.points.len(),
"elements": b.mesh.elements.len(),
"first_node": first_node,
"first_element": first_element,
"steiner": b.steiner,
"cavities": b.cavities,
"boundary_faces": b.mesh.boundary.len(),
"volume": r.volume,
"boundary_volume": r.boundary_volume,
"surface_volume": b.surface_volume,
"size": r.size,
"quality": {
"cdt": quality_json(&r.input),
"mesh": quality_json(&r.output),
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
"odt_moves": r.odt_moves,
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
"straightened": r.straightened,
"stopped": r.stopped,
},
}));
}
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
let first = bodies.first();
Ok(json!({
"kind": meshed.first().map(|b| b.mesh.kind.name()),
"nodes": count(|b| b.mesh.points.len()),
"elements": count(|b| b.mesh.elements.len()),
"first_node": first.map(|b| b["first_node"].clone()),
"first_element": first.map(|b| b["first_element"].clone()),
"boundary_faces": count(|b| b.mesh.boundary.len()),
"volume": sum(|b| b.mesh.report.volume),
"boundary_volume": sum(|b| b.mesh.report.boundary_volume),
"surface_volume": sum(|b| b.surface_volume),
"bodies": bodies,
}))
}
/// Add one body's elements after the model's largest labels, tagging them with the body and each
/// boundary element face with its geometry face; returns the first node and element labels.
fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
for (i, xyz) in mesh.points.iter().enumerate() {
s.model.set_node(first_node + i as u32, *xyz)?;
}
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
for (i, t) in cdt.tets.iter().enumerate() {
let body_tag = s.model.geometry_tag(&GeometryRef::body(body)?)?;
for (i, e) in mesh.elements.iter().enumerate() {
let label = first_element + i as u32;
s.model.set_element(label, ElementKind::Tet4, t.iter().map(|&v| first_node + v).collect())?;
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
s.model.tag_element(label, body_tag)?;
}
let mut face_tags: Vec<Option<u32>> = vec![None; face_names.len()];
for b in &cdt.boundary {
let face = surface.tri_face().get(b.triangle as usize).copied().unwrap_or(u32::MAX) as usize;
for b in &mesh.boundary {
let face = b.face as usize;
let tag = match face_tags.get(face).copied().flatten() {
Some(t) => t,
None => {
let name =
face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary triangle {} has no geometry face", b.triangle)))?;
let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?;
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
let t = s.model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
if let Some(slot) = face_tags.get_mut(face) {
*slot = Some(t);
}
t
}
};
let number = b.tet4_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no tet4 face number", b.opposite)))?;
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
s.model.tag_element_face(first_element + b.tet, number, tag)?;
}
Ok(json!({
"body": body,
"nodes": cdt.points.len(),
"elements": cdt.tets.len(),
"first_node": first_node,
"first_element": first_element,
"steiner": cdt.steiner.len(),
"cavities": cdt.stats.cavities,
"boundary_faces": cdt.boundary.len(),
"volume": gate.volume,
"boundary_volume": surface.volume(),
}))
Ok((first_node, first_element))
}
pub const PLANAR: CommandSpec = CommandSpec {
@@ -367,6 +529,7 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
Ok(json!({
"summary": quality_summary(&r),
"measured": r.measured,
"skipped": skipped,
"valid": r.valid,
@@ -378,21 +541,33 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// A boundary to mesh: the surface, with the body name and the geometry name of each face index
/// for tagging.
struct Input<'a> {
mesh: Cow<'a, SurfaceMesh>,
body: String,
faces: Vec<String>,
/// A quality report in a line, for the status bar and the CLI: the elements measured, how many
/// are invalid or undetermined, and the scaled Jacobian's minimum and mean.
fn quality_summary(r: &QualityReport) -> String {
if r.measured == 0 {
return "no elements to measure".into();
}
let mut text = format!("{} elements", r.measured);
if r.invalid.is_empty() && r.undetermined.is_empty() {
text.push_str(", all valid");
}
if !r.invalid.is_empty() {
text.push_str(&format!(", {} invalid", r.invalid.len()));
}
if !r.undetermined.is_empty() {
text.push_str(&format!(", {} undetermined", r.undetermined.len()));
}
let jacobian = r.metrics.iter().find(|m| m.metric == quality::Metric::ScaledJacobian && m.count > 0);
if let Some((min, mean)) = jacobian.and_then(|m| m.mean.map(|mean| (m.min, mean))) {
text.push_str(&format!("; scaled Jacobian min {min:.3}, mean {mean:.3}"));
}
text
}
/// The surface named by `body` (tessellated at `tolerance`) or `surface` (an imported index).
fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Input<'a>> {
fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Cow<'a, SurfaceMesh>> {
match (p.get("body"), p.get("surface")) {
(Some(_), None) => {
let (shape, mesh) = super::geometry::body_mesh(s, p)?;
Ok(Input { mesh: Cow::Owned(mesh), body: shape.name.clone(), faces: shape.faces().iter().map(|f| f.name.clone()).collect() })
}
(Some(_), None) => Ok(Cow::Owned(super::geometry::body_mesh(s, p)?.1)),
(None, Some(i)) => {
let n = s.surfaces.len();
let t = i
@@ -400,7 +575,7 @@ fn surface_input<'a>(s: &'a Session, p: &Value) -> Result<Input<'a>> {
.and_then(|i| usize::try_from(i).ok())
.and_then(|i| s.surfaces.get(i))
.ok_or_else(|| EngineError::Params(format!("`surface` must be the index of an imported surface (there are {n})")))?;
Ok(Input { mesh: Cow::Borrowed(&t.mesh), body: t.name.clone(), faces: (0..t.faces).map(|f| f.to_string()).collect() })
Ok(Cow::Borrowed(&t.mesh))
}
_ => Err(EngineError::Params("give either `body` (with `tolerance`) or `surface`".into())),
}
@@ -411,7 +586,7 @@ fn defect_json(d: &Defect) -> Value {
}
fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
let mesh = surface_input(s, p)?.mesh;
let mesh = surface_input(s, p)?;
let options = surface_options(p, &mesh)?;
let surface = match BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options) {
Ok(surface) => surface,
@@ -612,6 +787,7 @@ mod tests {
// The certified quality report agrees.
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements);
assert!(q["summary"].as_str().unwrap().starts_with(&format!("{elements} elements, all valid; scaled Jacobian min ")), "{q}");
// A second run appends after the existing labels.
let again = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.5})).unwrap();
assert_eq!(again["first_element"].as_u64().unwrap() as usize, elements + 1);
@@ -626,16 +802,60 @@ mod tests {
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 0}),
json!({"body": "Body1", "tolerance": 0.5, "max_tets": 10}),
json!({"body": "Body1", "tolerance": 0.5, "max_tets": "many"}),
json!({"body": "Body1"}),
json!({"body": "Body1", "tolerance": -1}),
json!({"body": "Nope", "tolerance": 0.5}),
json!({"body": "Body1", "surface": 0}),
json!({"surface": 0}),
json!({}),
] {
assert!(s.execute("mesh.tet.generate", &bad).is_err(), "{bad}");
}
assert_eq!((s.journal.len(), &s.model), (journal, &model), "failures change nothing");
}
/// Without parameters every body of the open design is meshed, each at a tolerance from its
/// size; an error says which body it is about.
#[test]
fn tet_generate_defaults_to_every_body() {
let commands = json!({"commands": [
{"command": "solid.box", "params": {"length": 20, "width": 10, "height": 10, "body_name": "Box"}},
{"command": "solid.split_body", "params": {"body": "Box", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}},
]});
let bytes = fieldcraft_geometry::script_design(&commands.to_string()).unwrap();
let (_dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
assert!(s.execute("mesh.tet.generate", &json!({})).unwrap_err().to_string().contains("open a design first"));
s.execute("geometry.open", &json!({"path": path})).unwrap();
let r = s.execute("mesh.tet.generate", &json!({})).unwrap();
let bodies = r["bodies"].as_array().unwrap();
assert_eq!(bodies.len(), 2, "{r}");
assert_eq!(r["kind"], "tet4");
assert!((r["volume"].as_f64().unwrap() - 2000.0).abs() < 1e-6, "{r}");
assert_eq!(s.model.elements().len() as u64, r["elements"].as_u64().unwrap());
assert_eq!(s.model.classification().elements().len(), s.model.elements().len(), "every element is on its body");
// The second body's labels follow the first's.
assert_eq!(bodies[1]["first_element"].as_u64(), Some(bodies[0]["elements"].as_u64().unwrap() + 1));
assert_eq!(s.journal.len(), 2);
// A body named alone takes the same default tolerance.
let one = s.execute("mesh.tet.generate", &json!({"body": bodies[0]["body"]})).unwrap();
assert_eq!(one["elements"], bodies[0]["elements"]);
let e = s.execute("mesh.tet.generate", &json!({"max_tets": 1})).unwrap_err().to_string();
assert!(e.starts_with("body `Box"), "{e}");
}
/// The quality report's one-line summary counts the inverted elements.
#[test]
fn quality_summary_counts_invalid_elements() {
let mut s = Session::default();
assert_eq!(s.execute("mesh.quality", &json!({})).unwrap()["summary"], "no elements to measure");
for (label, xyz) in [(1, [0, 0, 0]), (2, [1, 0, 0]), (3, [0, 1, 0]), (4, [0, 0, 1])] {
s.execute("mesh.node.add", &json!({"label": label, "xyz": xyz})).unwrap();
}
s.execute("mesh.element.add", &json!({"label": 1, "kind": "tet4", "nodes": [1, 2, 3, 4]})).unwrap();
s.execute("mesh.element.add", &json!({"label": 2, "kind": "tet4", "nodes": [1, 3, 2, 4]})).unwrap();
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert!(q["summary"].as_str().unwrap().starts_with("2 elements, 1 invalid; scaled Jacobian min -"), "{q}");
}
/// The session's caps are the defaults of `max_steiner` and `max_tets`; a parameter overrides them.
#[test]
fn tet_generate_honours_the_session_caps() {
@@ -656,6 +876,53 @@ mod tests {
assert!(r["elements"].as_u64().unwrap() > 0, "{r}");
}
/// The quality stage: a graded, improved tet4 mesh with its report, and tet10 with mid-edge
/// nodes on the bore; the raw tetrahedralization on request.
#[test]
fn tet_generate_quality_report_and_quadratic_elements() {
use std::sync::Arc;
use fieldcraft_geometry::open_design;
use fieldcraft_geometry::sample::{Plate, plate_design};
let plate = Plate::default();
let design = Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap());
let mut s = Session { design: Some(design.clone()), ..Session::default() };
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6})).unwrap();
let q = &r["bodies"][0]["quality"];
assert_eq!(q["mesh"]["inadmissible"], json!(0), "{r}");
assert_eq!(q["mesh"]["slivers"], json!(0), "{r}");
assert!(q["mesh"]["min_dihedral"].as_f64().unwrap() > 15.0, "{r}");
assert!(q["mesh"]["max_dihedral"].as_f64().unwrap() < 165.0, "{r}");
assert!(q["cdt"]["slivers"].as_u64().unwrap() > 0, "{r}");
// The mesh fills what its boundary encloses, which is the plate within the chordal error.
let (volume, boundary) = (r["volume"].as_f64().unwrap(), r["boundary_volume"].as_f64().unwrap());
assert!((volume - boundary).abs() <= 1e-9 * boundary, "{r}");
let exact = plate.width * plate.depth * plate.thickness - std::f64::consts::PI * 0.25 * plate.hole_diameter.powi(2) * plate.thickness;
// The bore's chordal error at 0.2 mm is at most 2/3 · 0.2 · its area (≈ 42 mm³).
assert!((volume - exact).abs() < 42.0, "{volume} vs {exact}");
// Quadratic: mid-edge nodes on the bore's cylinder.
let mut s = Session { design: Some(design), ..Session::default() };
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6, "order": "quadratic"})).unwrap();
assert_eq!(r["kind"], json!("tet10"), "{r}");
assert!(s.model.elements().values().all(|e| e.kind == fieldcraft_model::ElementKind::Tet10));
let report = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(report["valid"].as_u64().unwrap(), r["elements"].as_u64().unwrap(), "{report}");
// Nodes on the bore (within the tessellation's reach of its axis) lie on its radius.
let (cx, cy) = (plate.depth / 2.0, plate.depth / 2.0);
let radius = plate.hole_diameter / 2.0;
let mut on_bore = 0;
for n in s.model.nodes().values() {
let d = ((n.xyz[0] - cx).powi(2) + (n.xyz[1] - cy).powi(2)).sqrt();
if (d - radius).abs() < 0.05 {
on_bore += 1;
assert!((d - radius).abs() < 1e-9, "a bore node at {:?} is {d} from the axis", n.xyz);
}
}
assert!(on_bore > 20, "{on_bore}");
}
#[test]
fn tet_generate_on_an_imported_surface_and_an_open_one() {
use std::sync::Arc;
@@ -671,12 +938,17 @@ mod tests {
for mesh in [tet, open] {
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
}
let r = s.execute("mesh.tet.generate", &json!({"surface": 0})).unwrap();
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap();
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4)));
let element = s.model.element(1).unwrap();
assert_eq!(element.nodes.len(), 4);
// Refined to a size, the flat-faced tetrahedron keeps its volume exactly.
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "size": 0.3})).unwrap();
assert!(r["elements"].as_u64().unwrap() > 1, "{r}");
assert!((r["volume"].as_f64().unwrap() - 1.0 / 6.0).abs() < 1e-12, "{r}");
let total = s.model.elements().len();
assert!(s.execute("mesh.tet.generate", &json!({"surface": 1})).is_err(), "an open surface bounds no volume");
assert_eq!(s.model.elements().len(), 1);
assert_eq!(s.model.elements().len(), total);
}
#[test]
+4
View File
@@ -8,7 +8,10 @@ pub(crate) mod file;
mod geometry;
mod hex;
pub(crate) mod mesh;
pub use mesh::analytic_surfaces;
mod model;
pub(crate) mod octree;
mod results;
pub(crate) mod solve;
mod tags;
@@ -36,6 +39,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::SURFACE_CHECK,
mesh::PLANAR,
mesh::TET_GENERATE,
octree::HEX_OCTREE,
block::BLOCK,
hex::HEX_GENERATE,
hex::HEX_SWEEP,
+177
View File
@@ -0,0 +1,177 @@
//! Unstructured all-hexahedral meshing (`mesh.hex.octree`) of the open design's bodies or of
//! imported surfaces.
use fieldcraft_mesh::octree::{OctreeHexMesh, OctreeOptions, octree_hex_mesh};
use fieldcraft_mesh::size::UniformSize;
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::hex::{Input, inputs, number, tag_faces};
use super::mesh::optional_count;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_OCTREE: CommandSpec = CommandSpec {
id: "mesh.hex.octree",
label: "Octree Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size in the interior, mm; default 1/25 of the bounding-box diagonal), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal), "max_depth": 3 (refinement levels below the size, each a third, 0..=5), "max_elements": 2000000 (1..=10000000; default the session limit), "attempts": 4 (builds, refining where one failed, 1..=8), "max_sweeps": 300 (optimiser sweeps, 0..=10000), and the boundary options of mesh.tet.generate ("weld", "weld_distance", "feature_angle")} — replaces the mesh with an unstructured all-hex8 mesh of any part: an adaptive 3-refinement grid with conforming transition templates, a core inside the part, two boundary layers that capture every B-rep face boundary and sharp edge, and untangling. Every element is proven valid; boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name), so sets by geometry resolve. Reports per body the elements, quality, volume and grid statistics"#,
kind: CommandKind::Model,
run: hex_octree,
};
/// Smallest element size, relative to the bounding-box diagonal (the grid's cell count grows
/// with the cube of its inverse).
const MIN_RELATIVE_SIZE: f64 = 1e-3;
/// Default element size, relative to the bounding-box diagonal.
const DEFAULT_RELATIVE_SIZE: f64 = 1.0 / 25.0;
/// Caps on the options.
const MAX_DEPTH: u64 = 5;
const MAX_ATTEMPTS: u64 = 8;
const MAX_SWEEPS: u64 = 10_000;
const MAX_HEX_ELEMENTS: u64 = 10_000_000;
/// The session default of `max_elements` ([`crate::Limits::max_hex_elements`]).
pub(crate) const DEFAULT_MAX_ELEMENTS: usize = 2_000_000;
fn hex_octree(s: &mut Session, p: &Value) -> Result<Value> {
let size = number(p, "size")?;
let defaults = OctreeOptions::default();
let options = OctreeOptions {
max_depth: u8::try_from(optional_count(p, "max_depth", u64::from(defaults.max_depth), 0..=MAX_DEPTH)?).unwrap_or(defaults.max_depth),
max_elements: optional_count(p, "max_elements", s.limits.max_hex_elements as u64, 1..=MAX_HEX_ELEMENTS)?.min(MAX_ELEMENTS),
attempts: optional_count(p, "attempts", defaults.attempts as u64, 1..=MAX_ATTEMPTS)?,
max_sweeps: optional_count(p, "max_sweeps", defaults.max_sweeps as u64, 0..=MAX_SWEEPS)?,
..defaults
};
let bodies = inputs(s, p)?;
let mut meshes: Vec<(Input, OctreeHexMesh)> = Vec::with_capacity(bodies.len());
let mut total = 0usize;
for b in bodies {
if !(b.diagonal > 0.0 && b.diagonal.is_finite()) {
return Err(EngineError::Params(format!("body `{}` has no extent", b.name)));
}
let h = size.unwrap_or(DEFAULT_RELATIVE_SIZE * b.diagonal);
if h < MIN_RELATIVE_SIZE * b.diagonal {
return Err(EngineError::Params(format!("`size` must be at least {} mm for body `{}`", MIN_RELATIVE_SIZE * b.diagonal, b.name)));
}
let surface_options = super::mesh::surface_options(p, &b.mesh)?;
let surface = BoundarySurface::new(&b.mesh.positions, &b.mesh.triangles, &b.mesh.tri_face, &surface_options)
.map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
let left = OctreeOptions { max_elements: options.max_elements.saturating_sub(total).max(1), ..options.clone() };
let mesh = octree_hex_mesh(&surface, &UniformSize(h), &left).map_err(|e| EngineError::Params(format!("body `{}`: {e}", b.name)))?;
total += mesh.hexes.len();
meshes.push((b, mesh));
}
// Replace the mesh: definitions stay, nodes, elements and geometry tags are new.
let mut model = s.model.without_mesh();
let mut report = Vec::with_capacity(meshes.len());
let (mut node_label, mut element_label) = (1u32, 1u32);
for (b, m) in &meshes {
let first_node = node_label;
for xyz in &m.nodes {
model.set_node(node_label, *xyz)?;
node_label = node_label.checked_add(1).ok_or_else(|| EngineError::Params("too many nodes".into()))?;
}
let body_tag = model.geometry_tag(&GeometryRef::body(b.name.as_str())?)?;
let first_element = element_label;
for hx in &m.hexes {
model.set_element(element_label, ElementKind::Hex8, hx.iter().map(|&i| first_node + i).collect())?;
model.tag_element(element_label, body_tag)?;
element_label = element_label.checked_add(1).ok_or_else(|| EngineError::Params("too many elements".into()))?;
}
tag_faces(&mut model, std::slice::from_ref(b), |_| 0, &m.boundary, first_element)?;
let r = &m.report;
report.push(json!({
"body": b.name,
"kind": "hex8",
"elements": m.hexes.len(),
"nodes": m.nodes.len(),
"boundary_faces": m.boundary.len(),
"quality": {"min_scaled_jacobian": r.min_scaled_jacobian, "mean_scaled_jacobian": r.mean_scaled_jacobian},
"volume": r.volume,
"surface_volume": r.surface_volume,
"grid": {"cells": r.grid_cells, "core_elements": r.core_elements, "layer_elements": r.layer_elements, "frame": r.frame},
"features": {"corners": r.corners, "curves": r.curves, "patches": r.patches},
"attempts": r.attempts,
"retries": r.retries,
"sweeps": r.sweeps,
}));
}
s.model = model;
Ok(json!({"bodies": report, "nodes": node_label - 1, "elements": element_label - 1}))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use fieldcraft_geometry::open_design;
use fieldcraft_geometry::sample::{Plate, plate_design};
use serde_json::json;
use crate::Session;
fn opened() -> Session {
let plate = Plate::default();
Session { design: Some(Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap())), ..Session::default() }
}
#[test]
fn plate_with_a_hole_meshes_all_hex_and_face_sets_resolve() {
let mut s = opened();
let r = s.execute("mesh.hex.octree", &json!({"body": "Body1", "size": 5.0, "max_depth": 2})).unwrap();
let body = &r["bodies"][0];
let n = body["elements"].as_u64().unwrap() as usize;
assert!(n > 50, "{r}");
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() > 0.0, "{r}");
assert_eq!(s.model.element_counts(), vec![(fieldcraft_model::ElementKind::Hex8, n)]);
let (v, sv) = (body["volume"].as_f64().unwrap(), body["surface_volume"].as_f64().unwrap());
assert!((v - sv).abs() < 0.02 * sv, "{r}");
// The certified quality report agrees.
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, n);
// Every boundary face is tagged: the skin of the body set; each named face resolves.
s.execute("model.set.create", &json!({"name": "skin", "geometry": [{"body": "Body1"}]})).unwrap();
let skin = s.execute("model.set.resolve", &json!({"name": "skin"})).unwrap();
assert_eq!(skin["faces"]["count"], body["boundary_faces"], "{skin}");
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
for f in bodies[0]["faces"].as_array().unwrap() {
let name = f["name"].as_str().unwrap();
s.execute("model.set.create", &json!({"name": "f", "geometry": [{"body": "Body1", "face": name}]})).unwrap();
let set = s.execute("model.set.resolve", &json!({"name": "f"})).unwrap();
assert!(set["faces"]["count"].as_u64().unwrap() > 0, "face {name}: {set}");
s.execute("model.set.delete", &json!({"name": "f"})).unwrap();
}
}
#[test]
fn hostile_params_are_errors_and_change_nothing() {
let mut s = opened();
let before = s.model.clone();
for p in [
json!({"body": "Body1", "size": 0}),
json!({"body": "Body1", "size": -1}),
json!({"body": "Body1", "size": 1e-300}),
json!({"body": "Body1", "size": "big"}),
json!({"body": "Body1", "tolerance": 1e-300}),
json!({"body": "Body1", "tolerance": -1}),
json!({"body": "Body1", "max_depth": 9}),
json!({"body": "Body1", "max_depth": -1}),
json!({"body": "Body1", "max_elements": 0}),
json!({"body": "Body1", "max_elements": 1e300}),
json!({"body": "Body1", "attempts": 0}),
json!({"body": "Body1", "attempts": 100}),
json!({"body": "Body1", "max_sweeps": "many"}),
json!({"body": "Body1", "size": 5.0, "max_depth": 2, "max_elements": 10}),
json!({"body": "Nope"}),
json!({"body": 7}),
json!({"surface": 0}),
json!({"surface": -1}),
json!({"body": "Body1", "surface": 0}),
] {
let r = s.execute("mesh.hex.octree", &p);
assert!(r.is_err(), "{p}: {r:?}");
assert_eq!(s.model, before, "{p}");
}
assert!(Session::default().execute("mesh.hex.octree", &json!({})).is_err());
}
}
+27 -4
View File
@@ -34,7 +34,7 @@ pub const STATIC: CommandSpec = CommandSpec {
pub const NONLINEAR: CommandSpec = CommandSpec {
id: "solve.nonlinear",
label: "Solve Nonlinear Static Step",
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains, every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
params: r#"{"step": "Inflate", "max_dofs": 6000000, "max_factor_entries": 400000000, "max_newton_iterations": 100000, "threads": 8} — solves a nonlinear static step (the only one when `step` is left out) in load increments with Newton–Raphson iterations and keeps the result in the session: the final displacements, reactions, Cauchy stresses and logarithmic strains (small strains without nonlinear geometry), the equivalent plastic strain of plastic materials (the largest in the summary), every increment's load factor, iterations and residuals (force, displacement and energy, relative to their tolerances' scales), and a displacement frame per converged increment; the caps may only be lowered (`max_newton_iterations` over the whole step); `threads` as for solve.static"#,
kind: CommandKind::Model,
run: run_nonlinear,
};
@@ -82,7 +82,7 @@ pub const FLUID: CommandSpec = CommandSpec {
pub const PROBE: CommandSpec = CommandSpec {
id: "solve.probe",
label: "Probe Results",
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses, shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
params: r#"{"step": "Load", "nodes": [1, 2] (or "set": "fillet", a set's nodes), "mode": 1, "frame": 0} — the stored result at nodes (with `max_von_mises`, the largest among them, for static steps): displacement, rotation, reaction, reaction moment, stress and von Mises (static, with `elements` each element's results: solid, 2D and truss nodal stresses (and, in a nonlinear step, the equivalent plastic strain at the integration points of a plastic element), shell section forces (N, M, Q per length in the element's axes) and bottom, middle and top stresses, beam end forces (N, V_y, V_z, T, M_y, M_z in its axes)), the shape of mode `mode` with its rotations (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
kind: CommandKind::Model,
run: probe,
};
@@ -220,6 +220,7 @@ fn run_nonlinear(s: &mut Session, p: &Value) -> Result<Value> {
"frames": history.map(|h| h.load_factors()),
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
"max_plastic_strain": largest(r.elements.iter().flat_map(|e| e.plastic_strain.iter().map(|v| (e.label, *v)))),
"applied_total": r.applied_total,
"reaction_total": r.reaction_total,
"reference_points": r.reference_points.iter().map(|p| json!({
@@ -488,13 +489,21 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
}
let (nodes, located) = match (p.get("nodes"), p.get("at")) {
(Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None),
(None, None) if p.get("set").is_some() => {
let name = opt::<String>(p, "set")?.unwrap_or_default();
let set = s.model.resolve_set(&name)?;
if set.nodes.len() > MAX_PROBE {
return Err(EngineError::Params(format!("set `{name}` has {} nodes; a probe takes at most {MAX_PROBE}", set.nodes.len())));
}
(set.nodes, None)
}
(None, Some(_)) => {
let at = points(p, "at", MAX_PROBE)?;
let found = nearest_nodes(&s.model, result.nodes(), &at)?;
(found.iter().map(|(l, _, _)| *l).collect(), Some(found))
}
(None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) || p.get("elements").is_some() => (Vec::new(), None),
_ => return Err(EngineError::Params("give either `nodes` or `at`".into())),
_ => return Err(EngineError::Params("give one of `nodes`, `at` or `set`".into())),
};
let mut out = match result.as_ref() {
StepResult::Explicit(r) => {
@@ -504,6 +513,16 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?,
StepResult::Fluid(r) => probe_fluid(r, p)?,
};
// The largest von Mises stress among the probed nodes (static results).
let peak = out.get("nodes").and_then(Value::as_array).and_then(|entries| {
entries
.iter()
.filter_map(|e| Some((e.get("node")?.as_u64()?, e.get("von_mises")?.as_f64()?)))
.fold(None, |best: Option<(u64, f64)>, (n, v)| if best.is_none_or(|b| v > b.1) { Some((n, v)) } else { best })
});
if let (Some((node, value)), Some(obj)) = (peak, out.as_object_mut()) {
obj.insert("max_von_mises".into(), json!({"node": node, "value": value}));
}
if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) {
for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) {
if let Some(obj) = entry.as_object_mut() {
@@ -613,7 +632,11 @@ fn by_label<T>(list: &[T], label: u32, key: impl Fn(&T) -> u32) -> Option<&T> {
/// The stored results of one element of a static step.
fn element_result(r: &StaticResult, label: u32) -> Option<Value> {
if let Some(e) = by_label(&r.elements, label, |e| e.label) {
return Some(json!({"element": label, "kind": e.kind.name(), "stress": e.nodal}));
let mut v = json!({"element": label, "kind": e.kind.name(), "stress": e.nodal});
if !e.plastic_strain.is_empty() {
v["plastic_strain"] = json!(e.plastic_strain);
}
return Some(v);
}
if let Some(e) = by_label(&r.shells, label, |e| e.label) {
let s = &e.stress;
+45 -14
View File
@@ -15,6 +15,8 @@
pub mod autosave;
pub mod catalogue;
pub mod cmd;
pub use cmd::analytic_surfaces;
pub mod history;
pub mod results;
pub mod sample;
@@ -135,6 +137,8 @@ pub struct Limits {
pub max_steiner: usize,
/// `mesh.tet.generate`'s `max_tets`.
pub max_tets: usize,
/// `mesh.hex.octree`'s `max_elements`.
pub max_hex_elements: usize,
/// Threads the solvers use (the `solve.*` commands' `threads`): by default
/// `FIELDCRAFT_THREADS` or the available cores, 1 to [`fieldcraft_parallel::MAX_THREADS`].
/// Results do not depend on it.
@@ -144,7 +148,12 @@ pub struct Limits {
impl Default for Limits {
fn default() -> Self {
let cdt = fieldcraft_mesh::cdt::CdtOptions::default();
Limits { max_steiner: cdt.max_steiner, max_tets: cdt.max_tets, threads: fieldcraft_parallel::default_threads() }
Limits {
max_steiner: cdt.max_steiner,
max_tets: cdt.max_tets,
max_hex_elements: cmd::octree::DEFAULT_MAX_ELEMENTS,
threads: fieldcraft_parallel::default_threads(),
}
}
}
@@ -358,7 +367,8 @@ mod tests {
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
json!({"path": 7}),
json!({"body": "Body1", "tolerance": 0.05}),
// (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.)
json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}),
json!({"body": null, "tolerance": "fine"}),
json!({"elements": [u32::MAX, -1, 1.5]}),
json!({"elements": {}, "bins": -5}),
@@ -378,7 +388,7 @@ mod tests {
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "holes": [[[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]], [[0.1, 0.1], [0.2, 0.1], [0.1, 0.2]]], "size": 0.2}),
json!({"polygon": [["a", 0], [1, null], {}], "size": "big", "kind": 7}),
json!({"polygon": [[0, 0], [1, 0], [0, 1]], "size": 1e300, "kind": "tri6", "max_elements": 1e300}),
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1}),
json!({"body": "Body1", "face": "", "tolerance": 0.05, "size": 1, "max_points": 3000, "max_passes": 1}),
// Structured hex meshing.
json!({"size": -1, "order": 9, "method": "x"}),
json!({"size": 1e-300}),
@@ -471,9 +481,11 @@ mod tests {
json!({"geometry": {"body": "Bar", "vertex": "v"}, "nodes": [1], "edges": [{"element": 1, "edge": 0}]}),
];
// Small work caps, so input that would mesh up to the production caps (needle triangles
// need Steiner points all along them) is refused at once. The caps themselves are what
// need Steiner points all along them, an octree hex mesh is fitted and untangled) is
// refused at once. The caps themselves are what
// `tet_generate_honours_the_session_caps` checks.
let capped = || Session { limits: Limits { max_steiner: 64, max_tets: 100_000, ..Limits::default() }, ..Session::default() };
let capped =
|| Session { limits: Limits { max_steiner: 64, max_tets: 100_000, max_hex_elements: 500, ..Limits::default() }, ..Session::default() };
for spec in commands() {
for p in &hostile {
let mut s = capped();
@@ -506,15 +518,34 @@ mod tests {
json!({"body": "Body1", "tolerance": 1e-300}),
json!({"body": "Body1", "tolerance": 1e300}),
json!({"body": "Body2", "tolerance": 0.1}),
json!({"body": "Body1", "tolerance": 0.5, "surface": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 1e-3}),
json!({"body": "Body1", "tolerance": 0.5, "weld": f64::MIN_POSITIVE}),
json!({"body": "Body1", "tolerance": 0.5, "weld": 0, "weld_distance": 0}),
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "weld_distance": 0.05}),
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": 1e-300}),
json!({"body": "Body1", "tolerance": 0.5, "feature_angle": null}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "surface": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 1e-3}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": f64::MIN_POSITIVE}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld": 0, "weld_distance": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "weld_distance": 0.05}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": 1e-300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1, "feature_angle": null}),
// The quality stage's own parameters, hostile, with small caps so this stays fast.
json!({"body": "Body1", "tolerance": 0.5, "size": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": -1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 1e-300, "max_points": 2000, "max_tets": 5000}),
json!({"body": "Body1", "tolerance": 0.5, "size": "fine"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "cubic"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "order": "quadratic", "quality": false}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality": "yes"}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "quality_target": 1e300}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": -1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "curvature_angle": 1e-300, "max_points": 2000}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "grading": f64::MAX}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_passes": 1e9}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 0}),
json!({"body": "Body1", "tolerance": 0.5, "size": 20, "max_points": 30, "max_passes": 1}),
json!({"body": "Body1", "tolerance": 0.5, "size": 0.5, "max_tets": 3000, "max_passes": 1}),
json!({"body": "Body1", "size": 1e-300}),
json!({"body": "Body1", "size": 1e300, "order": 2}),
json!({"body": "Body1", "size": 4, "method": "sweep", "order": 2}),
+168 -33
View File
@@ -36,11 +36,22 @@
//! §6.6, eqs. 6.165–6.169 and 6.193, in Kirchhoff form). The split is what the mixed (hybrid)
//! elements need: they take the isochoric part from [`MaterialModel::isochoric`] and the
//! volumetric one from [`MaterialModel::volumetric`] with the element's own dilatation.
//! - **Mooney–Rivlin and Yeoh**: decoupled polynomials
//! W̄ = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ with the neo-Hookean volume; their fictitious stress and
//! elasticity from Holzapfel 2000, eqs. 6.193 and 6.168 ([`MaterialModel::Polynomial`]).
//! - **J2 plasticity** ([`crate::elastoplastic`]): Simo's multiplicative plasticity with the
//! exponential return map in the principal logarithmic strains (finite strain), or the
//! additive radial return (small strain, [`MaterialModel::small_strain`]); decoupled too, the
//! return deviatoric and the Hencky volume K/2 (ln J)².
use std::sync::Arc;
use fieldcraft_model::{Elastic, Hyperelastic};
use crate::dense::det3;
use crate::elastoplastic;
use crate::material::{Elasticity, elasticity, stress};
use crate::plasticity::J2;
use crate::rotation::{IDENTITY, Mat3, mul, transpose, voigt};
use crate::{Error, Result};
@@ -61,11 +72,24 @@ pub struct Response {
#[derive(Debug, Clone, PartialEq)]
pub enum MaterialModel {
StVenantKirchhoff(Box<Elasticity>),
NeoHookean { shear: f64, bulk: f64 },
NeoHookean {
shear: f64,
bulk: f64,
},
/// A decoupled polynomial in the isochoric invariants,
/// W̄ = C₁₀ (Ī₁ − 3) + C₀₁ (Ī₂ − 3) + C₂₀ (Ī₁ − 3)² + C₃₀ (Ī₁ − 3)³ with `c` = (C₁₀, C₀₁, C₂₀, C₃₀), and U = κ/2 (J − 1)²:
/// Mooney–Rivlin and Yeoh.
Polynomial {
name: &'static str,
c: [f64; 4],
bulk: f64,
},
/// Von Mises plasticity (shared: one per material, whatever the element count).
Plastic(Arc<J2>),
}
/// The 6 × 6 matrix of the symmetric fourth-order identity 𝕀 (engineering-shear columns).
fn identity4() -> Tangent {
pub(crate) fn identity4() -> Tangent {
let mut i4 = [[0.0; 6]; 6];
for k in 0..6 {
i4[k][k] = if k < 3 { 1.0 } else { 0.5 };
@@ -110,27 +134,35 @@ fn jacobian(f: &Mat3) -> Result<f64> {
}
impl MaterialModel {
/// The model of a material's elastic behaviour in a nonlinear step: neo-Hookean when the
/// material asks for it (its isotropic constants give μ and κ), St Venant–Kirchhoff
/// The model of a material's elastic behaviour in a nonlinear step: the hyperelastic option
/// when the material has one (its isotropic constants give μ and κ), St Venant–Kirchhoff
/// otherwise.
pub fn from_model(elastic: &Elastic, hyperelastic: Option<&Hyperelastic>) -> Result<MaterialModel> {
match hyperelastic {
None => Ok(MaterialModel::StVenantKirchhoff(Box::new(elasticity(elastic)?))),
Some(Hyperelastic::NeoHookean {}) => {
let (young, poisson) = crate::material::isotropic_constants(elastic, "neo-Hookean")?;
Some(h) => {
let (young, poisson) = crate::material::isotropic_constants(elastic, h.name())?;
let (shear, bulk) = Hyperelastic::moduli(young, poisson);
Ok(MaterialModel::NeoHookean { shear, bulk })
Ok(Self::hyperelastic(h, shear, bulk))
}
}
}
/// The hyperelastic model of an option with small-strain moduli μ and κ.
fn hyperelastic(h: &Hyperelastic, shear: f64, bulk: f64) -> MaterialModel {
match h {
Hyperelastic::NeoHookean {} => MaterialModel::NeoHookean { shear, bulk },
_ => MaterialModel::Polynomial { name: h.name(), c: h.constants(shear), bulk },
}
}
/// The model of an element whose elasticity is `d` (the small-strain one) and whose material
/// has the large-strain option `hyperelastic`: neo-Hookean takes μ = D₄₄ and κ = D₁₂ + ⅔ μ of
/// an isotropic `d`, St Venant–Kirchhoff `d` itself.
/// has the large-strain option `hyperelastic`: the hyperelastic models take μ = D₄₄ and
/// κ = D₁₂ + ⅔ μ of an isotropic `d`, St Venant–Kirchhoff `d` itself.
pub fn from_elasticity(d: &Elasticity, hyperelastic: Option<&Hyperelastic>) -> MaterialModel {
match hyperelastic {
None => MaterialModel::StVenantKirchhoff(Box::new(*d)),
Some(Hyperelastic::NeoHookean {}) => MaterialModel::NeoHookean { shear: d[3][3], bulk: d[0][1] + 2.0 / 3.0 * d[3][3] },
Some(h) => Self::hyperelastic(h, d[3][3], d[0][1] + 2.0 / 3.0 * d[3][3]),
}
}
@@ -138,23 +170,43 @@ impl MaterialModel {
match self {
MaterialModel::StVenantKirchhoff(_) => "St Venant–Kirchhoff",
MaterialModel::NeoHookean { .. } => "neo-Hookean",
MaterialModel::Polynomial { name, .. } => name,
MaterialModel::Plastic(_) => "J2 plastic",
}
}
/// History variables per integration point.
pub fn history_len(&self) -> usize {
0
match self {
MaterialModel::Plastic(_) => elastoplastic::HISTORY,
_ => 0,
}
}
/// Whether the response depends on the path (the material keeps history), so the solver
/// treats it with the inelastic kinematics and predictors.
pub fn is_path_dependent(&self) -> bool {
self.history_len() > 0
}
/// The equivalent plastic strain in one point's history; `None` for an elastic material.
pub fn plastic_strain(&self, history: &[f64]) -> Option<f64> {
match self {
MaterialModel::Plastic(_) => Some(elastoplastic::value(history, elastoplastic::PLASTIC_STRAIN)),
_ => None,
}
}
/// Whether the strain energy splits into isochoric and volumetric parts (what mixed
/// elements need).
pub fn is_decoupled(&self) -> bool {
matches!(self, MaterialModel::NeoHookean { .. })
!matches!(self, MaterialModel::StVenantKirchhoff(_))
}
/// The Kirchhoff stress and spatial tangent at deformation gradient `f`, from the history
/// The Kirchhoff stress and spatial tangent at deformation gradient `f` and (for
/// temperature-dependent plasticity) temperature `temperature`, from the history
/// `committed` (written, updated, to `updated`).
pub fn response(&self, f: &Mat3, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
pub fn response(&self, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
updated.iter_mut().zip(committed).for_each(|(u, c)| *u = *c);
let j = jacobian(f)?;
match self {
@@ -165,18 +217,25 @@ impl MaterialModel {
let t = push_forward(f);
Ok(Response { stress: std::array::from_fn(|a| (0..6).map(|b| t[a][b] * s[b]).sum()), tangent: sandwich(&t, d) })
}
MaterialModel::NeoHookean { .. } => {
let iso = self.isochoric(f)?;
let (p, dp) = self.volumetric(j).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
MaterialModel::NeoHookean { .. } | MaterialModel::Polynomial { .. } | MaterialModel::Plastic(_) => {
let iso = self.isochoric(f, temperature, committed, updated)?;
let (p, dp) = self.volumetric(j, false).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
Ok(add_pressure(&iso, j, p, j * dp))
}
}
}
/// The isochoric part of a decoupled material (τ_iso = dev τ̄ and its tangent).
pub fn isochoric(&self, f: &Mat3) -> Result<Response> {
let &MaterialModel::NeoHookean { shear: mu, .. } = self else {
return Err(Error::NotSupported(format!("the isochoric–volumetric split of a {} material", self.name())));
/// The isochoric part of a decoupled material (τ_iso = dev τ̄ and its tangent), from the
/// history `committed`; the history that changes is written to `updated`, which the caller
/// starts as a copy of `committed`.
pub fn isochoric(&self, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
let mu = match self {
MaterialModel::NeoHookean { shear, .. } => *shear,
MaterialModel::Plastic(j2) => return elastoplastic::finite_isochoric(j2, f, temperature, committed, updated),
MaterialModel::Polynomial { c, .. } => return polynomial_isochoric(c, f),
MaterialModel::StVenantKirchhoff(_) => {
return Err(Error::NotSupported(format!("the isochoric–volumetric split of a {} material", self.name())));
}
};
let j = jacobian(f)?;
let scale = j.powf(-2.0 / 3.0);
@@ -199,13 +258,74 @@ impl MaterialModel {
Ok(Response { stress: tau, tangent: c })
}
/// U′(J) and U″(J) of a decoupled material's volumetric energy.
pub fn volumetric(&self, j: f64) -> Option<(f64, f64)> {
match *self {
MaterialModel::NeoHookean { bulk, .. } => Some((bulk * (j - 1.0), bulk)),
/// U′(J) and U″(J) of a decoupled material's volumetric energy at dilatation J (with
/// `small`, J = 1 + tr ε of small strain, where every material's is K/2 (J − 1)²).
pub fn volumetric(&self, j: f64, small: bool) -> Option<(f64, f64)> {
match self {
MaterialModel::NeoHookean { bulk, .. } | MaterialModel::Polynomial { bulk, .. } => Some((bulk * (j - 1.0), *bulk)),
MaterialModel::Plastic(j2) => elastoplastic::volumetric(j2, j, small),
MaterialModel::StVenantKirchhoff(_) => None,
}
}
/// Small strain: the stress and its tangent at the (mechanical) strain `strain`
/// (engineering shears), from the history `committed` (written, updated, to `updated`);
/// `bulk` false keeps the deviatoric part only (what a mixed element takes). St
/// Venant–Kirchhoff is linear elasticity here; hyperelastic materials have no small-strain
/// form.
pub fn small_strain(&self, strain: &[f64; 6], temperature: Option<f64>, committed: &[f64], updated: &mut [f64], bulk: bool) -> Result<Response> {
updated.iter_mut().zip(committed).for_each(|(u, c)| *u = *c);
match self {
MaterialModel::Plastic(j2) => elastoplastic::small(j2, strain, temperature, committed, updated, bulk),
MaterialModel::StVenantKirchhoff(d) if bulk => Ok(Response { stress: stress(d, strain), tangent: **d }),
_ => Err(Error::NotSupported(format!("the {} material at small strain{}", self.name(), if bulk { "" } else { " in mixed elements" }))),
}
}
}
/// The isochoric response of the polynomial energy W̄(Ī₁, Ī₂) with `c` = (C₁₀, C₀₁, C₂₀, C₃₀)
/// (Holzapfel 2000, §6.5–6.6, in Kirchhoff form): the fictitious stress
/// τ̄ = 2 (Ψ₁ + Ī₁ Ψ₂) b̄ − 2 Ψ₂ b̄², τ_iso = dev τ̄, and with the fictitious elasticity
/// c̄ = 4 (Ψ₁₁ + Ψ₂) b̄ ⊗ b̄ − 4 Ψ₂ b̄ ⊙ b̄ (eq. 6.193 pushed forward, Ψ₁₂ = Ψ₂₂ = 0 here),
/// c_iso = ℙ : c̄ : ℙ + ⅔ tr τ̄ ℙ − ⅔ (τ_iso ⊗ I + I ⊗ τ_iso) (eq. 6.168). With C₁₀ alone it is
/// the neo-Hookean response.
fn polynomial_isochoric(c: &[f64; 4], f: &Mat3) -> Result<Response> {
let [c10, c01, c20, c30] = *c;
let j = jacobian(f)?;
let scale = j.powf(-2.0 / 3.0);
let b = mul(f, &transpose(f)).map(|row| row.map(|v| scale * v));
let b2 = mul(&b, &b);
let i1 = b[0][0] + b[1][1] + b[2][2];
let x = i1 - 3.0;
let (psi1, psi2, psi11) = (c10 + 2.0 * c20 * x + 3.0 * c30 * x * x, c01, 2.0 * c20 + 6.0 * c30 * x);
let (bv, b2v) = (voigt(&b), voigt(&b2));
let fict: [f64; 6] = std::array::from_fn(|a| 2.0 * (psi1 + i1 * psi2) * bv[a] - 2.0 * psi2 * b2v[a]);
let tr = fict[0] + fict[1] + fict[2];
let mut tau = fict;
for v in tau.iter_mut().take(3) {
*v -= tr / 3.0;
}
let mut cbar = [[0.0; 6]; 6];
for (a, &(i, jj)) in PAIRS.iter().enumerate() {
for (bb, &(k, l)) in PAIRS.iter().enumerate() {
cbar[a][bb] = 4.0 * (psi11 + psi2) * bv[a] * bv[bb] - 2.0 * psi2 * (b[i][k] * b[jj][l] + b[i][l] * b[jj][k]);
}
}
// ℙ : c̄ : ℙ = c̄ − ⅓ I ⊗ (I : c̄) − ⅓ (c̄ : I) ⊗ I + ⅑ (I : c̄ : I) I ⊗ I.
let row: [f64; 6] = std::array::from_fn(|a| (0..3).map(|bb| cbar[a][bb]).sum());
let col: [f64; 6] = std::array::from_fn(|bb| (0..3).map(|a| cbar[a][bb]).sum());
let all: f64 = (0..3).map(|a| row[a]).sum();
let i4 = identity4();
let mut out = [[0.0; 6]; 6];
for a in 0..6 {
for bb in 0..6 {
let (ia, ib) = (if a < 3 { 1.0 } else { 0.0 }, if bb < 3 { 1.0 } else { 0.0 });
let projected = cbar[a][bb] - ia * col[bb] / 3.0 - row[a] * ib / 3.0 + ia * ib * all / 9.0;
let projector = i4[a][bb] - ia * ib / 3.0;
out[a][bb] = projected + 2.0 / 3.0 * tr * projector - 2.0 / 3.0 * (tau[a] * ib + ia * tau[bb]);
}
}
Ok(Response { stress: tau, tangent: out })
}
/// An isochoric response with a pressure p acting on dilatation J: τ + J p I, and the tangent
@@ -269,7 +389,7 @@ mod tests {
/// The tangent's consistency: dS = C : dE with C the pull-back of c, checked by central
/// differences of S along several directions dF (dE = sym(Fᵀ dF), engineering shears).
fn check_tangent(m: &MaterialModel, f: &Mat3) {
let r = m.response(f, &[], &mut []).unwrap();
let r = m.response(f, None, &[], &mut []).unwrap();
let (inv, _) = inverse3(f).unwrap();
let back = sandwich(&push_forward(&inv), &r.tangent);
let scale = back.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
@@ -280,7 +400,7 @@ mod tests {
df[(k + 1) % 3][(k + 2) % 3] += 0.3;
let at = |s: f64| {
let fp: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] + s * df[i][j]));
pull_back_stress(&fp, &m.response(&fp, &[], &mut []).unwrap().stress)
pull_back_stress(&fp, &m.response(&fp, None, &[], &mut []).unwrap().stress)
};
let (sp, sm) = (at(h), at(-h));
let ftdf = mul(&transpose(f), &df);
@@ -300,13 +420,28 @@ mod tests {
check_tangent(&m, &deformation());
check_tangent(&m, &IDENTITY);
// Symmetric (hyperelastic): major symmetry of c.
let c = m.response(&deformation(), &[], &mut []).unwrap().tangent;
let c = m.response(&deformation(), None, &[], &mut []).unwrap().tangent;
for a in 0..6 {
for b in 0..6 {
assert!((c[a][b] - c[b][a]).abs() < 1e-9 * 5000.0, "{a} {b}");
}
}
}
// Mooney–Rivlin and Yeoh: consistent and symmetric; with C₁₀ alone the neo-Hookean model.
for m in [
MaterialModel::Polynomial { name: "Mooney–Rivlin", c: [120.0, 30.0, 0.0, 0.0], bulk: 5000.0 },
MaterialModel::Polynomial { name: "Yeoh", c: [150.0, 0.0, -12.0, 1.5], bulk: 5000.0 },
] {
check_tangent(&m, &deformation());
check_tangent(&m, &IDENTITY);
let c = m.response(&deformation(), None, &[], &mut []).unwrap().tangent;
assert!((0..36).all(|k| (c[k / 6][k % 6] - c[k % 6][k / 6]).abs() < 1e-9 * 5000.0), "{} symmetric", m.name());
}
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 5000.0 };
let poly = MaterialModel::Polynomial { name: "neo", c: [150.0, 0.0, 0.0, 0.0], bulk: 5000.0 };
let (a, b) = (neo.response(&deformation(), None, &[], &mut []).unwrap(), poly.response(&deformation(), None, &[], &mut []).unwrap());
assert!(a.stress.iter().zip(&b.stress).all(|(x, y)| (x - y).abs() < 1e-11 * 300.0));
assert!(a.tangent.iter().flatten().zip(b.tangent.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-10 * 5000.0));
let ortho = crate::material::orthotropic([1000.0, 300.0, 500.0], [0.3, 0.2, 0.25], [100.0, 120.0, 90.0]).unwrap();
check_tangent(&MaterialModel::StVenantKirchhoff(Box::new(ortho)), &deformation());
}
@@ -317,7 +452,7 @@ mod tests {
let d = isotropic(e, nu).unwrap();
let neo = MaterialModel::from_model(&Elastic::Isotropic { young: e, poisson: nu }, Some(&Hyperelastic::NeoHookean {})).unwrap();
for m in [MaterialModel::StVenantKirchhoff(Box::new(d)), neo] {
let c = m.response(&IDENTITY, &[], &mut []).unwrap().tangent;
let c = m.response(&IDENTITY, None, &[], &mut []).unwrap().tangent;
for a in 0..6 {
for b in 0..6 {
assert!((c[a][b] - d[a][b]).abs() < 1e-9 * e, "{}: {a} {b}", m.name());
@@ -329,7 +464,7 @@ mod tests {
let k = crate::rotation::skew(&axis);
let k2 = mul(&k, &k);
let rot: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| IDENTITY[i][j] + s * k[i][j] + (1.0 - cth) * k2[i][j]));
let tau = m.response(&rot, &[], &mut []).unwrap().stress;
let tau = m.response(&rot, None, &[], &mut []).unwrap().stress;
assert!(tau.iter().all(|v| v.abs() < 1e-12 * e), "{}: {tau:?}", m.name());
}
}
@@ -343,14 +478,14 @@ mod tests {
let (lam, l) = (1.5f64, 0.85f64);
let f = [[lam, 0.0, 0.0], [0.0, l, 0.0], [0.0, 0.0, l]];
let j = lam * l * l;
let tau = m.response(&f, &[], &mut []).unwrap().stress;
let tau = m.response(&f, None, &[], &mut []).unwrap().stress;
let i1 = lam * lam + 2.0 * l * l;
let sxx = mu / j.powf(5.0 / 3.0) * (lam * lam - i1 / 3.0) + kappa * (j - 1.0);
let syy = mu / j.powf(5.0 / 3.0) * (l * l - i1 / 3.0) + kappa * (j - 1.0);
assert!((tau[0] / j - sxx).abs() < 1e-10 * mu && (tau[1] / j - syy).abs() < 1e-10 * mu, "{tau:?}");
let e = logarithmic_strain(&f).unwrap();
assert!((e[0] - lam.ln()).abs() < 1e-14 && (e[1] - l.ln()).abs() < 1e-14 && e[3].abs() < 1e-14, "{e:?}");
assert!(m.response(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], &[], &mut []).is_err(), "inverted");
assert!(MaterialModel::StVenantKirchhoff(Box::new(isotropic(1.0, 0.3).unwrap())).isochoric(&IDENTITY).is_err());
assert!(m.response(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], None, &[], &mut []).is_err(), "inverted");
assert!(MaterialModel::StVenantKirchhoff(Box::new(isotropic(1.0, 0.3).unwrap())).isochoric(&IDENTITY, None, &[], &mut []).is_err());
}
}
+6 -4
View File
@@ -106,11 +106,13 @@ pub fn inverse3(m: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
}
/// The inverse and determinant of a Jacobian that maps a reference element onto a valid one:
/// `None` unless det J is positive beyond round-off relative to the product of its row norms.
/// `None` unless it is admissible by FieldCraft's one validity criterion
/// ([`fieldcraft_model::admissible_jacobian`], the one the meshers' validity gate proves).
pub fn positive_inverse(j: &[[f64; 3]; 3]) -> Option<([[f64; 3]; 3], f64)> {
let (inv, det) = inverse3(j)?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
(det > 1e-12 * norms).then_some((inv, det))
if !fieldcraft_model::admissible_jacobian(j) {
return None;
}
inverse3(j)
}
/// The eigenvalues and unit eigenvectors (the columns of the matrix returned) of a symmetric
+407
View File
@@ -0,0 +1,407 @@
//! Von Mises (J2) elastoplasticity at a material point of implicit (Newton) analysis: the
//! stress after an increment and its consistent tangent, at finite strain and at small strain.
//! Both kinematics call the radial return of [`crate::plasticity`], the update explicit
//! dynamics uses, so the yield surface, the hardening (bilinear isotropic or linear kinematic,
//! tabulated, Johnson–Cook) and the plastic strain increment of a trial state are one
//! implementation for both solvers.
//!
//! **Finite strain** (J. C. Simo, *Algorithms for static and dynamic multiplicative plasticity
//! that preserve the classical return mapping schemes of the infinitesimal theory*, CMAME
//! 99:61–112, 1992; Simo & Hughes 1998, ch. 9): the multiplicative split F = Fₑ Fₚ with the
//! elastic left Cauchy–Green tensor bₑ = F Cₚ⁻¹ Fᵀ and a Hencky (quadratic in the logarithmic
//! strain) elastic energy, so the Kirchhoff stress is τ = K ln J I + 2G dev εₑ with
//! εₑ = ½ ln bₑ. The history is Cₚ⁻¹ (stored as Cₚ⁻¹ − I, so the virgin state is zeros) and the
//! equivalent plastic strain. An increment freezes Cₚ⁻¹ at its committed value: the trial
//! bₑᵗʳ = F Cₚ⁻¹ Fᵀ, its principal stretches and directions, the trial logarithmic strains
//! ε_A = ½ ln λ_A², and then the **return map of the infinitesimal theory on the principal
//! values** (the exponential map integrates the flow rule exactly in the principal frame, which
//! the isotropy of the model keeps fixed during the return). The update writes back
//! bₑ = Σ exp(2 εₑ,A) n_A ⊗ n_A and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ, which keeps det Cₚ = 1 (plastic flow
//! is isochoric).
//!
//! **Its tangent.** With Cₚ⁻¹ frozen, the Lie derivative of bₑᵗʳ vanishes, so for a rate of
//! deformation d the trial strain changes by dεᵗʳ = ½ (∂ ln b/∂b) : (d b + b d), and
//! L_v τ = 𝔻ᵉᵖ : dεᵗʳ − (d τ + τ d), with 𝔻ᵉᵖ the algorithmic tangent of the return map
//! ([`crate::plasticity::J2::tangent`]). In the principal frame of b the derivative of the
//! logarithm is the Daleckii–Krein divided difference, so dεᵗʳ_AB = θ_AB d_AB with θ_AA = 1 and
//! θ_AB = ½ (λ²_A + λ²_B) ln(λ²_A/λ²_B)/(λ²_A − λ²_B) = artanh(r)/r, r = (λ²_A − λ²_B)/(λ²_A + λ²_B),
//! smooth through equal stretches (its series 1 + r²/3 + r⁴/5 + … near r = 0), so uniaxial and
//! undeformed states need no special case (Simo 1992, §3.3, gives the same tangent in spectral
//! form; R. Bhatia, *Matrix Analysis*, Springer 1997, §V.3, for the divided differences). The
//! tangent is symmetric for isotropic hardening: 𝔻ᵉᵖ and the θ scaling share the principal
//! frame.
//!
//! **Kinematic hardening at finite strain** is refused: a back stress is not coaxial with bₑ, so
//! the principal-frame return no longer holds, and it needs a formulation of its own (for
//! example the Lagrangian logarithmic strain space of C. Miehe, N. Apel, M. Lambrecht, CMAME
//! 191:5383–5425, 2002). It works with small strain.
//!
//! **Small strain** (Simo & Hughes 1998, §3.3): ε = εₑ + εₚ additively, the history the plastic
//! strain εₚ, the back stress α and the equivalent plastic strain; the trial εₑ = ε − εₚ,ₙ goes
//! through the same return, and εₚ advances by √(3/2) Δε̄ₚ n.
//!
//! **Mixed (hybrid) elements** take the isochoric part (the deviatoric return, without K) and the
//! volumetric energy U(J) = K/2 (ln J)², U′ = K ln J / J (finite strain), or U = K/2 (J − 1)² with
//! J the 1 + tr ε of small strain: J2 plasticity is pressure insensitive and isochoric, so the
//! split is exact.
//!
//! **History layout** per integration point ([`HISTORY`] values): 0–5 Cₚ⁻¹ − I (finite strain,
//! tensor components) or εₚ (small strain, engineering shears), 6–11 the back stress α, 12 the
//! equivalent plastic strain.
use crate::constitutive::{Response, Tangent};
use crate::dense::{inverse3, symmetric_eigen};
use crate::plasticity::{J2, PointState, Return};
use crate::rotation::{IDENTITY, Mat3, mul, tensor, transpose, voigt};
use crate::{Error, Result};
/// History values per integration point.
pub const HISTORY: usize = 13;
/// Where the equivalent plastic strain is in a point's history.
pub const PLASTIC_STRAIN: usize = 12;
/// History value `i` (0 when the slice is short).
pub(crate) fn value(h: &[f64], i: usize) -> f64 {
h.get(i).copied().unwrap_or(0.0)
}
/// Cₚ⁻¹ of a finite-strain history (stored as Cₚ⁻¹ − I).
fn plastic_stretch(h: &[f64]) -> Mat3 {
let stored = tensor(&std::array::from_fn(|k| value(h, k)));
std::array::from_fn(|i| std::array::from_fn(|j| IDENTITY[i][j] + stored[i][j]))
}
/// Store Cₚ⁻¹ as Cₚ⁻¹ − I in the first six history values.
fn store_plastic_stretch(cp: &Mat3, h: &mut [f64]) {
let v = voigt(cp);
for (k, slot) in h.iter_mut().take(6).enumerate() {
*slot = v[k] - if k < 3 { 1.0 } else { 0.0 };
}
}
/// artanh(r)/r, smooth through r = 0.
fn log_divided_difference(r: f64) -> f64 {
if r.abs() < 1e-4 {
let r2 = r * r;
1.0 + r2 * (1.0 / 3.0 + r2 * (1.0 / 5.0 + r2 / 7.0))
} else {
r.atanh() / r
}
}
/// The state a return starts from: the committed back stress and plastic strain, at the point's
/// temperature (Johnson–Cook softening; room temperature without one).
fn start(j2: &J2, committed: &[f64], temperature: Option<f64>) -> PointState {
let mut s = PointState::new(temperature.unwrap_or_else(|| j2.initial_temperature()));
s.back = std::array::from_fn(|k| value(committed, 6 + k));
s.plastic_strain = value(committed, PLASTIC_STRAIN);
s
}
fn singular() -> Error {
Error::Degenerate("the plastic material point has no tangent: its hardening falls faster than −3G (a softening material localises)".into())
}
/// The deviatoric trial stress 2G dev ε of a strain 6-vector (engineering shears), as tensor
/// components.
fn trial_deviator(j2: &J2, e: &[f64; 6]) -> [f64; 6] {
let mean = (e[0] + e[1] + e[2]) / 3.0;
let g2 = 2.0 * j2.shear;
[g2 * (e[0] - mean), g2 * (e[1] - mean), g2 * (e[2] - mean), j2.shear * e[3], j2.shear * e[4], j2.shear * e[5]]
}
/// Finite strain: the isochoric (deviatoric) Kirchhoff stress and its spatial tangent at
/// deformation gradient `f`, from the history `committed` (written, updated, to `updated`). Add
/// the volumetric part with [`volumetric`] (or the mixed element's pressure) for the whole
/// response.
pub fn finite_isochoric(j2: &J2, f: &Mat3, temperature: Option<f64>, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
if let Some(crate::plasticity::Flow::Linear { kinematic: true, .. }) = j2.flow {
return Err(Error::NotSupported(
"kinematic hardening at finite strain (its back stress is not coaxial with the elastic stretch; use nonlinear_geometry false or isotropic hardening)"
.into(),
));
}
let b = mul(&mul(f, &plastic_stretch(committed)), &transpose(f));
let (l, q) = symmetric_eigen(&b)
.filter(|(l, _)| l.iter().all(|x| *x > 0.0))
.ok_or_else(|| Error::Degenerate("the elastic stretch of a plastic material point is not positive (the material is inverted)".into()))?;
let eps = l.map(|x| 0.5 * x.ln());
let mut state = start(j2, committed, temperature);
let mut trial = trial_deviator(j2, &[eps[0], eps[1], eps[2], 0.0, 0.0, 0.0]);
let ret: Option<Return> = j2.radial_return(&mut state, &mut trial, 0.0);
// Back to the global frame: τ = Σ τ_A q_A ⊗ q_A, n likewise.
let spectral = |v: [f64; 3]| -> Mat3 { std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|a| v[a] * q[i][a] * q[j][a]).sum())) };
let tau = voigt(&spectral([trial[0], trial[1], trial[2]]));
let global = ret.map(|r| Return { normal: voigt(&spectral([r.normal[0], r.normal[1], r.normal[2]])), ..r });
let d = j2.tangent(global.as_ref(), false).ok_or_else(singular)?;
// θ_AB of the logarithm's derivative.
let theta: Mat3 =
std::array::from_fn(|a| std::array::from_fn(|bb| if a == bb { 1.0 } else { log_divided_difference((l[a] - l[bb]) / (l[a] + l[bb])) }));
let tau_m = tensor(&tau);
let qt = transpose(&q);
let mut c: Tangent = [[0.0; 6]; 6];
for (col, &(i, j)) in crate::constitutive::PAIRS.iter().enumerate() {
// The unit rate of deformation of this column (engineering shear 1: d_ij = d_ji = ½).
let mut dm = [[0.0; 3]; 3];
let half = if i == j { 1.0 } else { 0.5 };
dm[i][j] = half;
dm[j][i] = half;
let mut dp = mul(&mul(&qt, &dm), &q);
for a in 0..3 {
for bb in 0..3 {
dp[a][bb] *= theta[a][bb];
}
}
let de = voigt(&mul(&mul(&q, &dp), &qt));
let de = [de[0], de[1], de[2], 2.0 * de[3], 2.0 * de[4], 2.0 * de[5]];
let spin = voigt(&{
let (x, y) = (mul(&dm, &tau_m), mul(&tau_m, &dm));
std::array::from_fn(|r| std::array::from_fn(|s| x[r][s] + y[r][s]))
});
for row in 0..6 {
c[row][col] = (0..6).map(|k| d[row][k] * de[k]).sum::<f64>() - spin[row];
}
}
if let Some(r) = ret {
// bₑ = Σ exp(2 εₑ,A) q_A ⊗ q_A with εₑ = ε − √(3/2) Δε̄ₚ n, and Cₚ⁻¹ = F⁻¹ bₑ F⁻ᵀ.
let dg = (1.5f64).sqrt() * r.plastic_strain;
let be = spectral(std::array::from_fn(|a| (2.0 * (eps[a] - dg * r.normal[a])).exp()));
let (inv, _) = inverse3(f).ok_or_else(|| Error::Degenerate("the deformation gradient is singular".into()))?;
store_plastic_stretch(&mul(&mul(&inv, &be), &transpose(&inv)), updated);
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress: tau, tangent: c })
}
/// U′(J) and U″(J) of the Hencky volumetric energy K/2 (ln J)² (finite strain), or of
/// K/2 (J − 1)² for the dilatation J = 1 + tr ε of small strain.
pub fn volumetric(j2: &J2, j: f64, small: bool) -> Option<(f64, f64)> {
if !(j > 0.0 && j.is_finite()) {
return None;
}
let k = j2.bulk;
Some(if small { (k * (j - 1.0), k) } else { (k * j.ln() / j, k * (1.0 - j.ln()) / (j * j)) })
}
/// Small strain: the stress and the consistent tangent at total (mechanical) strain `strain`
/// (engineering shears), from the history `committed` (written, updated, to `updated`); with
/// `bulk` false only the deviatoric part (what a mixed element takes).
pub fn small(j2: &J2, strain: &[f64; 6], temperature: Option<f64>, committed: &[f64], updated: &mut [f64], bulk: bool) -> Result<Response> {
let elastic: [f64; 6] = std::array::from_fn(|k| strain[k] - value(committed, k));
let mut state = start(j2, committed, temperature);
let mut stress = trial_deviator(j2, &elastic);
let ret = j2.radial_return(&mut state, &mut stress, 0.0);
if bulk {
let p = j2.bulk * (elastic[0] + elastic[1] + elastic[2]);
for s in stress.iter_mut().take(3) {
*s += p;
}
}
let tangent = j2.tangent(ret.as_ref(), bulk).ok_or_else(singular)?;
if let Some(r) = ret {
let dg = (1.5f64).sqrt() * r.plastic_strain;
for (k, slot) in updated.iter_mut().take(6).enumerate() {
*slot = value(committed, k) + dg * r.normal[k] * if k < 3 { 1.0 } else { 2.0 };
}
for (k, slot) in updated.iter_mut().skip(6).take(6).enumerate() {
*slot = state.back[k];
}
if let Some(slot) = updated.get_mut(PLASTIC_STRAIN) {
*slot = state.plastic_strain;
}
}
Ok(Response { stress, tangent })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constitutive::{add_pressure, push_forward};
use fieldcraft_model::{Elastic, Hardening, HardeningPoint, Plastic};
const E: f64 = 200_000.0;
const NU: f64 = 0.3;
fn j2(plastic: Plastic) -> J2 {
J2::from_model(&Elastic::Isotropic { young: E, poisson: NU }, Some(&plastic), None).unwrap()
}
fn bilinear(h: f64, hardening: Hardening) -> J2 {
j2(Plastic::Bilinear { yield_stress: 250.0, hardening_modulus: h, hardening })
}
/// The whole finite-strain response, isochoric plus the Hencky volume.
fn finite(m: &J2, f: &Mat3, committed: &[f64], updated: &mut [f64]) -> Response {
let iso = finite_isochoric(m, f, None, committed, updated).unwrap();
let j = crate::dense::det3(f);
let (p, dp) = volumetric(m, j, false).unwrap();
add_pressure(&iso, j, p, j * dp)
}
/// The pulled-back tangent C (dS = C : dE) against central differences of S along several
/// directions of F, as `constitutive::tests::check_tangent` does, from fixed history.
fn check_finite_tangent(m: &J2, f: &Mat3, committed: &[f64]) -> f64 {
let mut scratch = vec![0.0; HISTORY];
let r = finite(m, f, committed, &mut scratch);
let (inv, _) = inverse3(f).unwrap();
let t = push_forward(&inv);
let back: Tangent =
std::array::from_fn(|a| std::array::from_fn(|b| (0..6).map(|k| (0..6).map(|l| t[a][k] * r.tangent[k][l] * t[b][l]).sum::<f64>()).sum()));
let scale = back.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
let h = 1e-7;
let mut worst: f64 = 0.0;
for k in 0..9 {
let mut df = [[0.0; 3]; 3];
df[k / 3][k % 3] = 1.0;
df[(k + 1) % 3][(k + 2) % 3] += 0.3;
let at = |s: f64| {
let fp: Mat3 = std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] + s * df[i][j]));
let mut scratch = vec![0.0; HISTORY];
let tau = finite(m, &fp, committed, &mut scratch).stress;
crate::rotation::rotate(&inverse3(&fp).unwrap().0, &tau)
};
let (sp, sm) = (at(h), at(-h));
let ftdf = mul(&transpose(f), &df);
let de = [ftdf[0][0], ftdf[1][1], ftdf[2][2], ftdf[0][1] + ftdf[1][0], ftdf[0][2] + ftdf[2][0], ftdf[1][2] + ftdf[2][1]];
for a in 0..6 {
let numeric = (sp[a] - sm[a]) / (2.0 * h);
let analytic: f64 = (0..6).map(|b| back[a][b] * de[b]).sum();
worst = worst.max((numeric - analytic).abs() / scale);
}
}
worst
}
#[test]
fn finite_strain_tangent_is_consistent_elastic_and_plastic() {
let deformations: [Mat3; 3] = [
[[1.02, 0.015, -0.005], [-0.01, 0.99, 0.02], [0.007, -0.012, 1.005]],
// Large: well into plasticity.
[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]],
// Uniaxial (two equal principal stretches).
[[1.2, 0.0, 0.0], [0.0, 0.92, 0.0], [0.0, 0.0, 0.92]],
];
let tables = Plastic::Table { points: vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 0.5, stress: 600.0 }] };
let jc = Plastic::JohnsonCook {
a: 350.0,
b: 275.0,
n: 0.36,
c: 0.0,
m: 1.0,
reference_strain_rate: 1.0,
room_temperature: 293.0,
melt_temperature: 1356.0,
specific_heat: None,
taylor_quinney: 0.9,
};
// A plastic history to start from: a prior stretch committed.
let mut prior = vec![0.0; HISTORY];
finite(&bilinear(2000.0, Hardening::Isotropic), &[[1.1, 0.02, 0.0], [0.0, 0.95, 0.03], [0.0, 0.0, 0.96]], &[0.0; HISTORY], &mut prior);
assert!(prior[PLASTIC_STRAIN] > 0.0);
for m in [bilinear(2000.0, Hardening::Isotropic), bilinear(0.0, Hardening::Isotropic), j2(tables), j2(jc)] {
for f in &deformations {
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let worst = check_finite_tangent(&m, f, committed);
assert!(worst < 1e-7, "{:?} at {f:?}: {worst}", m.flow);
}
}
}
}
#[test]
fn finite_strain_tangent_is_symmetric_and_elastic_at_rest() {
let m = bilinear(2000.0, Hardening::Isotropic);
let mut scratch = vec![0.0; HISTORY];
let r = finite(&m, &[[1.3, 0.15, -0.05], [-0.1, 0.85, 0.2], [0.07, -0.12, 1.05]], &[0.0; HISTORY], &mut scratch);
for a in 0..6 {
for b in 0..6 {
assert!((r.tangent[a][b] - r.tangent[b][a]).abs() < 1e-9 * E, "{a} {b}");
}
}
let rest = finite(&m, &IDENTITY, &[0.0; HISTORY], &mut scratch);
let d = crate::material::isotropic(E, NU).unwrap();
for a in 0..6 {
for b in 0..6 {
assert!((rest.tangent[a][b] - d[a][b]).abs() < 1e-9 * E);
}
}
assert!(rest.stress.iter().all(|v| *v == 0.0));
}
/// Uniaxial stress along x at finite strain, from a virgin point to a logarithmic strain ε
/// in one increment (the lateral stretch found so σ_yy = 0): the Kirchhoff stress is the
/// small-strain curve in the logarithmic strain, τ = σ_y + E_t (ε − σ_y/E), because the
/// exponential map integrates a proportional path exactly.
#[test]
fn finite_uniaxial_matches_the_logarithmic_closed_form() {
let (y0, h) = (250.0, 2000.0);
let m = bilinear(h, Hardening::Isotropic);
let strain: f64 = 0.2;
let lam = strain.exp();
let sigma = |l: f64| {
let f = [[lam, 0.0, 0.0], [0.0, l, 0.0], [0.0, 0.0, l]];
let mut u = vec![0.0; HISTORY];
let r = finite(&m, &f, &[0.0; HISTORY], &mut u);
let j = lam * l * l;
(r.stress[0] / j, r.stress[1] / j, u)
};
// Secant on the lateral stretch.
let (mut a, mut b) = (1.0, 0.9);
for _ in 0..60 {
let (fa, fb) = (sigma(a).1, sigma(b).1);
if fb.abs() < 1e-11 || fb == fa {
break;
}
(a, b) = (b, b - fb * (b - a) / (fb - fa));
}
let (sxx, _, u) = sigma(b);
let j = lam * b * b;
// In Kirchhoff stress τ = J σ: τ = y0 + E_t (ε − y0/E) along the log strain path.
let et = E * h / (E + h);
let exact = y0 + et * (strain - y0 / E);
assert!((sxx * j - exact).abs() < 1e-8 * exact, "{} vs {exact}", sxx * j);
assert!((u[PLASTIC_STRAIN] - (exact - y0) / h).abs() < 1e-10);
// The plastic deformation is isochoric: det Cₚ⁻¹ = 1.
let cp = plastic_stretch(&u);
assert!((crate::dense::det3(&cp) - 1.0).abs() < 1e-12);
}
#[test]
fn small_strain_tangent_is_consistent_and_cyclic_kinematic_shows_bauschinger() {
let m = bilinear(10_000.0, Hardening::Kinematic);
let strain = [3e-3, -1e-3, 5e-4, 2e-3, -1e-3, 7e-4];
let mut prior = vec![0.0; HISTORY];
small(&m, &[2e-3, -1e-3, -1e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut prior, true).unwrap();
for committed in [&[0.0; HISTORY][..], &prior[..]] {
let mut scratch = vec![0.0; HISTORY];
let r = small(&m, &strain, None, committed, &mut scratch, true).unwrap();
let h = 1e-9;
for b in 0..6 {
let at = |s: f64| {
let mut e = strain;
e[b] += s;
small(&m, &e, None, committed, &mut [0.0; HISTORY], true).unwrap().stress
};
let (p, q) = (at(h), at(-h));
for a in 0..6 {
let numeric = (p[a] - q[a]) / (2.0 * h);
assert!((numeric - r.tangent[a][b]).abs() < 1e-5 * E, "{a} {b}: {numeric} vs {}", r.tangent[a][b]);
}
}
}
// Kinematic hardening is refused at finite strain, named.
assert!(matches!(finite_isochoric(&m, &IDENTITY, None, &[0.0; HISTORY], &mut [0.0; HISTORY]), Err(Error::NotSupported(_))));
}
#[test]
fn softening_steeper_than_3g_has_no_tangent() {
let g = E / (2.0 * (1.0 + NU));
let points = vec![HardeningPoint { strain: 0.0, stress: 250.0 }, HardeningPoint { strain: 1e-4, stress: 250.0 - 4.0 * g * 1e-4 }];
let m = j2(Plastic::Table { points });
let r = small(&m, &[5e-3, -2.5e-3, -2.5e-3, 0.0, 0.0, 0.0], None, &[0.0; HISTORY], &mut [0.0; HISTORY], true);
assert!(matches!(r, Err(Error::Degenerate(_))), "{r:?}");
}
}
+2 -2
View File
@@ -35,7 +35,7 @@
use fieldcraft_model::ElementKind;
use crate::basis::Basis;
use crate::dense::{Cholesky, Matrix, inverse3, positive_inverse};
use crate::dense::{Cholesky, Matrix, positive_inverse};
use crate::dof::{DofSet, ElementDofs, Internal};
use crate::material::{Elasticity, stress};
use crate::quadrature::{Point, Rule};
@@ -356,7 +356,7 @@ impl<'a> Solid<'a> {
fn centre_jacobian(&self) -> Result<([[f64; 3]; 3], f64)> {
let s = (self.ty.shape)([0.0; 3]);
inverse3(&self.jacobian(&s)).filter(|(_, d)| *d > 0.0).ok_or_else(|| self.degenerate())
positive_inverse(&self.jacobian(&s)).ok_or_else(|| self.degenerate())
}
/// The parts of the stiffness with the incompatible modes: K_uu (when `with_uu`), K_ua
+483 -83
View File
@@ -1,6 +1,6 @@
//! Finite-strain (total Lagrangian) solid and 2D elements: internal forces and the consistent
//! tangent stiffness for nodal displacements u, and the Cauchy stress and logarithmic strain at
//! the integration points.
//! Solid and 2D elements of nonlinear steps: total Lagrangian (finite strain) or with small
//! strain and an inelastic material; internal forces and the consistent tangent stiffness for
//! nodal displacements u, and the Cauchy stress and the strain at the integration points.
//!
//! **Formulation.** Total Lagrangian: the integrals run over the reference (undeformed) element
//! with its own quadrature, F = I + Σ u_a ⊗ ∇₀N_a at each point, and the material returns the
@@ -26,7 +26,26 @@
//! symmetric; at u = 0 it is the linear hybrid stiffness K_dev + κ G M⁻¹ Gᵀ. The constant
//! pressure case is the mean dilatation method of J. C. Nagtegaal, D. M. Parks, J. R. Rice
//! (CMAME 4:153–177, 1974) at finite strain. St Venant–Kirchhoff has no such split, so hybrid
//! elements of an elastic material are refused in nonlinear steps.
//! elements of an elastic material are refused in nonlinear steps; J2 plasticity has it exactly
//! (the return is deviatoric, the volume Hencky's K/2 (ln J)², [`crate::elastoplastic`]).
//!
//! **Thermal strain** ([`Temperature`]): at finite strain the multiplicative split F = F_m F_θ
//! with the thermal stretches F_θ = diag(1 + αᵢ (T − T_ref)) at each point (T interpolated from
//! the nodes; V. A. Lubarda, *Constitutive theories based on the multiplicative decomposition of
//! deformation gradient: thermoelasticity, elastoplasticity, and biomechanics*, Appl. Mech. Rev.
//! 57(2):95–108, 2004): the material sees
//! F_m, the stress per reference volume is J_θ τ_m(F_m), and since F_θ is fixed over an
//! iteration the spatial tangent is J_θ c_m, so the loops run unchanged on the thermally
//! expanded volume J_θ dV₀ (the mixed elements project det F_m on it). The linear stretch
//! 1 + α ΔT is the engineering definition of α, so a free body grows by exactly α ΔT. At small
//! strain the split is additive, ε_m = ε − α ΔT. Reported strains are the total ones.
//!
//! **Small strain** ([`Kinematics::Small`], a step without nonlinear geometry with an inelastic
//! material): the same loops on the linear kinematics, ε = sym ∇u with the reference gradients,
//! f = ∫ Bᵀ σ dV₀ and K = ∫ Bᵀ 𝔻 B dV₀ with the material's consistent tangent 𝔻, and no
//! geometric stiffness. Mixed elements project the dilatation 1 + tr ε and the pressure K tr ε
//! the same way, which is the linear hybrid element of [`crate::hybrid`] while the material is
//! elastic.
//!
//! **2D elements**: plane strain (F₃₃ = 1) and axisymmetric (F₃₃ = r/R, the hoop stretch, with
//! the hoop column N/r of the current radius in Bₓ and a geometric term τ_θθ N_a N_b / r² on the
@@ -37,7 +56,8 @@
//! (enhanced assumed strain, J. C. Simo, F. Armero, IJNME 33:1413–1449, 1992) needs the internal
//! modes as element unknowns carried between iterations, and it develops spurious hourglass
//! modes in large compression (P. Wriggers, S. Reese, CMAME 135:201–209, 1996), which want a
//! stabilised variant of their own.
//! stabilised variant of their own. With small strain they are refused for inelastic materials
//! too: their internal modes would carry the plastic history between iterations.
use crate::constitutive::{MaterialModel, Response, add_pressure, logarithmic_strain};
use crate::dense::{Cholesky, Matrix, inverse3};
@@ -54,12 +74,87 @@ pub struct Finite {
pub tangent: Option<Matrix>,
}
/// The kinematics of an evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kinematics {
/// Total Lagrangian, large displacements, rotations and strains.
Finite,
/// Small displacements and strains (ε = sym ∇u, the reference gradients, no geometric
/// stiffness): only the material is nonlinear.
Small,
}
/// The temperatures of an element in a nonlinear step: the linear expansion coefficients along
/// X, Y, Z (zero for a material that does not expand), the reference temperature at which the
/// thermal strain is zero, and the temperature at each node.
#[derive(Debug, Clone, PartialEq)]
pub struct Temperature {
pub alpha: [f64; 3],
pub reference: f64,
pub nodal: Vec<f64>,
}
impl Temperature {
fn check(&self, nodes: usize) -> Result<()> {
if self.nodal.len() != nodes {
return Err(Error::Invalid(format!("{} temperatures for a {nodes}-node element", self.nodal.len())));
}
if self.alpha.iter().chain(&self.nodal).chain([&self.reference]).any(|v| !v.is_finite()) {
return Err(Error::Invalid("temperatures and expansion coefficients must be finite".into()));
}
Ok(())
}
}
/// The temperature at a point and its thermal stretches ϑᵢ = 1 + αᵢ (T − T_ref).
#[derive(Debug, Clone, Copy)]
struct Heat {
temperature: Option<f64>,
stretch: [f64; 3],
}
impl Heat {
/// At a point of shape function values `n`.
fn at(t: Option<&Temperature>, n: &[f64]) -> Result<Heat> {
let Some(t) = t else { return Ok(Heat { temperature: None, stretch: [1.0; 3] }) };
let temperature: f64 = t.nodal.iter().zip(n).map(|(a, b)| a * b).sum();
let stretch = t.alpha.map(|a| 1.0 + a * (temperature - t.reference));
if !stretch.iter().all(|v| *v > 0.0 && v.is_finite()) {
return Err(Error::Degenerate(format!("the thermal stretches {stretch:?} at T = {temperature} are not positive")));
}
Ok(Heat { temperature: Some(temperature), stretch })
}
/// J_θ = ϑ₁ ϑ₂ ϑ₃.
fn volume(&self) -> f64 {
self.stretch.iter().product()
}
/// The mechanical part F_m = F F_θ⁻¹ of a deformation gradient.
fn mechanical(&self, f: &Mat3) -> Mat3 {
std::array::from_fn(|i| std::array::from_fn(|j| f[i][j] / self.stretch[j]))
}
/// The mechanical part ε − α (T − T_ref) of a small strain.
fn mechanical_strain(&self, e: &[f64; 6]) -> [f64; 6] {
std::array::from_fn(|k| if k < 3 { e[k] - (self.stretch[k] - 1.0) } else { e[k] })
}
}
/// What one integration point gives the element.
struct PointKinematics {
/// The point's share of the reference volume (of the thermally expanded volume J_θ dV₀ at
/// finite strain).
weight: f64,
/// The deformation gradient, and its mechanical part F_m = F F_θ⁻¹ the material sees.
total: Mat3,
f: Mat3,
/// det F_m, or the dilatation 1 + tr ε_m of small strain (what a mixed element projects).
j: f64,
/// ∇ₓN of every node.
/// The mechanical small strain ε − ε_th (engineering shears); zero at finite strain.
strain: [f64; 6],
temperature: Option<f64>,
/// ∇ₓN of every node (∇₀N at small strain).
gradients: Vec<[f64; 3]>,
/// Reference position.
x0: [f64; 3],
@@ -70,8 +165,22 @@ fn point_history(history: &[f64], len: usize, point: usize) -> &[f64] {
history.get(point * len..(point + 1) * len).unwrap_or_default()
}
/// Add f_a += w Bₓ,aᵀ τ and, with `k`, the material and geometric tangent.
fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]], r: &Response, w: f64) {
/// The small strain of a displacement gradient F − I (engineering shears).
fn small_strain(f: &Mat3) -> [f64; 6] {
[f[0][0] - 1.0, f[1][1] - 1.0, f[2][2] - 1.0, f[0][1] + f[1][0], f[0][2] + f[2][0], f[1][2] + f[2][1]]
}
/// The response of one point's material with its history slices.
fn point_response(m: &MaterialModel, kin: Kinematics, p: &PointKinematics, committed: &[f64], updated: &mut [f64]) -> Result<Response> {
match kin {
Kinematics::Finite => m.response(&p.f, p.temperature, committed, updated),
Kinematics::Small => m.small_strain(&p.strain, p.temperature, committed, updated, true),
}
}
/// Add f_a += w Bₓ,aᵀ τ and, with `k`, the material tangent and (with `geometric`) the
/// geometric one.
fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]], r: &Response, w: f64, geometric: bool) {
for (a, g) in gradients.iter().enumerate() {
let b = b_block(g);
for c in 0..3 {
@@ -82,6 +191,9 @@ fn add_point(force: &mut [f64], k: Option<&mut Matrix>, gradients: &[[f64; 3]],
}
let Some(k) = k else { return };
accumulate_symmetric(&mut k.data, gradients, &r.tangent, w);
if !geometric {
return;
}
let tau = crate::rotation::tensor(&r.stress);
let n = k.n;
for (a, ga) in gradients.iter().enumerate() {
@@ -122,14 +234,17 @@ fn check_history(m: &MaterialModel, points: usize, committed: &[f64], updated: &
}
impl Solid<'_> {
/// Refuse what has no finite-strain form here.
fn check_finite(&self, m: &MaterialModel, u: &[[f64; 3]]) -> Result<()> {
/// Refuse what has no form here.
fn check_nonlinear(&self, m: &MaterialModel, u: &[[f64; 3]], kin: Kinematics) -> Result<()> {
check_displacements(u, self.ty.nodes)?;
match self.formulation {
Formulation::IncompatibleModes => Err(Error::NotSupported(format!(
"{} incompatible modes in nonlinear steps (their finite-strain form, enhanced assumed strain, is not implemented; use full integration, hybrid or hex20)",
self.ty.kind.name()
))),
Formulation::IncompatibleModes => {
let why = match kin {
Kinematics::Finite => "in nonlinear steps (their finite-strain form, enhanced assumed strain, is not implemented",
Kinematics::Small => "with an inelastic material (their internal modes would carry the plastic history",
};
Err(Error::NotSupported(format!("{} incompatible modes {why}; use full integration, hybrid or hex20)", self.ty.kind.name())))
}
Formulation::Hybrid { .. } if !m.is_decoupled() => Err(Error::NotSupported(format!(
"hybrid elements of a {} material in nonlinear steps (the mixed formulation needs a volumetric–isochoric split of the strain energy; give the material the neo-Hookean option)",
m.name()
@@ -138,59 +253,87 @@ impl Solid<'_> {
}
}
/// F, J and the current gradients at every stiffness point.
fn kinematics(&self, u: &[[f64; 3]]) -> Result<Vec<PointKinematics>> {
/// F, J and the gradients at every stiffness point: the current ones at finite strain, the
/// reference ones at small strain.
fn kinematics(&self, u: &[[f64; 3]], kin: Kinematics, thermal: Option<&Temperature>) -> Result<Vec<PointKinematics>> {
let n = self.ty.nodes;
if let Some(t) = thermal {
t.check(n)?;
}
let mut out = Vec::new();
for p in self.rule().points() {
let g = self.gradients(p.xi)?;
let reference = g.dx.get(..n).unwrap_or_default();
let f = crate::constitutive::deformation_gradient(u, reference);
let (inv, j) = inverse3(&f).ok_or_else(|| inverted(self.ty.kind.name(), 0.0))?;
if !(j > 0.0 && j.is_finite()) {
return Err(inverted(self.ty.kind.name(), j));
let total = crate::constitutive::deformation_gradient(u, reference);
let (weight, x0) = (g.det * p.weight, self.position_of(g.shape.values()));
let heat = Heat::at(thermal, g.shape.values())?;
if kin == Kinematics::Small {
let strain = heat.mechanical_strain(&small_strain(&total));
let j = 1.0 + strain[0] + strain[1] + strain[2];
let gradients = reference.to_vec();
out.push(PointKinematics { weight, total, f: total, j, strain, temperature: heat.temperature, gradients, x0 });
continue;
}
let (inv, det) = inverse3(&total).ok_or_else(|| inverted(self.ty.kind.name(), 0.0))?;
let j = det / heat.volume();
if !(det > 0.0 && j.is_finite()) {
return Err(inverted(self.ty.kind.name(), det));
}
let gradients = reference.iter().map(|d| std::array::from_fn(|i| (0..3).map(|jj| d[jj] * inv[jj][i]).sum())).collect();
out.push(PointKinematics { weight: g.det * p.weight, f, j, gradients, x0: self.position_of(g.shape.values()) });
let f = heat.mechanical(&total);
out.push(PointKinematics { weight: weight * heat.volume(), total, f, j, strain: [0.0; 6], temperature: heat.temperature, gradients, x0 });
}
Ok(out)
}
/// The effective response (Kirchhoff stress and tangent) at every point, the forces, and the
/// tangent when `tangent`.
fn finite_points(
/// The effective response (Kirchhoff stress and tangent, or the stress of small strain) at
/// every point, the forces, and the tangent when `tangent`.
#[allow(clippy::too_many_arguments)]
fn nonlinear_points(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<(Finite, Vec<(PointKinematics, Response)>)> {
self.check_finite(m, u)?;
let points = self.kinematics(u)?;
self.check_nonlinear(m, u, kin)?;
let points = self.kinematics(u, kin, thermal)?;
let len = check_history(m, points.len(), committed, updated)?;
let ndof = 3 * self.ty.nodes;
let mut force = vec![0.0; ndof];
let mut k = tangent.then(|| Matrix::zeros(ndof));
let responses: Vec<Response> = if let Formulation::Hybrid { .. } = self.formulation {
self.mixed_responses(m, &points, k.as_mut())?
self.mixed_responses(m, &points, kin, committed, updated, k.as_mut())?
} else {
let mut out = Vec::with_capacity(points.len());
for (i, p) in points.iter().enumerate() {
let updated_point = updated.get_mut(i * len..(i + 1) * len).unwrap_or_default();
out.push(m.response(&p.f, point_history(committed, len, i), updated_point)?);
out.push(point_response(m, kin, p, point_history(committed, len, i), updated_point)?);
}
out
};
for (p, r) in points.iter().zip(&responses) {
add_point(&mut force, k.as_mut(), &p.gradients, r, p.weight);
add_point(&mut force, k.as_mut(), &p.gradients, r, p.weight, kin == Kinematics::Finite);
}
Ok((Finite { force, tangent: k }, points.into_iter().zip(responses).collect()))
}
/// The three-field mixed responses τ_iso + J p I with their tangents, and the dilatation
/// stiffness Gᵀ M⁻¹ H M⁻¹ G added to `k`.
fn mixed_responses(&self, m: &MaterialModel, points: &[PointKinematics], k: Option<&mut Matrix>) -> Result<Vec<Response>> {
/// The three-field mixed responses τ_iso + J p I with their tangents (small strain:
/// s + p I), and the dilatation stiffness Gᵀ M⁻¹ H M⁻¹ G added to `k`.
fn mixed_responses(
&self,
m: &MaterialModel,
points: &[PointKinematics],
kin: Kinematics,
committed: &[f64],
updated: &mut [f64],
k: Option<&mut Matrix>,
) -> Result<Vec<Response>> {
let small = kin == Kinematics::Small;
let len = m.history_len();
let (np, frame) = (self.np(), self.frame());
let basis: Vec<[f64; 4]> = points.iter().map(|p| pressure_basis(np, p.x0, &frame)).collect();
let mut mm = Matrix::zeros(np);
@@ -212,7 +355,7 @@ impl Solid<'_> {
if !(th > 0.0 && th.is_finite()) {
return Err(inverted(self.ty.kind.name(), th));
}
let (dp, ddp) = m.volumetric(th).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
let (dp, ddp) = m.volumetric(th, small).ok_or_else(|| Error::Invalid("volumetric response".into()))?;
for a in 0..np {
pressure[a] += p.weight * nb[a] * dp;
for b in 0..np {
@@ -222,20 +365,28 @@ impl Solid<'_> {
}
chol.solve(&mut pressure);
let mut out = Vec::with_capacity(points.len());
for (p, nb) in points.iter().zip(&basis) {
for (i, (p, nb)) in points.iter().zip(&basis).enumerate() {
let pr: f64 = (0..np).map(|a| nb[a] * pressure[a]).sum();
out.push(add_pressure(&m.isochoric(&p.f)?, p.j, pr, 0.0));
let (c, u) = (point_history(committed, len, i), updated.get_mut(i * len..(i + 1) * len).unwrap_or_default());
out.push(if small {
let mut r = m.small_strain(&p.strain, p.temperature, c, u, false)?;
r.stress.iter_mut().take(3).for_each(|s| *s += pr);
r
} else {
add_pressure(&m.isochoric(&p.f, p.temperature, c, u)?, p.j, pr, 0.0)
});
}
if let Some(k) = k {
// A = M⁻¹ G, np × ndof; K += Aᵀ H A.
// A = M⁻¹ G, np × ndof; K += Aᵀ H A. At small strain G = ∫ N_p ∇₀N dV₀.
let ndof = k.n;
let mut a_cols = vec![vec![0.0; np]; ndof];
for (p, nb) in points.iter().zip(&basis) {
let jw = p.weight * if small { 1.0 } else { p.j };
for (node, g) in p.gradients.iter().enumerate() {
for c in 0..3 {
if let Some(col) = a_cols.get_mut(3 * node + c) {
for (x, n) in col.iter_mut().zip(nb) {
*x += p.weight * n * p.j * g[c];
*x += jw * n * g[c];
}
}
}
@@ -262,32 +413,71 @@ impl Solid<'_> {
/// with the material history `committed` at the start of the increment (points × the
/// material's history length) written, updated, to `updated`.
pub fn finite_strain(&self, m: &MaterialModel, u: &[[f64; 3]], committed: &[f64], updated: &mut [f64], tangent: bool) -> Result<Finite> {
Ok(self.finite_points(m, u, committed, updated, tangent)?.0)
Ok(self.nonlinear_points(m, u, Kinematics::Finite, None, committed, updated, tangent)?.0)
}
/// The Cauchy stress σ = τ/J and the logarithmic strain at every integration point (at its
/// reference position).
pub fn finite_strain_stresses(&self, m: &MaterialModel, u: &[[f64; 3]], committed: &[f64]) -> Result<Vec<PointStress>> {
/// As [`Self::finite_strain`] with either kinematics and the element's temperatures when
/// it has some; with small strain an inelastic material on the linear kinematics,
/// f = ∫ Bᵀ σ dV and K = ∫ Bᵀ 𝔻 B dV with the material's consistent tangent 𝔻.
#[allow(clippy::too_many_arguments)]
pub fn nonlinear(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<Finite> {
Ok(self.nonlinear_points(m, u, kin, thermal, committed, updated, tangent)?.0)
}
/// The Cauchy stress and the total strain (logarithmic at finite strain, ε at small strain)
/// at every integration point (at its reference position).
pub fn nonlinear_stresses(
&self,
m: &MaterialModel,
u: &[[f64; 3]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
) -> Result<Vec<PointStress>> {
let mut scratch = committed.to_vec();
let (_, points) = self.finite_points(m, u, committed, &mut scratch, false)?;
let (_, points) = self.nonlinear_points(m, u, kin, thermal, committed, &mut scratch, false)?;
points
.iter()
.map(|(p, r)| {
let strain = logarithmic_strain(&p.f).ok_or_else(|| inverted(self.ty.kind.name(), p.j))?;
if kin == Kinematics::Small {
return Ok(PointStress { xyz: p.x0, strain: small_strain(&p.total), stress: r.stress });
}
let strain = logarithmic_strain(&p.total).ok_or_else(|| inverted(self.ty.kind.name(), p.j))?;
Ok(PointStress { xyz: p.x0, strain, stress: r.stress.map(|v| v / p.j) })
})
.collect()
}
}
/// One integration point of a 2D element in a nonlinear step: the total deformation gradient,
/// det F_m, the reference position and the response.
struct PlanePoint {
f: Mat3,
j: f64,
x: [f64; 3],
response: Response,
}
impl Plane<'_> {
fn check_finite(&self, u: &[[f64; 2]]) -> Result<()> {
fn check_nonlinear(&self, u: &[[f64; 2]], kin: Kinematics) -> Result<()> {
check_displacements(u, self.ty.nodes)?;
if let Theory::PlaneStress { .. } = self.theory {
return Err(Error::NotSupported(
"plane stress elements in nonlinear steps (the thickness change needs a local iteration; use plane strain, axisymmetric or solid elements)"
.into(),
));
let why = match kin {
Kinematics::Finite => "the thickness change needs a local iteration",
Kinematics::Small => "σ_zz = 0 needs a local iteration of the inelastic material",
};
return Err(Error::NotSupported(format!(
"plane stress elements in nonlinear steps ({why}; use plane strain, axisymmetric or solid elements)"
)));
}
if self.formulation == Formulation::IncompatibleModes {
return Err(Error::NotSupported(format!(
@@ -298,24 +488,31 @@ impl Plane<'_> {
Ok(())
}
/// Per point: (dv, F, J, current columns (∂N/∂x, ∂N/∂y, N/r), reference position, response).
#[allow(clippy::type_complexity)]
fn finite_points(
/// Per point: F, J, the reference position, the small strain and the response; the forces
/// and the tangent.
#[allow(clippy::too_many_arguments)]
fn nonlinear_points(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<(Finite, Vec<(Mat3, f64, [f64; 3], Response)>)> {
self.check_finite(u)?;
) -> Result<(Finite, Vec<PlanePoint>)> {
self.check_nonlinear(u, kin)?;
let n = self.ty.nodes;
if let Some(t) = thermal {
t.check(n)?;
}
let rule = self.rule().points();
let len = check_history(m, rule.len(), committed, updated)?;
let ndof = 2 * n;
let mut force = vec![0.0; ndof];
let mut k = tangent.then(|| Matrix::zeros(ndof));
let axisymmetric = self.theory == Theory::Axisymmetric;
let small = kin == Kinematics::Small;
let mut out = Vec::with_capacity(rule.len());
for (i, p) in rule.iter().enumerate() {
let at = self.at(p)?;
@@ -331,37 +528,47 @@ impl Plane<'_> {
f[2][2] += ua[0] * g[2];
}
}
let det2 = f[0][0] * f[1][1] - f[0][1] * f[1][0];
let j = det2 * f[2][2];
if !(j > 0.0 && j.is_finite() && f[2][2] > 0.0) {
return Err(inverted(&format!("2D {}", self.ty.kind.name()), j));
}
let inv = [[f[1][1] / det2, -f[0][1] / det2], [-f[1][0] / det2, f[0][0] / det2]];
let current: Vec<[f64; 3]> =
cols.iter().map(|g| [g[0] * inv[0][0] + g[1] * inv[1][0], g[0] * inv[0][1] + g[1] * inv[1][1], g[2] / f[2][2]]).collect();
let updated_point = updated.get_mut(i * len..(i + 1) * len).unwrap_or_default();
let r = m.response(&f, point_history(committed, len, i), updated_point)?;
let heat = Heat::at(thermal, at.shape.values())?;
let (j, dv, current, r) = if small {
let strain = heat.mechanical_strain(&small_strain(&f));
let r = m.small_strain(&strain, heat.temperature, point_history(committed, len, i), updated_point, true)?;
(1.0, at.dv, cols.to_vec(), r)
} else {
let det2 = f[0][0] * f[1][1] - f[0][1] * f[1][0];
let j = det2 * f[2][2];
if !(j > 0.0 && j.is_finite() && f[2][2] > 0.0) {
return Err(inverted(&format!("2D {}", self.ty.kind.name()), j));
}
let inv = [[f[1][1] / det2, -f[0][1] / det2], [-f[1][0] / det2, f[0][0] / det2]];
let current: Vec<[f64; 3]> =
cols.iter().map(|g| [g[0] * inv[0][0] + g[1] * inv[1][0], g[0] * inv[0][1] + g[1] * inv[1][1], g[2] / f[2][2]]).collect();
let r = m.response(&heat.mechanical(&f), heat.temperature, point_history(committed, len, i), updated_point)?;
(j / heat.volume(), at.dv * heat.volume(), current, r)
};
let tau = [r.stress[0], r.stress[1], r.stress[2], r.stress[3]];
for (a, g) in current.iter().enumerate() {
if let Some(x) = force.get_mut(2 * a) {
*x += at.dv * (g[0] * tau[0] + g[2] * tau[2] + g[1] * tau[3]);
*x += dv * (g[0] * tau[0] + g[2] * tau[2] + g[1] * tau[3]);
}
if let Some(x) = force.get_mut(2 * a + 1) {
*x += at.dv * (g[1] * tau[1] + g[0] * tau[3]);
*x += dv * (g[1] * tau[1] + g[0] * tau[3]);
}
}
if let Some(k) = k.as_mut() {
let c4: [[f64; 4]; 4] = std::array::from_fn(|a| std::array::from_fn(|b| r.tangent[a][b]));
crate::plane::accumulate(&mut k.data, &current, &c4, at.dv);
for (a, ga) in current.iter().enumerate() {
for (b, gb) in current.iter().enumerate() {
let s = ga[0] * gb[0] * tau[0] + ga[1] * gb[1] * tau[1] + (ga[0] * gb[1] + ga[1] * gb[0]) * tau[3];
k.add(2 * a, 2 * b, at.dv * (s + tau[2] * ga[2] * gb[2]));
k.add(2 * a + 1, 2 * b + 1, at.dv * s);
crate::plane::accumulate(&mut k.data, &current, &c4, dv);
if !small {
for (a, ga) in current.iter().enumerate() {
for (b, gb) in current.iter().enumerate() {
let s = ga[0] * gb[0] * tau[0] + ga[1] * gb[1] * tau[1] + (ga[0] * gb[1] + ga[1] * gb[0]) * tau[3];
k.add(2 * a, 2 * b, dv * (s + tau[2] * ga[2] * gb[2]));
k.add(2 * a + 1, 2 * b + 1, dv * s);
}
}
}
}
out.push((f, j, at.x, r));
out.push(PlanePoint { f, j, x: at.x, response: r });
}
Ok((Finite { force, tangent: k }, out))
}
@@ -369,19 +576,44 @@ impl Plane<'_> {
/// Internal forces (u, v per node) and (with `tangent`) the tangent stiffness at nodal
/// displacements `u`, as for [`Solid::finite_strain`].
pub fn finite_strain(&self, m: &MaterialModel, u: &[[f64; 2]], committed: &[f64], updated: &mut [f64], tangent: bool) -> Result<Finite> {
Ok(self.finite_points(m, u, committed, updated, tangent)?.0)
Ok(self.nonlinear_points(m, u, Kinematics::Finite, None, committed, updated, tangent)?.0)
}
/// The Cauchy stress and logarithmic strain at every integration point, as the solid
/// As [`Solid::nonlinear`], on the 2D element.
#[allow(clippy::too_many_arguments)]
pub fn nonlinear(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
updated: &mut [f64],
tangent: bool,
) -> Result<Finite> {
Ok(self.nonlinear_points(m, u, kin, thermal, committed, updated, tangent)?.0)
}
/// The Cauchy stress and the total strain at every integration point, as the solid
/// elements' 6-vectors (axisymmetric: rr, axial, hoop, r-axial).
pub fn finite_strain_stresses(&self, m: &MaterialModel, u: &[[f64; 2]], committed: &[f64]) -> Result<Vec<PointStress>> {
pub fn nonlinear_stresses(
&self,
m: &MaterialModel,
u: &[[f64; 2]],
kin: Kinematics,
thermal: Option<&Temperature>,
committed: &[f64],
) -> Result<Vec<PointStress>> {
let mut scratch = committed.to_vec();
let (_, points) = self.finite_points(m, u, committed, &mut scratch, false)?;
let (_, points) = self.nonlinear_points(m, u, kin, thermal, committed, &mut scratch, false)?;
points
.iter()
.map(|(f, j, x, r)| {
let strain = logarithmic_strain(f).ok_or_else(|| inverted(&format!("2D {}", self.ty.kind.name()), *j))?;
Ok(PointStress { xyz: *x, strain, stress: r.stress.map(|v| v / j) })
.map(|p| {
if kin == Kinematics::Small {
return Ok(PointStress { xyz: p.x, strain: small_strain(&p.f), stress: p.response.stress });
}
let strain = logarithmic_strain(&p.f).ok_or_else(|| inverted(&format!("2D {}", self.ty.kind.name()), p.j))?;
Ok(PointStress { xyz: p.x, strain, stress: p.response.stress.map(|v| v / p.j) })
})
.collect()
}
@@ -420,20 +652,26 @@ mod tests {
/// Central differences of the internal forces against the tangent.
fn check_tangent(solid: &Solid, m: &MaterialModel, u: &[[f64; 3]]) {
let k = solid.finite_strain(m, u, &[], &mut [], true).unwrap().tangent.unwrap();
check_tangent_from(solid, m, u, Kinematics::Finite, &[], 1e-6);
}
/// The same from a committed history, with either kinematics.
fn check_tangent_from(solid: &Solid, m: &MaterialModel, u: &[[f64; 3]], kin: Kinematics, committed: &[f64], tol: f64) {
let mut scratch = committed.to_vec();
let k = solid.nonlinear_points(m, u, kin, None, committed, &mut scratch, true).unwrap().0.tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let n = 3 * u.len();
let h = 1e-6;
let h = if kin == Kinematics::Small { 1e-9 } else { 1e-6 };
for j in 0..n {
let shifted = |s: f64| {
let mut v = u.to_vec();
v[j / 3][j % 3] += s;
solid.finite_strain(m, &v, &[], &mut [], false).unwrap().force
solid.nonlinear_points(m, &v, kin, None, committed, &mut committed.to_vec(), false).unwrap().0.force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..n {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!((numeric - k.get(i, j)).abs() < 1e-6 * scale, "{:?} {i} {j}: {numeric} vs {}", solid.ty.kind, k.get(i, j));
assert!((numeric - k.get(i, j)).abs() < tol * scale, "{:?} {kin:?} {i} {j}: {numeric} vs {}", solid.ty.kind, k.get(i, j));
}
}
for i in 0..n {
@@ -465,6 +703,10 @@ mod tests {
for (kind, x, np) in [(ElementKind::Hex8, &x8, 1), (ElementKind::Hex20, &x20, 4), (ElementKind::Tet10, &x10, 1)] {
let hybrid = Solid::new(kind, x, Formulation::Hybrid { pressures: np }).unwrap();
check_tangent(&hybrid, &neo, &displacement(x.len(), 0.08));
let mr = MaterialModel::Polynomial { name: "Mooney–Rivlin", c: [100.0, 50.0, 0.0, 0.0], bulk: 3000.0 };
let yeoh = MaterialModel::Polynomial { name: "Yeoh", c: [150.0, 0.0, -10.0, 2.0], bulk: 3000.0 };
check_tangent(&hybrid, &mr, &displacement(x.len(), 0.08));
check_tangent(&hybrid, &yeoh, &displacement(x.len(), 0.08));
assert!(hybrid.finite_strain(&svk, &displacement(x.len(), 0.0), &[], &mut [], false).is_err(), "SVK has no split");
}
let im = Solid::new(ElementKind::Hex8, &x8, Formulation::IncompatibleModes).unwrap();
@@ -493,6 +735,164 @@ mod tests {
}
}
/// J2 plasticity in full and hybrid elements, at finite and small strain: the tangent is
/// the derivative of the forces from a plastic history too, and the plastic points are
/// really plastic.
#[test]
fn plastic_tangents_are_the_derivative_of_the_forces() {
use crate::elastoplastic::{HISTORY, PLASTIC_STRAIN};
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let m = MaterialModel::Plastic(std::sync::Arc::new(j2));
let (x20, x10) = (hex20(), tet10());
let x8: Vec<[f64; 3]> = x20[..8].to_vec();
for (kind, x, f) in [
(ElementKind::Hex8, &x8, Formulation::Full),
(ElementKind::Tet10, &x10, Formulation::Full),
(ElementKind::Hex20, &x20, Formulation::Reduced),
(ElementKind::Hex8, &x8, Formulation::Hybrid { pressures: 1 }),
(ElementKind::Hex20, &x20, Formulation::Hybrid { pressures: 4 }),
] {
let solid = Solid::new(kind, x, f).unwrap();
let points = solid.rule().points().len();
for (kin, scale) in [(Kinematics::Finite, 0.05), (Kinematics::Small, 0.05)] {
// A first increment from the virgin state, committed, then a second from it.
let mut committed = vec![0.0; points * HISTORY];
let first = displacement(x.len(), 0.5 * scale);
solid.nonlinear_points(&m, &first, kin, None, &vec![0.0; points * HISTORY], &mut committed, false).unwrap();
assert!((0..points).all(|p| committed[p * HISTORY + PLASTIC_STRAIN] > 0.0), "{kind:?} {f:?} {kin:?} yields everywhere");
check_tangent_from(&solid, &m, &displacement(x.len(), scale), kin, &committed, 1e-6);
}
}
// Incompatible modes have no inelastic form.
let im = Solid::new(ElementKind::Hex8, &x8, Formulation::IncompatibleModes).unwrap();
assert!(matches!(
im.nonlinear(&m, &displacement(8, 0.0), Kinematics::Small, None, &[0.0; 8 * HISTORY], &mut [0.0; 8 * HISTORY], true),
Err(Error::NotSupported(_))
));
}
#[test]
fn plastic_plane_strain_and_axisymmetric_tangents_are_the_derivative_of_the_forces() {
use crate::elastoplastic::{HISTORY, PLASTIC_STRAIN};
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let m = MaterialModel::Plastic(std::sync::Arc::new(j2));
let quad8 = [
[1.0, 0.0, 0.0],
[2.0, 0.1, 0.0],
[2.2, 1.0, 0.0],
[0.9, 1.1, 0.0],
[1.5, 0.0, 0.0],
[2.1, 0.55, 0.0],
[1.5, 1.1, 0.0],
[0.95, 0.5, 0.0],
];
for theory in [Theory::PlaneStrain { thickness: 2.0 }, Theory::Axisymmetric] {
for (kind, x) in [(ElementKind::Quad8, &quad8[..]), (ElementKind::Tri3, &quad8[..3])] {
let plane = Plane::new(kind, x, Formulation::Full, theory).unwrap();
let points = plane.rule().points().len();
for kin in [Kinematics::Finite, Kinematics::Small] {
let u = |scale: f64| -> Vec<[f64; 2]> { displacement(x.len(), scale).iter().map(|v| [v[0], v[1]]).collect() };
let mut committed = vec![0.0; points * HISTORY];
plane.nonlinear_points(&m, &u(0.03), kin, None, &vec![0.0; points * HISTORY], &mut committed, false).unwrap();
assert!((0..points).all(|p| committed[p * HISTORY + PLASTIC_STRAIN] > 0.0), "{theory:?} {kind:?} {kin:?}");
let at = u(0.06);
let k = plane.nonlinear_points(&m, &at, kin, None, &committed, &mut committed.clone(), true).unwrap().0.tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let h = 1e-8;
for j in 0..2 * x.len() {
let shifted = |s: f64| {
let mut v = at.clone();
v[j / 2][j % 2] += s;
plane.nonlinear_points(&m, &v, kin, None, &committed, &mut committed.clone(), false).unwrap().0.force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..2 * x.len() {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!(
(numeric - k.get(i, j)).abs() < 1e-6 * scale,
"{theory:?} {kind:?} {kin:?} {i} {j}: {numeric} vs {}",
k.get(i, j)
);
}
}
}
}
}
let stress = Plane::new(ElementKind::Quad4, &quad8[..4], Formulation::Full, Theory::PlaneStress { thickness: 1.0 }).unwrap();
assert!(matches!(
stress.nonlinear(&m, &[[0.0; 2]; 4], Kinematics::Small, None, &[0.0; 4 * HISTORY], &mut [0.0; 4 * HISTORY], true),
Err(Error::NotSupported(_))
));
}
/// A temperature field: the tangent is the derivative of the forces (finite and small strain,
/// full and hybrid, elastic and plastic), and a free expansion u = (F_θ − I) X of a uniform
/// temperature stresses nothing.
#[test]
fn thermal_tangents_and_free_expansion() {
use crate::elastoplastic::HISTORY;
use fieldcraft_model::{Elastic, Hardening, Plastic};
let j2 = crate::plasticity::J2::from_model(
&Elastic::Isotropic { young: 1000.0, poisson: 0.3 },
Some(&Plastic::Bilinear { yield_stress: 5.0, hardening_modulus: 50.0, hardening: Hardening::Isotropic }),
None,
)
.unwrap();
let plastic = MaterialModel::Plastic(std::sync::Arc::new(j2));
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 3000.0 };
let x20 = hex20();
let field = |n: usize| Temperature {
alpha: [1e-3, 2e-3, 1.5e-3],
reference: 20.0,
nodal: (0..n).map(|i| 20.0 + 40.0 * (i as f64 * 0.37).sin()).collect(),
};
for f in [Formulation::Full, Formulation::Hybrid { pressures: 4 }] {
let solid = Solid::new(ElementKind::Hex20, &x20, f).unwrap();
let points = solid.rule().points().len();
let t = field(20);
for (m, kin, len) in [(&neo, Kinematics::Finite, 0), (&plastic, Kinematics::Finite, HISTORY), (&plastic, Kinematics::Small, HISTORY)] {
let u = displacement(20, 0.03);
let committed = vec![0.0; points * len];
let k = solid.nonlinear(m, &u, kin, Some(&t), &committed, &mut committed.clone(), true).unwrap().tangent.unwrap();
let scale = k.data.iter().fold(0.0f64, |a, v| a.max(v.abs()));
let h = if kin == Kinematics::Small { 1e-9 } else { 1e-7 };
for j in 0..60 {
let shifted = |s: f64| {
let mut v = u.clone();
v[j / 3][j % 3] += s;
solid.nonlinear(m, &v, kin, Some(&t), &committed, &mut committed.clone(), false).unwrap().force
};
let (p, q) = (shifted(h), shifted(-h));
for i in 0..60 {
let numeric = (p[i] - q[i]) / (2.0 * h);
assert!((numeric - k.get(i, j)).abs() < 1e-6 * scale, "{f:?} {} {kin:?} {i} {j}: {numeric} vs {}", m.name(), k.get(i, j));
}
}
}
// Free expansion by a uniform 150 degrees: no force, no stress.
let uniform = Temperature { alpha: [1e-3, 2e-3, 1.5e-3], reference: 20.0, nodal: vec![170.0; 20] };
let stretch = uniform.alpha.map(|a| 1.0 + 150.0 * a);
let u: Vec<[f64; 3]> = x20.iter().map(|x| std::array::from_fn(|k| (stretch[k] - 1.0) * x[k])).collect();
let r = solid.nonlinear(&neo, &u, Kinematics::Finite, Some(&uniform), &[], &mut [], false).unwrap();
assert!(r.force.iter().all(|v| v.abs() < 1e-10 * 3000.0), "{f:?}: {:?}", r.force);
let s = solid.nonlinear_stresses(&neo, &u, Kinematics::Finite, Some(&uniform), &[]).unwrap();
assert!(s.iter().all(|p| p.stress.iter().all(|v| v.abs() < 1e-10 * 3000.0)), "{f:?}");
assert!((s[0].strain[1] - stretch[1].ln()).abs() < 1e-12, "total logarithmic strain: {:?}", s[0].strain);
}
}
#[test]
fn rigid_rotation_strains_and_stresses_nothing() {
let x20 = hex20();
@@ -501,7 +901,7 @@ mod tests {
let neo = MaterialModel::NeoHookean { shear: 300.0, bulk: 3000.0 };
for f in [Formulation::Full, Formulation::Hybrid { pressures: 4 }] {
let solid = Solid::new(ElementKind::Hex20, &x20, f).unwrap();
let s = solid.finite_strain_stresses(&neo, &u, &[]).unwrap();
let s = solid.nonlinear_stresses(&neo, &u, Kinematics::Finite, None, &[]).unwrap();
assert!(s.iter().all(|p| p.stress.iter().chain(&p.strain).all(|v| v.abs() < 1e-12 * 3000.0)), "{f:?}: {:?}", s[0]);
let r = solid.finite_strain(&neo, &u, &[], &mut [], false).unwrap();
assert!(r.force.iter().all(|v| v.abs() < 1e-11 * 3000.0));
+8 -3
View File
@@ -14,12 +14,13 @@
//! the solid kernels evaluate;
//! - [`material`]: isotropic and orthotropic elasticity matrices, von Mises stress;
//! - [`constitutive`]: the material point of finite-strain analysis (Kirchhoff stress and
//! spatial tangent from the deformation gradient, with history): St Venant–Kirchhoff and
//! neo-Hookean;
//! spatial tangent from the deformation gradient, with history): St Venant–Kirchhoff,
//! neo-Hookean and J2 plasticity;
//! - [`element`]: the element table, stiffness (full, reduced, incompatible modes), consistent
//! and lumped mass, body forces, integration-point stresses and their extrapolation to nodes;
//! - [`finite`]: total Lagrangian solid and 2D elements (internal forces, the consistent tangent
//! with its geometric part, mixed u–p at finite strain, Cauchy stress and logarithmic strain);
//! with its geometric part, mixed u–p at finite strain, Cauchy stress and logarithmic strain),
//! and the same with small strain for inelastic materials;
//! - [`plane`]: 2D elements (plane stress, plane strain, axisymmetric) on tri3, tri6, quad4 and
//! quad8, with two displacement components per node;
//! - [`shell`]: flat MITC4 quadrilateral and MITC3 triangle shells with a drilling rotation, six
@@ -33,6 +34,9 @@
//! shape vectors and element frequency bounds for explicit dynamics;
//! - [`rotation`]: incrementally objective stress updates (Hughes–Winget Jaumann, Flanagan–Taylor
//! Green–Naghdi);
//! - [`elastoplastic`]: J2 plasticity at a material point of implicit analysis, at finite strain
//! (Simo's exponential map in the principal logarithmic strains) and at small strain, with the
//! consistent tangents, on the radial return of [`plasticity`];
//! - [`plasticity`]: J2 radial return with isotropic, kinematic and Johnson–Cook hardening,
//! Johnson–Cook damage;
//! - [`truss`]: two- and three-node trusses (straight or curved), their stiffness, mass, body
@@ -52,6 +56,7 @@ pub mod beam;
pub mod constitutive;
pub mod dense;
pub mod dof;
pub mod elastoplastic;
pub mod element;
pub mod face;
pub mod finite;
+92 -21
View File
@@ -30,6 +30,11 @@
//! [`MIN_FRACTURE_STRAIN`] counts as that (some published sets give negative values at high
//! triaxiality, where the point fails at once). The point has failed at D ≥ 1.
//!
//! **One return map for both solvers.** [`J2::radial_return`] is the return above; explicit
//! dynamics calls it through [`J2::update`] on its hypoelastic stress, the implicit material
//! point ([`crate::elastoplastic`]) on its trial elastic strain, small or logarithmic, and adds
//! the consistent tangent [`J2::tangent`].
//!
//! The strain increment the update takes is the **mechanical** one: a caller with initial or
//! thermal strains subtracts their increment first.
//!
@@ -98,6 +103,21 @@ impl PointState {
}
}
/// What a radial return did: what the consistent tangent needs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Return {
/// The equivalent plastic strain increment Δε_p.
pub plastic_strain: f64,
/// The trial von Mises stress of ξ* = s* − α, q* = √(3/2)‖ξ*‖.
pub trial: f64,
/// The flow direction ξ*/‖ξ*‖ (tensor components).
pub normal: [f64; 6],
/// The kinematic hardening modulus H_k.
pub kinematic: f64,
/// The slope of the flow stress against the plastic strain at the end of the increment.
pub slope: f64,
}
/// What one update did.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Increment {
@@ -209,6 +229,11 @@ impl J2 {
Ok(J2 { shear, bulk, flow, damage, heating })
}
/// Whether the flow stress depends on the temperature (Johnson–Cook softening).
pub fn is_temperature_dependent(&self) -> bool {
matches!(self.flow, Some(Flow::JohnsonCook { .. }))
}
/// The temperature a point starts at.
pub fn initial_temperature(&self) -> f64 {
match self.flow {
@@ -232,27 +257,11 @@ impl J2 {
}
let pressure = mean + self.bulk * vol;
let mut out = Increment::default();
if let Some(flow) = &self.flow {
let xi: [f64; 6] = std::array::from_fn(|k| trial[k] - state.back[k]);
let norm = norm2(&xi).sqrt();
let q = (1.5f64).sqrt() * norm;
let (yield_now, _) = flow.stress(state.plastic_strain, 0.0, state.temperature, dt);
if q > yield_now && norm > 0.0 {
let hk = flow.kinematic();
let de = self.plastic_increment(flow, state, q, yield_now, hk, dt);
let n: [f64; 6] = xi.map(|x| x / norm);
let radial = g2 * (1.5f64).sqrt() * de;
let shift = (2.0f64 / 3.0).sqrt() * hk * de;
for k in 0..6 {
trial[k] -= radial * n[k];
state.back[k] += shift * n[k];
}
state.plastic_strain += de;
let q_new = q - (3.0 * self.shear + hk) * de;
out = Increment { plastic_strain: de, plastic_work: q_new * de };
if self.heating > 0.0 && density > 0.0 {
state.temperature += self.heating * out.plastic_work / density;
}
if let Some(r) = self.radial_return(state, &mut trial, dt) {
let q_new = r.trial - (3.0 * self.shear + r.kinematic) * r.plastic_strain;
out = Increment { plastic_strain: r.plastic_strain, plastic_work: q_new * r.plastic_strain };
if self.heating > 0.0 && density > 0.0 {
state.temperature += self.heating * out.plastic_work / density;
}
}
for k in 0..3 {
@@ -273,6 +282,68 @@ impl J2 {
out
}
/// The radial return of a trial deviator `trial` (tensor components) from `state`: when the
/// trial von Mises stress exceeds the yield stress, `trial` is moved back to the yield
/// surface, the back stress and the equivalent plastic strain of `state` are advanced, and
/// what the consistent tangent needs is returned; `None` when the point stays elastic.
pub fn radial_return(&self, state: &mut PointState, trial: &mut [f64; 6], dt: f64) -> Option<Return> {
let flow = self.flow.as_ref()?;
let xi: [f64; 6] = std::array::from_fn(|k| trial[k] - state.back[k]);
let norm = norm2(&xi).sqrt();
let q = (1.5f64).sqrt() * norm;
let (yield_now, _) = flow.stress(state.plastic_strain, 0.0, state.temperature, dt);
if !(q > yield_now && norm > 0.0) {
return None;
}
let hk = flow.kinematic();
let de = self.plastic_increment(flow, state, q, yield_now, hk, dt);
let n: [f64; 6] = xi.map(|x| x / norm);
let radial = 2.0 * self.shear * (1.5f64).sqrt() * de;
let shift = (2.0f64 / 3.0).sqrt() * hk * de;
for k in 0..6 {
trial[k] -= radial * n[k];
state.back[k] += shift * n[k];
}
let rate = if dt > 0.0 { de / dt } else { 0.0 };
let (_, slope) = flow.stress(state.plastic_strain + de, rate, state.temperature, dt);
state.plastic_strain += de;
Some(Return { plastic_strain: de, trial: q, normal: n, kinematic: hk, slope })
}
/// The consistent (algorithmic) tangent of the return map, dσ/dε of the stress after the
/// update against the total strain of the increment (engineering-shear columns): the
/// elasticity K I ⊗ I + 2G ℙ while elastic, and after a return
/// K I ⊗ I + 2G θ ℙ − 2G θ̄ n ⊗ n with θ = 1 − 3G Δε_p / q*, θ̄ = 1/(1 + H/3G) − (1 − θ) and
/// H the slope of the flow stress plus the kinematic modulus at the end of the increment
/// (Simo & Hughes 1998, Box 3.2, eq. 3.3.10, in the equivalent plastic strain). `bulk`
/// leaves out the volumetric part K I ⊗ I when false (the isochoric part a mixed element
/// takes). `None` when 3G + H is not positive: the point softens faster than it unloads
/// elastically and has no tangent.
pub fn tangent(&self, r: Option<&Return>, bulk: bool) -> Option<[[f64; 6]; 6]> {
let g = self.shear;
let (theta, theta_bar, n) = match r {
None => (1.0, 0.0, [0.0; 6]),
Some(r) => {
let stiff = 3.0 * g + r.slope + r.kinematic;
if !(stiff > 0.0 && stiff.is_finite() && r.trial > 0.0) {
return None;
}
let theta = 1.0 - 3.0 * g * r.plastic_strain / r.trial;
(theta, 3.0 * g / stiff - (1.0 - theta), r.normal)
}
};
let k = if bulk { self.bulk } else { 0.0 };
let identity = crate::constitutive::identity4();
let mut d = [[0.0; 6]; 6];
for a in 0..6 {
for b in 0..6 {
let (ia, ib) = (if a < 3 { 1.0 } else { 0.0 }, if b < 3 { 1.0 } else { 0.0 });
d[a][b] = k * ia * ib + 2.0 * g * theta * (identity[a][b] - ia * ib / 3.0) - 2.0 * g * theta_bar * n[a] * n[b];
}
}
Some(d)
}
/// The plastic strain increment of a trial state that yields.
fn plastic_increment(&self, flow: &Flow, state: &PointState, q: f64, yield_now: f64, hk: f64, dt: f64) -> f64 {
let g3 = 3.0 * self.shear;
+4 -14
View File
@@ -30,7 +30,7 @@
use fieldcraft_model::Elastic;
use crate::dense::inverse3;
use crate::dense::positive_inverse;
use crate::material::Elasticity;
use crate::quadrature::Rule;
use crate::shape::{self, HEX_CORNERS};
@@ -72,11 +72,7 @@ impl UniformStrain<8> {
}
}
}
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("hex8"))?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
if !(det > 1e-12 * norms) {
return Err(degenerate("hex8"));
}
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("hex8"))?;
let w = det * p.weight;
volume += w;
for (((bi, vi), d), n) in b.iter_mut().zip(nodal_volume.iter_mut()).zip(s.derivatives()).zip(s.values()) {
@@ -172,15 +168,9 @@ impl UniformStrain<4> {
/// The linear tetrahedron of corners `xyz` (the model's tet4 node order).
pub fn tet4(xyz: &[[f64; 3]; 4]) -> Result<Tet4> {
fieldcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?;
let [x0, x1, x2, x3] = *xyz;
let e = |x: [f64; 3]| [x[0] - x0[0], x[1] - x0[1], x[2] - x0[2]];
// J[a][c] = ∂x_c/∂ξ_a: the edges from corner 1.
let j = [e(x1), e(x2), e(x3)];
let (inv, det) = inverse3(&j).ok_or_else(|| degenerate("tet4"))?;
let norms: f64 = j.iter().map(|r| (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()).product();
if !(det > 1e-12 * norms) {
return Err(degenerate("tet4"));
}
let j = fieldcraft_model::tet4_jacobian(xyz);
let (inv, det) = positive_inverse(&j).ok_or_else(|| degenerate("tet4"))?;
let volume = det / 6.0;
let s = shape::tet4([0.25; 3]);
let mut b = [[0.0; 3]; 4];
+2 -11
View File
@@ -2,9 +2,8 @@
//! with SolidCraft, and hand back its bodies with persistent face names.
//!
//! The design is rebuilt from its authoritative inputs (sketches, features, parameters); cached
//! display meshes in the file are never used. [`open_design`] takes bytes; [`open_design_file`]
//! reads a path through SolidCraft's file access, which in the browser is the in-memory files the
//! web host loaded.
//! display meshes in the file are never used. [`open_design`] takes the file's bytes (reading
//! files is `fieldcraft_io::fs`'s job, which in the browser holds the page's files).
use solidcraft_doc::{Document, Model, naming};
use solidcraft_kernel::{KernelError, guard};
@@ -45,11 +44,6 @@ pub fn open_design(bytes: &[u8]) -> Result<Design> {
rebuild(&doc)
}
/// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it.
pub fn open_design_file(path: &str) -> Result<Design> {
open_design(&crate::read_file(path, MAX_DESIGN_BYTES)?)
}
/// Rebuild an in-memory document (the design half of [`open_design`]).
pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
let model = guard("rebuild design", || {
@@ -165,8 +159,5 @@ mod tests {
assert!(open_design(bytes).is_err(), "{bytes:?}");
}
assert!(open_design(&vec![b' '; MAX_DESIGN_BYTES + 1]).is_err());
for path in ["", "/", "/dev/zero", "/no/such/design.solidcraft"] {
assert!(open_design_file(path).is_err(), "{path}");
}
}
}
+1 -9
View File
@@ -22,7 +22,7 @@ pub mod sample;
#[cfg(feature = "scripts")]
mod script;
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
pub mod tagged_mesh;
#[cfg(feature = "scripts")]
pub use script::{MAX_SCRIPT_BYTES, script_design};
@@ -47,14 +47,6 @@ impl From<KernelError> for Error {
}
}
/// Read a regular file of at most `max_bytes` through SolidCraft's file access (in the browser,
/// the in-memory files the web host loaded). Opening a design or a 3MF package from these bytes
/// ([`open_design`], [`tagged_mesh::read_3mf`]) lets the caller record a hash of exactly what it
/// opened.
pub fn read_file(path: &str, max_bytes: usize) -> Result<Vec<u8>> {
solidcraft_io::vfs::read(path, max_bytes).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))
}
/// SolidCraft's linear modelling tolerance (mm): points closer than this are the same point.
const LINEAR_TOLERANCE: f64 = solidcraft_geom::LINEAR_TOL;
-6
View File
@@ -344,12 +344,6 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<Tagge
Ok(TaggedSurface { name, mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags, weld_distance: 0.0 }, faces })
}
/// [`read_3mf`] of a file: a regular file of at most [`MAX_FILE_BYTES`] (SolidCraft's virtual
/// file system, so the browser's in-memory files work too).
pub fn read_3mf_file(path: &str) -> Result<Vec<TaggedSurface>> {
read_3mf(&crate::read_file(path, MAX_FILE_BYTES)?)
}
/// The surfaces of a 3MF package, one per build item, in millimetres.
pub fn read_3mf(bytes: &[u8]) -> Result<Vec<TaggedSurface>> {
if bytes.len() > MAX_FILE_BYTES {
+262 -170
View File
@@ -7,17 +7,18 @@
//!
//! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
//!
//! In the browser (`wasm32`) the same calls read and write files kept in memory
//! ([`crate::memory`]): the web host puts files in ([`insert`]) and takes out what commands wrote
//! ([`take_writes`]). Everything else in FieldCraft reaches files only through this module.
#[cfg(target_arch = "wasm32")]
pub use browser::*;
#[cfg(not(target_arch = "wasm32"))]
pub use disk::*;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::{Error, Result};
fn io_error(path: &Path, e: std::io::Error) -> Error {
Error::Io(format!("{}: {e}", path.display()))
}
/// The backup of a file written with `backup: true`.
pub fn backup_path(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
@@ -25,185 +26,276 @@ pub fn backup_path(path: &Path) -> PathBuf {
PathBuf::from(s)
}
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
}
#[cfg(target_arch = "wasm32")]
mod browser {
use std::cell::RefCell;
use std::path::{Path, PathBuf};
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(bytes)?;
f.sync_all()
}
use crate::Result;
use crate::memory::MemoryFs;
/// Flush the directory entry (the rename) to disk where the platform allows it.
fn sync_dir(path: &Path) {
#[cfg(unix)]
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
&& let Ok(d) = fs::File::open(dir)
{
let _ = d.sync_all();
thread_local! {
// The browser runs the app on one thread.
static FILES: RefCell<MemoryFs> = RefCell::new(MemoryFs::default());
}
/// Write `bytes` to `path` (a backup is not kept: the host hands each save out as a
/// download).
pub fn write_atomic(path: &Path, bytes: &[u8], _backup: bool) -> Result<()> {
FILES.with(|f| f.borrow_mut().write(path, bytes))
}
/// Read a file of at most `max` bytes.
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
FILES.with(|f| f.borrow().read(path, max))
}
pub fn is_file(path: &Path) -> bool {
FILES.with(|f| f.borrow().is_file(path))
}
pub fn remove(path: &Path) -> Result<()> {
FILES.with(|f| f.borrow_mut().remove(path))
}
/// The files directly in `dir`.
pub fn list(dir: &Path) -> Vec<PathBuf> {
FILES.with(|f| f.borrow().list(dir))
}
/// Folders are implied by the files' paths.
pub fn create_dir_all(_dir: &Path) -> Result<()> {
Ok(())
}
/// The host puts a file in (one picked, dropped or kept in the browser's storage).
pub fn insert(path: &Path, bytes: Vec<u8>) -> Result<()> {
FILES.with(|f| f.borrow_mut().insert(path, bytes))
}
/// What commands wrote since the last call, for the host to hand out or store.
pub fn take_writes() -> Vec<(PathBuf, std::sync::Arc<[u8]>)> {
FILES.with(|f| f.borrow_mut().take_writes())
}
#[cfg(not(unix))]
let _ = path;
}
/// Steps of a save, so tests can stop one part way (as a crash would).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Step {
TempWritten,
BackupWritten,
Replaced,
}
#[cfg(not(target_arch = "wasm32"))]
mod disk {
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
let tmp = temp_beside(path, "new");
if let Err(e) = write_synced(&tmp, bytes) {
let _ = fs::remove_file(&tmp);
return Err(e);
use super::backup_path;
use crate::{Error, Result};
fn io_error(path: &Path, e: std::io::Error) -> Error {
Error::Io(format!("{}: {e}", path.display()))
}
if stop == Step::TempWritten {
return Ok(());
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
}
if backup && path.is_file() {
// Copy (not move) the old file, so the target exists at every moment.
let bak_tmp = temp_beside(path, "bak");
let r = fs::copy(path, &bak_tmp)
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
.and_then(|f| f.sync_all())
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
if let Err(e) = r {
let _ = fs::remove_file(&bak_tmp);
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Flush the directory entry (the rename) to disk where the platform allows it.
fn sync_dir(path: &Path) {
#[cfg(unix)]
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
&& let Ok(d) = fs::File::open(dir)
{
let _ = d.sync_all();
}
#[cfg(not(unix))]
let _ = path;
}
/// Steps of a save, so tests can stop one part way (as a crash would).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Step {
TempWritten,
BackupWritten,
Replaced,
}
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
let tmp = temp_beside(path, "new");
if let Err(e) = write_synced(&tmp, bytes) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
}
if stop == Step::BackupWritten {
return Ok(());
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(path);
// Temporary files of saves that crashed part way (a minute old or more) go.
for t in stale_temps(path) {
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
let _ = fs::remove_file(t);
if stop == Step::TempWritten {
return Ok(());
}
}
Ok(())
}
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
/// `<path>.bak` first.
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
}
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let prefix = format!(".{name}.");
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
.take(10_000)
.collect()
}
/// Read a regular file of at most `max` bytes.
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
if !meta.is_file() {
return Err(Error::Io(format!("{}: not a regular file", path.display())));
}
if meta.len() > max {
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
}
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
// The file may grow between the size check and the read.
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
if bytes.len() as u64 > max {
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn replaces_and_keeps_a_backup() {
let d = dir("bak");
let p = d.join("a.fieldcraft");
write_atomic(&p, b"one", true).unwrap();
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
write_atomic(&p, b"two", true).unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two");
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
write_atomic(&p, b"three", false).unwrap();
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
assert!(stale_temps(&p).is_empty());
let _ = fs::remove_dir_all(&d);
}
/// A save stopped after any step leaves the old file whole (or the new one).
#[test]
fn a_save_stopped_part_way_keeps_the_old_file() {
let d = dir("stop");
let p = d.join("a.fieldcraft");
write_atomic(&p, b"old", true).unwrap();
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
write_steps(&p, b"new content", true, stop).unwrap();
let now = fs::read(&p).unwrap();
if stop < Step::Replaced {
assert_eq!(now, b"old", "{stop:?}");
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
for t in stale_temps(&p) {
fs::remove_file(t).unwrap();
}
} else {
assert_eq!(now, b"new content");
if backup && path.is_file() {
// Copy (not move) the old file, so the target exists at every moment.
let bak_tmp = temp_beside(path, "bak");
let r = fs::copy(path, &bak_tmp)
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
.and_then(|f| f.sync_all())
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
if let Err(e) = r {
let _ = fs::remove_file(&bak_tmp);
let _ = fs::remove_file(&tmp);
return Err(e);
}
write_atomic(&p, b"old", false).unwrap();
}
let _ = fs::remove_dir_all(&d);
if stop == Step::BackupWritten {
return Ok(());
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(path);
// Temporary files of saves that crashed part way (a minute old or more) go.
for t in stale_temps(path) {
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
let _ = fs::remove_file(t);
}
}
Ok(())
}
#[test]
fn a_failed_write_leaves_the_target() {
let d = dir("fail");
let p = d.join("missing-dir").join("a.fieldcraft");
assert!(write_atomic(&p, b"x", true).is_err());
assert!(!p.exists());
let _ = fs::remove_dir_all(&d);
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
/// `<path>.bak` first.
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
}
#[test]
fn reads_are_capped() {
let d = dir("read");
let p = d.join("a.bin");
fs::write(&p, [7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
assert!(read_capped(&d, 1 << 20).is_err());
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
let _ = fs::remove_dir_all(&d);
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let prefix = format!(".{name}.");
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
.take(10_000)
.collect()
}
/// Read a regular file of at most `max` bytes.
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
if !meta.is_file() {
return Err(Error::Io(format!("{}: not a regular file", path.display())));
}
if meta.len() > max {
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
}
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
// The file may grow between the size check and the read.
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
if bytes.len() as u64 > max {
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
}
Ok(bytes)
}
pub fn is_file(path: &Path) -> bool {
path.is_file()
}
pub fn remove(path: &Path) -> Result<()> {
fs::remove_file(path).map_err(|e| io_error(path, e))
}
/// The files directly in `dir` (at most a million).
pub fn list(dir: &Path) -> Vec<PathBuf> {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.take(1_000_000)
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.path())
.collect()
}
pub fn create_dir_all(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).map_err(|e| io_error(dir, e))
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn replaces_and_keeps_a_backup() {
let d = dir("bak");
let p = d.join("a.fieldcraft");
write_atomic(&p, b"one", true).unwrap();
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
write_atomic(&p, b"two", true).unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two");
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
write_atomic(&p, b"three", false).unwrap();
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
assert!(stale_temps(&p).is_empty());
let _ = fs::remove_dir_all(&d);
}
/// A save stopped after any step leaves the old file whole (or the new one).
#[test]
fn a_save_stopped_part_way_keeps_the_old_file() {
let d = dir("stop");
let p = d.join("a.fieldcraft");
write_atomic(&p, b"old", true).unwrap();
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
write_steps(&p, b"new content", true, stop).unwrap();
let now = fs::read(&p).unwrap();
if stop < Step::Replaced {
assert_eq!(now, b"old", "{stop:?}");
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
for t in stale_temps(&p) {
fs::remove_file(t).unwrap();
}
} else {
assert_eq!(now, b"new content");
}
write_atomic(&p, b"old", false).unwrap();
}
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_target() {
let d = dir("fail");
let p = d.join("missing-dir").join("a.fieldcraft");
assert!(write_atomic(&p, b"x", true).is_err());
assert!(!p.exists());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn reads_are_capped() {
let d = dir("read");
let p = d.join("a.bin");
fs::write(&p, [7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
assert!(read_capped(&d, 1 << 20).is_err());
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
let _ = fs::remove_dir_all(&d);
}
}
}
+2 -1
View File
@@ -18,11 +18,12 @@
//! capped before anything is allocated, every part's checksum is verified, and the model is
//! rebuilt through the same checked methods commands use, so a file cannot hold what commands
//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`],
//! [`recovery`] and [`roots`] touch the file system.
//! [`recovery`] and [`roots`] touch the file system, which in the browser is [`memory`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod fs;
pub mod memory;
pub mod mesh;
pub mod recovery;
pub mod roots;
+151
View File
@@ -0,0 +1,151 @@
//! Files kept in memory: the file system of the browser build.
//!
//! A browser page has no file system it can call synchronously, so on `wasm32` [`crate::fs`]
//! keeps files here. The web host puts files in before a command reads them (a file picked or
//! dropped, an autosave kept in the browser's storage) and takes out what commands wrote
//! ([`MemoryFs::take_writes`]) to hand it out as a download or keep it in the browser's storage.
//! Natively the type is only used by its tests.
//!
//! Follows SolidCraft's `crates/io/src/vfs.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{Error, Result};
/// Most files kept, and most bytes in all: a page that is handed more refuses the write.
pub const MAX_FILES: usize = 10_000;
pub const MAX_TOTAL_BYTES: u64 = 2 << 30;
/// Files by path (shared, so handing one out copies nothing), their total size, and the paths
/// written since the host last took them.
#[derive(Debug, Default)]
pub struct MemoryFs {
files: BTreeMap<PathBuf, Arc<[u8]>>,
total: u64,
written: Vec<PathBuf>,
}
fn missing(path: &Path) -> Error {
Error::Io(format!("{}: no such file in this browser page", path.display()))
}
impl MemoryFs {
fn room_for(&self, path: &Path, len: usize) -> Result<()> {
let replaced = self.files.get(path).map_or(0, |b| b.len() as u64);
let total = self.total.saturating_sub(replaced).saturating_add(len as u64);
if !self.files.contains_key(path) && self.files.len() >= MAX_FILES {
return Err(Error::Limit(format!("{}: more than {MAX_FILES} files in this browser page", path.display())));
}
if total > MAX_TOTAL_BYTES {
return Err(Error::Limit(format!("{}: files in this browser page would take more than {} MB", path.display(), MAX_TOTAL_BYTES >> 20)));
}
Ok(())
}
fn put(&mut self, path: &Path, bytes: Arc<[u8]>) -> Result<()> {
self.room_for(path, bytes.len())?;
self.total = self.total.saturating_add(bytes.len() as u64);
if let Some(old) = self.files.insert(path.to_path_buf(), bytes) {
self.total = self.total.saturating_sub(old.len() as u64);
}
Ok(())
}
/// Put a file in from the host (not recorded as written).
pub fn insert(&mut self, path: &Path, bytes: Vec<u8>) -> Result<()> {
self.put(path, bytes.into())
}
/// Write a file for a command: kept, and recorded for [`MemoryFs::take_writes`].
pub fn write(&mut self, path: &Path, bytes: &[u8]) -> Result<()> {
self.put(path, bytes.into())?;
self.written.retain(|p| p != path);
self.written.push(path.to_path_buf());
Ok(())
}
/// A file of at most `max` bytes.
pub fn read(&self, path: &Path, max: u64) -> Result<Vec<u8>> {
let bytes = self.files.get(path).ok_or_else(|| missing(path))?;
if bytes.len() as u64 > max {
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), bytes.len() >> 20, max >> 20)));
}
Ok(bytes.to_vec())
}
pub fn is_file(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
pub fn remove(&mut self, path: &Path) -> Result<()> {
let old = self.files.remove(path).ok_or_else(|| missing(path))?;
self.total = self.total.saturating_sub(old.len() as u64);
Ok(())
}
/// The files directly in `dir`, in path order.
pub fn list(&self, dir: &Path) -> Vec<PathBuf> {
// Paths order by component, so everything under `dir` follows it.
self.files
.range(dir.to_path_buf()..)
.map(|(p, _)| p)
.take_while(|p| p.starts_with(dir))
.filter(|p| p.parent() == Some(dir))
.cloned()
.collect()
}
/// The files written since the last call, in the order last written, with their content
/// (a file removed since is left out).
pub fn take_writes(&mut self) -> Vec<(PathBuf, Arc<[u8]>)> {
std::mem::take(&mut self.written).into_iter().filter_map(|p| self.files.get(&p).cloned().map(|b| (p, b))).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_are_kept_listed_and_handed_out_once_written() {
let mut m = MemoryFs::default();
let (a, b) = (Path::new("/browser/a.fieldcraft"), Path::new("/browser/b.inp"));
m.insert(a, b"picked".to_vec()).unwrap();
assert_eq!(m.read(a, 100).unwrap(), b"picked");
assert!(m.take_writes().is_empty(), "a file put in by the host is not a write");
m.write(b, b"one").unwrap();
m.write(a, b"saved").unwrap();
m.write(b, b"two").unwrap();
let taken: Vec<(PathBuf, Vec<u8>)> = m.take_writes().into_iter().map(|(p, b)| (p, b.to_vec())).collect();
assert_eq!(taken, vec![(a.to_path_buf(), b"saved".to_vec()), (b.to_path_buf(), b"two".to_vec())]);
assert!(m.take_writes().is_empty());
assert_eq!(m.list(Path::new("/browser")), vec![a.to_path_buf(), b.to_path_buf()]);
assert!(m.list(Path::new("/")).is_empty(), "only the folder's own files");
m.insert(Path::new("/browser2/c"), vec![]).unwrap();
m.insert(Path::new("/browser/sub/d"), vec![]).unwrap();
assert_eq!(m.list(Path::new("/browser")).len(), 2, "not a sibling folder's files, nor a subfolder's");
m.write(a, b"again").unwrap();
m.remove(a).unwrap();
assert!(m.take_writes().is_empty(), "a removed file is not handed out");
assert!(!m.is_file(a) && m.remove(a).is_err() && m.read(a, 100).is_err());
}
#[test]
fn reads_and_sizes_are_capped() {
let mut m = MemoryFs::default();
let p = Path::new("/x");
m.insert(p, vec![0; 100]).unwrap();
assert!(matches!(m.read(p, 99), Err(Error::Limit(_))));
assert_eq!(m.read(p, 100).map(|b| b.len()), Ok(100));
for i in 1..MAX_FILES {
m.insert(&PathBuf::from(format!("/f{i}")), vec![]).unwrap();
}
assert!(matches!(m.write(Path::new("/one-too-many"), b""), Err(Error::Limit(_))));
assert!(m.write(p, b"replacing is fine").is_ok());
assert_eq!(m.total, 17, "the total follows replacements");
m.remove(p).unwrap();
assert_eq!(m.total, 0);
}
}
+144 -37
View File
@@ -6,20 +6,29 @@
//! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists
//! it for the next launch to offer.
//!
//! In the browser the folder is [`BROWSER_DIR`] in the page's files ([`crate::memory`]), which
//! the web host keeps in the browser's storage, and an entry is held while the page that wrote it
//! is open (the page holds no file locks; another tab's entry counts as left behind).
//!
//! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md. The engine decides when to autosave; this module only stores.
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fs::{read_capped, write_atomic};
use crate::fs::{create_dir_all, is_file, list, read_capped, remove as remove_file, write_atomic};
use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result};
/// The recovery folder of the browser build.
pub const BROWSER_DIR: &str = "/recovery";
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `FieldCraft/recovery` (`fieldcraft/` on
/// Linux) in the platform's per-user data folder.
/// Linux) in the platform's per-user data folder; [`BROWSER_DIR`] in the browser.
pub fn default_dir() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return Some(PathBuf::from(BROWSER_DIR));
}
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
if let Some(d) = env("FIELDCRAFT_RECOVERY_DIR") {
return Some(d);
@@ -47,12 +56,28 @@ pub struct Entry {
pub elements: usize,
}
/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm).
fn unix_now() -> u64 {
/// Milliseconds since 1970 from the host, where the platform has no clock of its own (the
/// browser's plain wasm); see [`set_clock`].
static CLOCK: std::sync::OnceLock<fn() -> u64> = std::sync::OnceLock::new();
/// Give the module a clock (milliseconds since 1970) on a platform without one: the web host
/// passes the browser's. Entries then record when they were saved and get ids no other page
/// takes. The first clock given stays.
pub fn set_clock(now_ms: fn() -> u64) {
let _ = CLOCK.set(now_ms);
}
/// Milliseconds since 1970 (0 without a clock).
fn unix_now_ms() -> u64 {
if cfg!(target_arch = "wasm32") {
return 0;
return CLOCK.get().map_or(0, |now| now());
}
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis().try_into().unwrap_or(u64::MAX)).unwrap_or(0)
}
/// Seconds since 1970 (0 without a clock).
fn unix_now() -> u64 {
unix_now_ms() / 1000
}
fn valid_id(id: &str) -> bool {
@@ -63,11 +88,106 @@ fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
dir.join(format!("{id}.{ext}"))
}
/// Whether a running app holds entry `id`'s lock.
fn held(dir: &Path, id: &str) -> bool {
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
/// Entry locks on the desktop: `<id>.lock`, locked while the app runs.
#[cfg(not(target_arch = "wasm32"))]
mod lock {
use std::fs::{self, File, OpenOptions};
use std::path::Path;
use super::{MAX_LISTED, file};
use crate::{Error, Result};
/// A held entry lock.
#[derive(Debug)]
pub struct Lock(#[allow(dead_code)] File);
/// Whether a running app holds entry `id`'s lock.
pub fn held(dir: &Path, id: &str) -> bool {
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
}
pub fn take(dir: &Path, id: &str) -> Result<Lock> {
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, id, "lock")).map_err(io)?;
lock.lock().map_err(io)?;
Ok(Lock(lock))
}
/// Remove the locks of apps that ended with nothing to recover.
pub fn sweep(dir: &Path) {
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("lock")
&& !p.with_extension("json").exists()
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
{
let _ = fs::remove_file(&p);
}
}
}
/// A process-unique part of new entry ids.
pub fn instance() -> String {
std::process::id().to_string()
}
/// Let go of the lock; its file goes unless an autosave stays for the next launch.
pub fn release(lock: Lock, dir: &Path, id: &str, keep: bool) {
drop(lock);
if !keep {
let _ = fs::remove_file(file(dir, id, "lock"));
}
}
}
/// Entry "locks" in the browser: the entries this page holds, kept in memory (a page that is
/// gone holds none).
#[cfg(target_arch = "wasm32")]
mod lock {
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::path::Path;
use crate::Result;
thread_local! {
static HELD: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
}
/// Releases the entry when dropped (the slot is gone).
#[derive(Debug)]
pub struct Lock(String);
impl Drop for Lock {
fn drop(&mut self) {
HELD.with(|h| h.borrow_mut().remove(&self.0));
}
}
pub fn held(_dir: &Path, id: &str) -> bool {
HELD.with(|h| h.borrow().contains(id))
}
pub fn take(_dir: &Path, id: &str) -> Result<Lock> {
HELD.with(|h| h.borrow_mut().insert(id.to_string()));
Ok(Lock(id.to_string()))
}
pub fn sweep(_dir: &Path) {}
/// The page's part of new entry ids: the milliseconds of the clock (the id has its seconds),
/// so pages started in the same second take different ids (unless in the same millisecond).
pub fn instance() -> String {
format!("w{}", super::unix_now_ms() % 1000)
}
pub fn release(lock: Lock, _dir: &Path, _id: &str, _keep: bool) {
drop(lock);
}
}
use lock::held;
fn missing(id: &str) -> Error {
Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::<String>()))
}
@@ -84,17 +204,14 @@ fn read_entry(path: &Path) -> Option<Entry> {
/// Entries left by apps that are no longer running, newest first.
pub fn orphans(dir: &Path) -> Vec<Entry> {
let mut out: Vec<Entry> = fs::read_dir(dir)
let mut out: Vec<Entry> = list(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let p = e.path();
.filter_map(|p| {
if p.extension().and_then(|x| x.to_str()) != Some("json") {
return None;
}
let entry = read_entry(&p)?;
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file()
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !is_file(&file(dir, &entry.id, EXTENSION))
{
return None;
}
@@ -126,7 +243,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
return Err(Error::Io(format!("recovered model `{id}` belongs to a running app")));
}
for ext in ["json", EXTENSION, "lock"] {
let _ = fs::remove_file(file(dir, id, ext));
let _ = remove_file(&file(dir, id, ext));
}
Ok(())
}
@@ -136,32 +253,21 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
pub struct Slot {
dir: PathBuf,
id: String,
lock: Option<File>,
lock: Option<lock::Lock>,
has_entry: bool,
}
impl Slot {
/// Take a new entry in `dir` (created if needed), holding its lock.
pub fn new(dir: &Path) -> Result<Slot> {
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
fs::create_dir_all(dir).map_err(io)?;
// Locks of apps that ended with nothing to recover.
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("lock")
&& !p.with_extension("json").exists()
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
{
let _ = fs::remove_file(&p);
}
}
create_dir_all(dir)?;
lock::sweep(dir);
// Unique within the process too: an app started again in the same second (tests that
// "crash" and restart) must not take over the crashed app's entry.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
lock.lock().map_err(io)?;
let id = format!("{}-{}-{seq}", unix_now(), lock::instance());
let lock = lock::take(dir, &id)?;
Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false })
}
@@ -189,7 +295,7 @@ impl Slot {
pub fn clear(&mut self) {
if self.has_entry {
for ext in ["json", EXTENSION] {
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
let _ = remove_file(&file(&self.dir, &self.id, ext));
}
self.has_entry = false;
}
@@ -198,15 +304,16 @@ impl Slot {
/// The app is closing: the lock is released; with no autosave the lock file goes too, while
/// an autosave stays for the next launch to offer.
pub fn close(mut self) {
self.lock = None;
if !self.has_entry {
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
if let Some(lock) = self.lock.take() {
lock::release(lock, &self.dir, &self.id, self.has_entry);
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn dir(tag: &str) -> PathBuf {
+11 -1
View File
@@ -1,6 +1,6 @@
//! Where tool calls end up: an in-process headless session, or a running app's control channel
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.camera`,
//! `ui.render`, `ui.capture`).
//! `ui.render`, `ui.capture`, `ui.select`, `ui.selection`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
@@ -43,6 +43,8 @@ pub trait Backend {
fn commands(&mut self) -> Result<Value, String>;
/// A PNG of the model, or of the app's window when one is attached.
fn picture(&mut self, request: &PictureRequest) -> Result<Picture, String>;
/// A request to the app's window (`ui.select`, `ui.selection`): an error without one.
fn window(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// The folders file access is confined to (checked by the server for the files it writes
/// itself).
fn roots(&self) -> &Roots;
@@ -124,6 +126,10 @@ impl Backend for Headless {
Ok(Picture { png, info })
}
fn window(&mut self, method: &str, _params: Value) -> Result<Value, String> {
Err(format!("{method} needs the app's window: start the server with --connect to a running FieldCraft"))
}
fn roots(&self) -> &Roots {
&self.roots
}
@@ -267,6 +273,10 @@ impl Backend for Remote {
read
}
fn window(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.call(method, params)
}
fn roots(&self) -> &Roots {
&self.roots
}
+5
View File
@@ -78,6 +78,8 @@ fn initialize_lists_tools_and_resources() {
"probe",
"reactions",
"screenshot",
"select",
"selection",
"undo",
"redo",
] {
@@ -181,6 +183,9 @@ fn hostile_tool_arguments_are_tool_errors() {
("screenshot", json!({"camera": {"yaw": 0, "pitch": 0}, "view": "top"})),
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
("select", json!({"filter": "faces"})),
("select", json!({})),
("selection", json!({})),
("undo", json!({})),
("redo", json!({})),
] {
+44 -1
View File
@@ -14,7 +14,7 @@ pub const MAX_STEPS: usize = 10_000;
/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is
/// left out.
const MESHERS: &[(&str, &str, &str)] = &[
("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"),
("tet", "mesh.tet.generate", "constrained Delaunay tets of every body of the open design, one {body}, or an imported surface {surface}"),
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
@@ -290,6 +290,41 @@ pub fn tool_definitions() -> Vec<Value> {
true,
false,
),
tool(
"select",
"Select in the viewport",
"Change the app window's viewport selection (needs --connect; ui.select): `filter` (faces, edges, vertices, \
bodies, nodes, element_faces, elements), `mode` (replace, add, remove, toggle), `clear`, items by name and \
label as model.set.create takes them (`geometry`, `nodes`, `elements`, `faces`), a pick at a window point \
`at` [x, y] or the visible items in a box `box` [x0, y0, x1, y1]. Returns the selection; its `params` are \
a set's members.",
obj(
json!({
"filter": {"type": "string", "enum": ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"]},
"box_mode": {"type": "boolean", "description": "Box selection on or off in the window"},
"mode": {"type": "string", "enum": ["replace", "add", "remove", "toggle"]},
"clear": {"type": "boolean", "description": "Empty the selection first"},
"geometry": {"type": "array", "items": {"type": "object"}, "description": "[{body, face|edge|vertex}]"},
"nodes": {"type": "array", "items": {"type": "integer"}},
"elements": {"type": "array", "items": {"type": "integer"}},
"faces": {"type": "array", "items": {"type": "object"}, "description": "[{element, face}]"},
"at": {"type": "array", "items": {"type": "number"}, "description": "Window point [x, y] to pick at"},
"box": {"type": "array", "items": {"type": "number"}, "description": "Window box [x0, y0, x1, y1]"},
}),
&[],
),
false,
false,
),
tool(
"selection",
"Read the viewport selection",
"The app window's viewport selection (needs --connect; ui.selection): filter, items, `params` (the \
selection as a set's members, ready for model.set.create) and what is under the pointer.",
obj(json!({}), &[]),
true,
false,
),
tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false),
]
@@ -440,6 +475,14 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
"show_results" => show_results(b, a),
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
"select" => {
let p = pick(a, &["filter", "box_mode", "mode", "clear", "geometry", "nodes", "elements", "faces", "at", "box"]);
if p.as_object().is_none_or(|o| o.is_empty()) {
return Err("select: give `filter`, `mode`, `clear`, items, `at` or `box`".into());
}
b.window("ui.select", p)
}
"selection" => b.window("ui.selection", json!({})),
"undo" => b.execute("edit.undo", json!({})),
"redo" => b.execute("edit.redo", json!({})),
other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::<String>())),
+1 -51
View File
@@ -8,43 +8,12 @@
//! the hull. The hull is the bounding box the recovery adds around the surface, so every Steiner
//! point is strictly inside it and no ghost tetrahedra are needed.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
pub(super) use crate::tet::complex::{IdMap, NONE, face_toward, sorted3};
use crate::delaunay::Triangulation;
use crate::predicates::{GenericPoint, Sign, generic_insphere_sos, generic_lex, generic_orient3d};
use crate::{Error, Result};
/// A multiplicative hasher for small integer keys (vertex triples). The maps it serves are only
/// looked up, never iterated, so its order cannot leak into the output.
#[derive(Default)]
pub(super) struct IdHasher(u64);
impl Hasher for IdHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u32(&mut self, x: u32) {
self.write_u64(u64::from(x));
}
fn write_u64(&mut self, x: u64) {
self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
}
/// A hash map keyed by vertex ids.
pub(super) type IdMap<K, V> = HashMap<K, V, BuildHasherDefault<IdHasher>>;
/// No tetrahedron / no vertex.
pub(super) const NONE: u32 = u32::MAX;
/// Marks a recycled tetrahedron slot.
const DEAD: u32 = u32::MAX - 1;
@@ -52,25 +21,6 @@ pub(super) fn corrupt(what: &str) -> Error {
Error::Failed(format!("constrained tetrahedralization is inconsistent: {what}"))
}
/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side
/// ([`Complex::volume`] of the face plus that vertex is positive).
pub(super) fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] {
let [a, b, c, d] = vs;
match k {
0 => [b, d, c],
1 => [a, c, d],
2 => [a, d, b],
_ => [a, b, c],
}
}
/// A triangle's vertices sorted: the key of an unoriented face.
pub(super) fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] {
let mut f = [a, b, c];
f.sort_unstable();
f
}
/// The working tetrahedralization.
#[derive(Debug, Clone)]
pub(super) struct Complex {
+10 -9
View File
@@ -34,9 +34,10 @@
//! then the Steiner points, each with its segment and parameter, rounded to doubles; and for each
//! boundary tetrahedron face the input triangle it lies on (so the B-rep face tag carries over).
//! Rounding a Steiner point to the nearest doubles moves it off its segment by under an ulp and
//! can, on a nearly flat tetrahedron, flip it (§5.1). [`round`] then tries the doubles a few ulps
//! around such a point for one that keeps every incident tetrahedron positive; [`TetMesh::gate`]
//! runs the validity gate on the rounded mesh and any failure left is reported, never hidden.
//! can, on a nearly flat tetrahedron, flip it (§5.1); nearly cospherical input also gives
//! tetrahedra that are positive yet flat to rounding. [`repair`] removes them by flips that keep
//! the boundary (§5.1); [`TetMesh::gate`] runs the validity gate on the rounded mesh and any
//! failure left is reported, never hidden.
//!
//! # Determinism and bounds
//! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are
@@ -46,11 +47,11 @@
mod complex;
mod facets;
mod meet;
mod round;
mod repair;
mod segments;
#[cfg(test)]
mod tests;
pub(crate) mod tests;
use std::collections::HashMap;
@@ -122,9 +123,9 @@ pub struct CdtStats {
pub gift_wrapped_tets: usize,
/// Tetrahedra in the box at the end.
pub box_tets: usize,
/// Steiner points moved by a few ulps from their nearest doubles to keep every incident
/// tetrahedron positive ([`round`]).
pub rounding_adjusted: usize,
/// Tetrahedra that were flat, inverted or inadmissible on the rounded coordinates and were
/// removed by flips ([`repair`]).
pub repaired: usize,
}
/// The constrained Delaunay tetrahedralization of a closed surface (see the module docs).
@@ -355,7 +356,7 @@ impl Recovery {
}
let mut stats = self.stats;
stats.box_tets = self.cx.live_count();
stats.rounding_adjusted = round::repair(&mut points, &tets, n)?;
stats.repaired = repair::repair(&points, &mut tets, &mut boundary, self.options.max_tets)?;
Ok(TetMesh { points, steiner, tets, boundary, stats })
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Repairing the tetrahedra that rounding to doubles leaves flat, inverted or inadmissible.
//!
//! The exact tetrahedralization is valid, but its Steiner points are rounded to doubles, which
//! can flip a nearly flat tetrahedron (Diazzi et al. 2023, §5.1), and nearly cospherical input
//! (a tessellated sphere, a cylinder) gives tetrahedra that are positive yet flat to rounding:
//! their normalized Jacobian is below FieldCraft's validity criterion
//! ([`fieldcraft_model::admissible_jacobian`]), so a solver would refuse them. Following §5.1,
//! such tetrahedra are removed by flips that never touch the boundary, on the rounded
//! coordinates and decided by exact `orient3d`: edge removal (which includes the 3-2 and 4-4
//! flips) around each of their edges and face removal (the 2-3 flip and multi-face removal)
//! across each of their faces, each applied only when it improves the worst tetrahedron it
//! changes ([`crate::tet::ops`]). Every boundary edge is held fixed, so every input triangle
//! stays a union of tetrahedron faces. The passes are capped; whatever is left is reported by the
//! validity gate, never hidden.
use fieldcraft_model::{normalized_jacobian, tet4_jacobian};
use super::BoundaryFace;
use crate::tet::complex::{EDGES, NONE, TetComplex, VertexKind};
use crate::tet::ops::{remove_edge_at, remove_face};
use crate::tet::refine::feature_curves;
use crate::{Error, Result};
/// A tetrahedron is repaired when its normalized Jacobian is at most this (a margin of a
/// thousand above the validity criterion, so rounding differences cannot bring it back).
const REPAIR_BELOW: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN;
/// Passes over the tetrahedra still to repair.
const MAX_PASSES: usize = 8;
fn needs_repair(points: &[[f64; 3]], t: &[u32; 4]) -> bool {
let p = |v: u32| points.get(v as usize).copied().unwrap_or([f64::NAN; 3]);
!(normalized_jacobian(&tet4_jacobian(&[p(t[0]), p(t[1]), p(t[2]), p(t[3])])) > REPAIR_BELOW)
}
/// Repair the rounded mesh in place; the number of tetrahedra that needed it.
pub(super) fn repair(points: &[[f64; 3]], tets: &mut Vec<[u32; 4]>, boundary: &mut Vec<BoundaryFace>, max_tets: usize) -> Result<usize> {
let bad = tets.iter().filter(|t| needs_repair(points, t)).count();
if bad == 0 {
return Ok(0);
}
let tags: Vec<(u32, usize, u32)> = boundary.iter().map(|b| (b.tet, usize::from(b.opposite), b.triangle)).collect();
let kinds = vec![VertexKind::Corner; points.len()];
let mut cx = TetComplex::with_invalid(points, kinds, tets, &tags, &[], points.len(), max_tets.max(tets.len() * 2))?;
// The segments: boundary edges between two different input triangles (never by angle).
// Edges inside one input triangle (between its pieces) may be flipped; the triangle stays
// covered.
let (_, segments) = feature_curves(&cx, -2.0)?;
for ((a, b), c) in segments {
cx.set_curve(a, b, Some(c));
}
for _ in 0..MAX_PASSES {
let targets: Vec<(u32, [u32; 4])> = cx.live_tets().filter(|(_, vs)| needs_repair(points, vs)).collect();
if targets.is_empty() {
break;
}
let mut changed = false;
for (t, vs) in targets {
if !cx.is_live(t) || cx.tet(t)? != vs {
continue;
}
for &(i, j) in &EDGES {
if cx.is_live(t) && cx.tet(t)? == vs && remove_edge_at(&mut cx, t, vs[i], vs[j])? {
changed = true;
break;
}
}
// A flat tetrahedron lying on one input triangle (two of its faces are pieces of
// it): delete it; its two other faces cover the same piece of the triangle.
if cx.is_live(t) && cx.tet(t)? == vs && cx.delete_boundary_tet(t)? {
changed = true;
continue;
}
if cx.is_live(t) && cx.tet(t)? == vs {
for k in 0..4 {
if remove_face(&mut cx, t, k)? {
changed = true;
break;
}
}
}
}
if !changed {
break;
}
}
let c = cx.compact()?;
if c.source.len() != points.len() || c.source.iter().enumerate().any(|(i, &s)| s as usize != i) {
return Err(Error::Failed("repair removed a vertex".into()));
}
*tets = c.tets;
*boundary = c.boundary.into_iter().map(|(tet, k, tag)| BoundaryFace { tet, opposite: k as u8, triangle: tag }).collect();
if boundary.iter().any(|b| b.triangle == NONE) {
return Err(Error::Failed("repair lost a boundary face's triangle".into()));
}
Ok(bad)
}
-126
View File
@@ -1,126 +0,0 @@
//! Rounding the Steiner points to doubles without inverting tetrahedra.
//!
//! The exact tetrahedralization is valid, but a Steiner point rounded to its nearest doubles is
//! off its segment by up to half an ulp per coordinate, which flips a tetrahedron whose volume is
//! below that scale (Diazzi et al. 2023, §5.1, §6.2: the nearly flat ones, typically four Steiner
//! points on nearly parallel boundary edges). For each tetrahedron that is not positive after
//! rounding, each of its Steiner points tries the doubles within [`REACH`] ulps of its rounded
//! coordinates, nearest first, and takes the first with which every tetrahedron around it is
//! positive (exact `orient3d` on the doubles). Surface vertices never move. The moves are a few
//! ulps, so boundary faces stay on their triangles to rounding precision. The number of passes is
//! capped; what is left is for the validity gate to report.
//!
//! This is the interim answer. The paper's own (§5.1) improves the exact mesh by flips that never
//! cross a facet before rounding, which removes most nearly flat tetrahedra instead of nudging
//! their vertices; it belongs with the flips of the quality stage.
use crate::predicates::{Point3, Sign, tet_volume_sign};
use crate::{Error, Result};
/// How far, in ulps per coordinate, a rounded Steiner point may move.
const REACH: i32 = 2;
/// Passes over the tetrahedra that are not positive.
const MAX_PASSES: usize = 4;
fn positive(points: &[[f64; 3]], t: &[u32; 4]) -> Result<bool> {
let p = |v: u32| -> Result<Point3> {
let c = points.get(v as usize).copied().ok_or_else(|| Error::Failed("rounding: vertex id out of range".into()))?;
Point3::try_from(c)
};
Ok(tet_volume_sign(&p(t[0])?, &p(t[1])?, &p(t[2])?, &p(t[3])?) == Sign::Positive)
}
/// `x` moved by `k` ulps.
fn step(mut x: f64, k: i32) -> f64 {
for _ in 0..k.unsigned_abs() {
x = if k > 0 { x.next_up() } else { x.next_down() };
}
x
}
/// Offsets within [`REACH`], nearest (smallest total) first, in a fixed order.
fn offsets() -> Vec<[i32; 3]> {
let r = -REACH..=REACH;
let mut o: Vec<[i32; 3]> = r.clone().flat_map(|a| r.clone().flat_map(move |b| (-REACH..=REACH).map(move |c| [a, b, c]))).collect();
o.retain(|d| d != &[0, 0, 0]);
o.sort_by_key(|d| (d.iter().map(|k| k.abs()).sum::<i32>(), *d));
o
}
/// Move Steiner points (ids `>= surface`) so every tetrahedron is positive where a few-ulp move
/// does it. The number of points moved.
pub(super) fn repair(points: &mut [[f64; 3]], tets: &[[u32; 4]], surface: usize) -> Result<usize> {
let mut bad = Vec::new();
for (i, t) in tets.iter().enumerate() {
if !positive(points, t)? {
bad.push(i);
}
}
if bad.is_empty() {
return Ok(0);
}
let mut around: Vec<Vec<u32>> = vec![Vec::new(); points.len()];
for (i, t) in tets.iter().enumerate() {
for &v in t {
if (v as usize) >= surface
&& let Some(a) = around.get_mut(v as usize)
{
a.push(i as u32);
}
}
}
let offsets = offsets();
let mut moved = vec![false; points.len()];
for _ in 0..MAX_PASSES {
let mut still = Vec::new();
for &i in &bad {
let t = *tets.get(i).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if positive(points, &t)? {
continue;
}
let mut fixed = false;
for v in t.into_iter().filter(|&v| (v as usize) >= surface) {
let Some(origin) = points.get(v as usize).copied() else { continue };
let incident = around.get(v as usize).cloned().unwrap_or_default();
for d in &offsets {
let candidate: [f64; 3] = std::array::from_fn(|k| step(origin[k], d[k]));
if candidate.iter().any(|x| !crate::predicates::is_admissible(*x)) {
continue;
}
if let Some(p) = points.get_mut(v as usize) {
*p = candidate;
}
let mut ok = true;
for &j in &incident {
let u = tets.get(j as usize).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if !positive(points, u)? {
ok = false;
break;
}
}
if ok {
fixed = true;
if let Some(m) = moved.get_mut(v as usize) {
*m = true;
}
break;
}
if let Some(p) = points.get_mut(v as usize) {
*p = origin;
}
}
if fixed {
break;
}
}
if !fixed {
still.push(i);
}
}
if still.is_empty() || still.len() == bad.len() {
break;
}
bad = still;
}
Ok(moved.iter().filter(|m| **m).count())
}
+3 -3
View File
@@ -10,7 +10,7 @@ use crate::vec3::{cross, dot, norm, sub};
/// A closed surface from points and triangles, each triangle oriented outward from `centre`
/// (the shapes here are star-shaped about it).
fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
pub(crate) fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
let oriented: Vec<[u32; 3]> = triangles
.iter()
.map(|&[a, b, c]| {
@@ -47,7 +47,7 @@ fn check(s: &BoundarySurface, m: &TetMesh) {
}
}
fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
pub(crate) fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, (i >> 1 & 1) as f64, (i >> 2 & 1) as f64]).collect();
let quads = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]];
let t = quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect();
@@ -98,7 +98,7 @@ fn schonhardt_prism_gets_steiner_points_and_conforms() {
}
/// A UV sphere: many cospherical vertices.
fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
pub(crate) fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
let mut p = vec![[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]];
for r in 1..rings {
let th = std::f64::consts::PI * r as f64 / rings as f64;
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more