Compare commits

...
25 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
83 changed files with 2917 additions and 855 deletions

No files matched your search

+1 -1
View File
@@ -41,6 +41,6 @@ body:
id: version
attributes:
label: Version and platform
placeholder: "v0.1.0 (or commit), macOS 15 arm64"
placeholder: "v0.5.5 (or commit), macOS 15 arm64"
validations:
required: true
+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: ["*"]
+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
+41 -12
View File
@@ -1,9 +1,11 @@
name: CI
# CI runs once per merge, on main (AGENTS.md, "Workflow for every change"): the core team's pull
# requests are checked locally on the crates they touch and merged; a red main is fixed forward.
# CI runs once per merge on `develop`, the trunk (AGENTS.md, "Workflow for every change"): the core
# team's pull requests are checked locally on the crates they touch and merged; a red trunk is
# fixed forward. `main` holds released code only (docs/releasing.md).
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
# the run, so the review sees it.
# the run, so the review sees it; so do Dependabot's (.github/dependabot.yml), which nobody tests
# locally. The heavy job stays off pull requests.
on:
pull_request:
paths:
@@ -13,7 +15,7 @@ on:
- 'docs/features.md'
- 'docs/oracle.md'
push:
branches: [main]
branches: [develop]
# A merge that only touches prose starts no run. The generated pages are checked by the jobs
# (features.md by `cargo xtask ci`, oracle.md by the oracle job), so they still count.
# (Later patterns override earlier ones.)
@@ -24,8 +26,11 @@ on:
- 'docs/features.md'
- 'docs/oracle.md'
schedule:
# Nightly: the heavy tests on main.
# Nightly, on the default branch (develop): the heavy tests.
- cron: '23 3 * * *'
# Weekly: the oracle on macOS too, so results that differ by platform show up in CI (the
# fixtures are often blessed on a Mac; #115).
- cron: '41 4 * * 0'
workflow_dispatch:
permissions:
@@ -43,7 +48,7 @@ env:
jobs:
ci:
name: cargo xtask ci
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -54,13 +59,19 @@ jobs:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
# A dependency update changes THIRD-PARTY-LICENSES.txt, which Dependabot cannot regenerate:
# regenerate it here so the gate checks everything else, and dependabot-licences.yml commits
# it to the pull request.
- name: Regenerate THIRD-PARTY-LICENSES.txt (Dependabot)
if: github.event.pull_request.user.login == 'dependabot[bot]'
run: cargo xtask licences
- run: cargo xtask ci
heavy:
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
name: cargo xtask test --heavy
if: github.event_name != 'pull_request'
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -68,7 +79,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask test --heavy
oracle:
@@ -76,7 +87,7 @@ jobs:
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
# run here too while the whole set stays within a few minutes; `--quick` skips them.
name: cargo xtask oracle
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
@@ -85,14 +96,32 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
oracle-macos:
# The same oracle on macOS, weekly and on demand: the solvers must agree across platforms
# to the fixture bands, and docs/oracle.md must read the same.
name: cargo xtask oracle (macOS)
if: github.event.schedule == '41 4 * * 0' || github.event_name == 'workflow_dispatch'
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle-macos
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
advisories:
name: Dependency advisories and sources
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
+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
+11 -10
View File
@@ -1,11 +1,12 @@
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `release` (fast-forwarded from main) builds every package and creates or updates the
# A push to `main` (fast-forwarded from a verified `develop` commit; main holds released code only)
# builds every package and creates or updates the
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt and generated notes. The
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
# pre-release. A person verifies the draft and publishes it; this workflow never publishes,
# and pushes to main or tags never start it.
# and pushes to develop or tags never start it.
#
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
# Linux x86_64 (glibc 2.35+) .tar.gz and .AppImage packaging/linux
@@ -15,17 +16,17 @@
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `release` retries
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
# the draft.
name: Release
on:
push:
branches: [release]
branches: [main]
workflow_dispatch:
inputs:
draft:
description: "Create/update the draft release (release branch only; off: build artifacts only)"
description: "Create/update the draft release (main only; off: build artifacts only)"
type: boolean
default: false
@@ -60,8 +61,8 @@ jobs:
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1, not '$VERSION'"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
echo "::error::Draft releases are built from the release branch only"
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/main ]; then
echo "::error::Draft releases are built from main only"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
@@ -86,7 +87,7 @@ jobs:
with:
key: release-macos-universal
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -129,7 +130,7 @@ jobs:
with:
key: release-linux-x86_64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build and package (tar.gz, AppImage)
run: packaging/linux/package.sh
- name: Check the packages
@@ -167,7 +168,7 @@ jobs:
with:
key: release-windows-x64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/release' }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build and package (zip)
shell: pwsh
run: ./packaging/windows/package.ps1
+12 -10
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,11 +108,12 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
oracle cases or solver numerics change (`cargo xtask oracle`).
3. Fold every fix into the commit it fixes, never a separate "fmt", "clippy", "review fixes" or
"regenerate features.md" commit: `git commit --fixup <sha>`, then
`git rebase -i --autosquash origin/main` (`GIT_SEQUENCE_EDITOR=true` keeps it
`git rebase -i --autosquash origin/develop` (`GIT_SEQUENCE_EDITOR=true` keeps it
non-interactive). A regenerated page goes into the commit that changed what it lists.
4. Rebase onto the latest `origin/main` (never merge `main` into a branch), rerun the tests of
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `main`
linear and every commit bisectable. `main` is protected (CODEOWNERS review, linear history,
4. Branch from and rebase onto the latest `origin/develop`, the trunk (never merge `develop` into
a branch), rerun the tests of
the touched crates, push, open the PR and merge it with a rebase merge, which keeps `develop`
linear and every commit bisectable. `develop` and `main` are protected (CODEOWNERS review, linear history,
rebase merges only); the core team merges as the maintainer with
`gh pr merge <N> --rebase --admin --delete-branch`, run as its own command. Outside
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
@@ -120,20 +121,21 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
oracle, the heavy tests and the advisories once per merge on `main`; the coordinator
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
watches it and sends a failure back to the change that caused it, which is fixed forward
at once.
- **Keep the main gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
- **Keep the trunk gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
(convergence studies, the whole corpus, cross-checks between solvers) is
`#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative
test left in the main gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
test, benchmarks included, and the doc tests) on every merge to `main`, nightly and on
test left in the trunk gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
test, benchmarks included, and the doc tests) on every merge to `develop`, nightly and on
demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
test.
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
as the workflow above describes; the coordinator reads every merged diff and sends findings
back as fix-forward work. Never force-push `main`. Never commit `plan/`, `.agent-files/`,
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
`worktrees/` or `target*/`; check `git ls-files` before pushing.
## Running
+9 -5
View File
@@ -134,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 |
@@ -172,10 +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/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
| packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 |
| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 |
| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 |
| apps/fieldcraft-web/ (the web runner, browser file services and downloads, Trunk configuration, index.html) | VectorCraft `apps/vectorcraft-web/` and SolidCraft `apps/solidcraft-web/` | MIT OR Apache-2.0 |
| crates/io/src/memory.rs (files kept in memory for the browser build) | SolidCraft `crates/io/src/vfs.rs` | MIT OR Apache-2.0 |
| .github/workflows/pages.yml | SolidCraft `.github/workflows/pages.yml` | MIT OR Apache-2.0 |
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
## Test oracles
+59 -5
View File
@@ -1,7 +1,59 @@
# Contributing
# Contributing to FieldCraft
Thanks for helping. Read [AGENTS.md](AGENTS.md) first: it holds the rules every change follows,
whether a person or an agent writes it.
FieldCraft is one open-source modeler for structures, heat and fluids, written in pure Rust: open a
parametric SolidCraft part, mesh it (tet or all-hex), define the analysis, solve it with our own
implicit, explicit, thermal or lattice Boltzmann solvers, and look at verified results — from the
desktop app, the command line, or an AI agent over MCP.
Help is welcome, whether that's a one-line fix, a verification benchmark, a file format or owning a
whole solver. This page covers how to build it, the few rules that aren't negotiable, where help is
needed most, and how to claim work. The rules every change follows, by a person or an agent, are in
[AGENTS.md](AGENTS.md).
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer.
```sh
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo test -p fieldcraft-solver # the tests of one crate
cargo xtask oracle --quick # the verification cases (docs/oracle.md)
```
`develop` is the trunk and `main` holds released code. The layers and crates are in
[docs/architecture.md](docs/architecture.md); every user-visible capability is a command in
`crates/engine/src/cmd/` ([docs/features.md](docs/features.md) lists them).
## Where help is wanted most
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
the `area:` labels for your field. In order of how much they would help users:
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
must give the same tet mesh on every platform and every run, as the solvers already do.
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
are next. Hex meshing is the centre of gravity of the project.
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
can add it as an oracle case without touching solver code.
5. **Nonlinear elements and materials**: incompatible modes at finite strain
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
propagation, turbulence — are open to anyone who wants to own one: start a thread in
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
## Before you start: claim an issue
@@ -18,7 +70,8 @@ Issues labelled **in progress** are being worked on by the core team; please don
## Making the change
1. Branch from `main`, keep a pull request to one topic.
1. Branch from `develop` (the trunk; `main` holds released code only) and open the pull request
against `develop`. Keep it to one topic.
2. Before pushing: `cargo fmt --all`, and `cargo clippy -p <crate> --all-targets -- -D warnings`
and `cargo test -p <crate>` for the crates you touched. CI runs the full gate on your pull
request once the maintainer approves the run.
@@ -30,6 +83,7 @@ Issues labelled **in progress** are being worked on by the core team; please don
By contributing you agree your work is licensed under MIT OR Apache-2.0.
`main` is protected: changes land only through pull requests that the maintainer has reviewed,
`develop` and `main` are protected: changes land on `develop` only through pull requests that the
maintainer has reviewed,
as rebase merges (keep each commit building and passing its tests). Be kind: see the
[code of conduct](CODE_OF_CONDUCT.md).
Generated
+114 -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]]
@@ -1187,7 +1237,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fieldcraft"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"eframe",
"egui",
@@ -1202,7 +1252,7 @@ dependencies = [
[[package]]
name = "fieldcraft-cli"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
@@ -1212,7 +1262,7 @@ dependencies = [
[[package]]
name = "fieldcraft-decks"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"serde_json",
@@ -1221,7 +1271,7 @@ dependencies = [
[[package]]
name = "fieldcraft-engine"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-decks",
"fieldcraft-explicit",
@@ -1243,7 +1293,7 @@ dependencies = [
[[package]]
name = "fieldcraft-explicit"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-fem",
"fieldcraft-model",
@@ -1254,7 +1304,7 @@ dependencies = [
[[package]]
name = "fieldcraft-fem"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"thiserror 2.0.21",
@@ -1262,7 +1312,7 @@ dependencies = [
[[package]]
name = "fieldcraft-geometry"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"quick-xml",
@@ -1278,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",
@@ -1301,7 +1351,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mcp"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
@@ -1312,7 +1362,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mesh"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-model",
"num-bigint",
@@ -1324,7 +1374,7 @@ dependencies = [
[[package]]
name = "fieldcraft-model"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"serde",
"serde_json",
@@ -1333,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",
@@ -1350,7 +1400,7 @@ dependencies = [
[[package]]
name = "fieldcraft-solver"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-fem",
"fieldcraft-mesh",
@@ -1362,7 +1412,7 @@ dependencies = [
[[package]]
name = "fieldcraft-testkit"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"fieldcraft-engine",
"fieldcraft-geometry",
@@ -1373,7 +1423,7 @@ dependencies = [
[[package]]
name = "fieldcraft-ui-egui"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"craft-ui",
"egui",
@@ -1390,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"
@@ -1750,6 +1819,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3238,8 +3316,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -3406,7 +3495,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-sketch",
@@ -3448,7 +3537,7 @@ dependencies = [
"quick-xml",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4897,7 +4986,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.5.5"
dependencies = [
"serde_json",
]
+2 -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"
+15
View File
@@ -22,6 +22,21 @@ 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
+1 -1
View File
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, command schema as the public API | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
## Next
+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
================================================================================
+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(())
}
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": [
{
+6 -5
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,10 +15,10 @@ hand. It runs on macOS (sips, iconutil, `xcrun actool`) with ImageMagick (`magic
| File | What it is |
|---|---|
| `icon.vectorcraft`, `icon.svg`, `icon.png` | The full icon on its backdrop, 1024 × 1024 |
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: no mesh or floor grid, faces edge to edge |
| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: the fringe cube alone, no mesh or floor grid, faces edge to edge |
| `icon.iconset/`, `icon.icns` | macOS iconset (sips) and its iconutil compilation |
| `icon.ico` | Windows icon, 16–256 px |
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), part and mesh layers, each scaled to 90% |
| `FieldCraft.icon/` | Icon Composer document: floor grid (Default and Dark), and cube (faces and mesh) layers, each scaled to 90% |
| `layers/*.vectorcraft` | Editable sources of the Icon Composer layers |
| `Assets.car` | `actool` compilation of `FieldCraft.icon` for macOS 26 and later (needs Xcode 26 or later; the task skips it with a note otherwise) |
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.
+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();
+1 -1
View File
@@ -33,7 +33,7 @@ pub(super) struct Input<'a> {
pub(super) name: String,
pub(super) mesh: Cow<'a, SurfaceMesh>,
surfaces: Vec<Option<Surface>>,
face_names: Vec<String>,
pub(super) face_names: Vec<String>,
pub(super) diagonal: f64,
}
+176 -53
View File
@@ -8,7 +8,7 @@ use fieldcraft_mesh::quality::{self, QualityReport};
use fieldcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions};
use fieldcraft_mesh::tet::geometry::AnalyticSurface;
use fieldcraft_mesh::tet::improve::ImproveOptions;
use fieldcraft_mesh::tet::{Order, QualityOptions, TetQuality, quality_mesh, raw_mesh};
use fieldcraft_mesh::tet::{Order, QualityMesh, QualityOptions, TetQuality, quality_mesh, raw_mesh};
use fieldcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS};
use serde_json::{Value, json};
@@ -86,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 "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if the surface is invalid or the mesh does not pass the validity gate"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the body's bounding-box diagonal), the boundary options of mesh.surface.check ("weld", "weld_distance", "feature_angle"), optional "size": mm (target edge length; default a twentieth of the bounding-box diagonal), "order": "linear" | "quadratic" (tet4 or tet10; quadratic mid-edge nodes lie on the B-rep faces), "quality": true (false: the raw constrained Delaunay tetrahedralization), "quality_target": 40 (degrees: tetrahedra whose smallest dihedral-angle sine is at least sin of this are left alone by improvement, 5..=60), "curvature_angle": 22.5 (degrees per element along curved boundary, 0 for none, at most 90), "grading": 0.3 (largest size change per unit length, 0.05..=2), "max_passes": 20 (improvement passes, 0..=200), "max_points": 1000000 (1..=20000000), "max_steiner": 50000 (0..=10000000), "max_tets": 5000000 (1..=50000000) — the constrained Delaunay tetrahedralization of the welded boundary (every boundary triangle a union of tet faces), refined to the size field with its boundary vertices on the B-rep faces, smoothed and improved (Delaunay refinement, ODT smoothing, Klingner–Shewchuk improvement), with a quality report. Adds the elements and their nodes (labels after the largest existing ones), tags the elements with the body and each boundary element face with its geometry face, and fails, changing nothing, if a surface is invalid or a mesh does not pass the validity gate. Reports the totals and, under `bodies`, each body's counts, size and quality report"#,
kind: CommandKind::Model,
run: tet_generate,
};
@@ -199,24 +199,113 @@ fn quality_json(q: &TetQuality) -> Value {
})
}
/// One body's tetrahedra, ready to add, with what its report needs of the surface and the CDT.
struct BodyTets {
name: String,
face_names: Vec<String>,
mesh: QualityMesh,
steiner: usize,
cavities: usize,
surface_volume: f64,
}
/// Mesh one body or imported surface; the model is not touched. With `several` bodies an error
/// names the body.
fn mesh_body(
s: &Session,
p: &Value,
input: super::hex::Input,
cdt: &CdtOptions,
options: &QualityOptions,
quality: bool,
several: bool,
) -> Result<BodyTets> {
let mesh = || -> Result<BodyTets> {
// The exact faces of a design body; an imported surface keeps to its triangles.
let analytic = match p.get("surface") {
None => s.design.as_deref().and_then(|d| d.body(&input.name)).map(analytic_surfaces).unwrap_or_default(),
Some(_) => Vec::new(),
};
let surface = BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &surface_options(p, &input.mesh)?)?;
let tets = tetrahedralize(&surface, cdt)?;
let mesh = if quality { quality_mesh(&surface, &tets, analytic, options)? } else { raw_mesh(&surface, &tets)? };
Ok(BodyTets {
name: input.name.clone(),
face_names: input.face_names.clone(),
mesh,
steiner: tets.steiner.len(),
cavities: tets.stats.cavities,
surface_volume: surface.volume(),
})
};
mesh().map_err(|e| if several { EngineError::Params(format!("body `{}`: {e}", input.name)) } else { e })
}
fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let (options, quality) = quality_options(p, s.limits.max_tets as u64)?;
let cdt_options =
CdtOptions { max_steiner: optional_count(p, "max_steiner", s.limits.max_steiner as u64, 0..=TET_MAX_STEINER)?, max_tets: options.max_tets };
let (surface, body, face_names, analytic) = {
let Input { mesh, body, faces } = surface_input(s, p)?;
let analytic = s.design.as_deref().and_then(|d| d.body(&body)).filter(|_| p.get("body").is_some()).map(analytic_surfaces).unwrap_or_default();
let options = surface_options(p, &mesh)?;
(BoundarySurface::new(&mesh.positions, &mesh.triangles, &mesh.tri_face, &options)?, body, faces, analytic)
};
let cdt = tetrahedralize(&surface, &cdt_options)?;
let mesh = if quality { quality_mesh(&surface, &cdt, analytic, &options)? } else { raw_mesh(&surface, &cdt)? };
// Every body is meshed before the model changes; with several, an error names its body.
let inputs = super::hex::inputs(s, p)?;
let several = inputs.len() > 1;
let mut meshed = Vec::with_capacity(inputs.len());
for input in inputs {
meshed.push(mesh_body(s, p, input, &cdt_options, &options, quality, several)?);
}
let mut bodies = Vec::with_capacity(meshed.len());
for b in &meshed {
let (first_node, first_element) = add_tets(s, &b.name, &b.face_names, &b.mesh)?;
let r = &b.mesh.report;
bodies.push(json!({
"body": b.name,
"nodes": b.mesh.points.len(),
"elements": b.mesh.elements.len(),
"first_node": first_node,
"first_element": first_element,
"steiner": b.steiner,
"cavities": b.cavities,
"boundary_faces": b.mesh.boundary.len(),
"volume": r.volume,
"boundary_volume": r.boundary_volume,
"surface_volume": b.surface_volume,
"size": r.size,
"quality": {
"cdt": quality_json(&r.input),
"mesh": quality_json(&r.output),
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
"odt_moves": r.odt_moves,
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
"straightened": r.straightened,
"stopped": r.stopped,
},
}));
}
let sum = |f: fn(&BodyTets) -> f64| meshed.iter().map(f).sum::<f64>();
let count = |f: fn(&BodyTets) -> usize| meshed.iter().map(f).sum::<usize>();
let first = bodies.first();
Ok(json!({
"kind": meshed.first().map(|b| b.mesh.kind.name()),
"nodes": count(|b| b.mesh.points.len()),
"elements": count(|b| b.mesh.elements.len()),
"first_node": first.map(|b| b["first_node"].clone()),
"first_element": first.map(|b| b["first_element"].clone()),
"boundary_faces": count(|b| b.mesh.boundary.len()),
"volume": sum(|b| b.mesh.report.volume),
"boundary_volume": sum(|b| b.mesh.report.boundary_volume),
"surface_volume": sum(|b| b.surface_volume),
"bodies": bodies,
}))
}
/// Add one body's elements after the model's largest labels, tagging them with the body and each
/// boundary element face with its geometry face; returns the first node and element labels.
fn add_tets(s: &mut Session, body: &str, face_names: &[String], mesh: &QualityMesh) -> Result<(u32, u32)> {
let (first_node, first_element) = super::fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?;
for (i, xyz) in mesh.points.iter().enumerate() {
s.model.set_node(first_node + i as u32, *xyz)?;
}
let body_tag = s.model.geometry_tag(&GeometryRef::body(body.clone())?)?;
let body_tag = s.model.geometry_tag(&GeometryRef::body(body)?)?;
for (i, e) in mesh.elements.iter().enumerate() {
let label = first_element + i as u32;
s.model.set_element(label, mesh.kind, e.iter().map(|&v| first_node + v).collect())?;
@@ -229,7 +318,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
Some(t) => t,
None => {
let name = face_names.get(face).ok_or_else(|| EngineError::Params(format!("boundary face {} has no geometry face", b.face)))?;
let t = s.model.geometry_tag(&GeometryRef::face(body.clone(), name.clone())?)?;
let t = s.model.geometry_tag(&GeometryRef::face(body, name.clone())?)?;
if let Some(slot) = face_tags.get_mut(face) {
*slot = Some(t);
}
@@ -239,31 +328,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result<Value> {
let number = b.element_face().ok_or_else(|| EngineError::Params(format!("boundary face {} has no element face number", b.opposite)))?;
s.model.tag_element_face(first_element + b.tet, number, tag)?;
}
let r = &mesh.report;
Ok(json!({
"body": body,
"kind": mesh.kind.name(),
"nodes": mesh.points.len(),
"elements": mesh.elements.len(),
"first_node": first_node,
"first_element": first_element,
"steiner": cdt.steiner.len(),
"cavities": cdt.stats.cavities,
"boundary_faces": mesh.boundary.len(),
"volume": r.volume,
"boundary_volume": r.boundary_volume,
"surface_volume": surface.volume(),
"size": r.size,
"quality": {
"cdt": quality_json(&r.input),
"mesh": quality_json(&r.output),
"refined": r.refine.iter().map(|x| x.interior + x.boundary).sum::<usize>(),
"odt_moves": r.odt_moves,
"improvement": {"passes": r.improve.passes, "smoothed": r.improve.smoothed, "topological": r.improve.topological, "inserted": r.improve.inserted},
"straightened": r.straightened,
"stopped": r.stopped,
},
}))
Ok((first_node, first_element))
}
pub const PLANAR: CommandSpec = CommandSpec {
@@ -464,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,
@@ -475,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
@@ -497,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())),
}
@@ -508,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,
@@ -709,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);
@@ -723,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() {
@@ -766,7 +889,7 @@ mod tests {
let design = Arc::new(open_design(&plate_design(&plate).unwrap()).unwrap());
let mut s = Session { design: Some(design.clone()), ..Session::default() };
let r = s.execute("mesh.tet.generate", &json!({"body": "Body1", "tolerance": 0.2, "size": 6})).unwrap();
let q = &r["quality"];
let q = &r["bodies"][0]["quality"];
assert_eq!(q["mesh"]["inadmissible"], json!(0), "{r}");
assert_eq!(q["mesh"]["slivers"], json!(0), "{r}");
assert!(q["mesh"]["min_dihedral"].as_f64().unwrap() > 15.0, "{r}");
+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 {
+1 -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"),
+44 -3
View File
@@ -94,6 +94,10 @@ pub struct Quantity {
/// The fixture band's relative part, when the quantity is more sensitive to rounding than
/// [`FIXTURE_RELATIVE`] allows (an ill-conditioned problem); `fixture_reason` says why.
pub fixture_relative: f64,
/// Whether the result is frozen in `fixture.json`. A quantity that depends on a mesh the
/// mesher generates from geometry (`"fixture": false`, with a `fixture_reason`) is not: the
/// mesh is not bit-identical across platforms, so only its reference tolerance gates it.
pub frozen: bool,
}
/// One case's `expected.json`.
@@ -153,6 +157,17 @@ impl Expected {
if quantities.iter().any(|x: &Quantity| x.id == id) {
return Err(format!("quantity `{id}` is listed twice"));
}
let frozen = match q.get("fixture") {
None => true,
Some(Value::Bool(f)) => *f,
Some(_) => return Err(context("`fixture` must be true or false".into())),
};
if !frozen {
text(q, "fixture_reason").map_err(|_| context("`\"fixture\": false` needs a `fixture_reason`".into()))?;
if q.get("fixture_relative").is_some() {
return Err(context("`fixture_relative` has no meaning with `\"fixture\": false`".into()));
}
}
let fixture_relative = match q.get("fixture_relative") {
None => FIXTURE_RELATIVE,
Some(r) => {
@@ -163,6 +178,7 @@ impl Expected {
};
quantities.push(Quantity {
fixture_relative,
frozen,
description: text(q, "quantity").map_err(context)?,
known_failure: q.get("known_failure").and_then(Value::as_str).map(str::to_string),
id,
@@ -319,6 +335,7 @@ pub fn judge(q: &Quantity, ours: &Result<f64, String>, frozen: Option<&Frozen>)
(false, Some(_)) => Verdict::Known,
};
let fixture = match frozen {
_ if !q.frozen => FixtureCheck::NotKept,
Some(f) if (ours - f.value).abs() <= f.band => FixtureCheck::Matches,
Some(f) => FixtureCheck::Moved { frozen: f.value },
None if verdict == Verdict::Pass => FixtureCheck::Missing,
@@ -392,7 +409,7 @@ pub fn run(root: &Path, options: &Options) -> Result<(), String> {
problems.push(format!("{case}: not blessed, it does not pass against expected.json"));
} else {
let mut values = serde_json::Map::new();
for r in case_rows.iter_mut().filter(|r| r.verdict == Verdict::Pass) {
for r in case_rows.iter_mut().filter(|r| r.verdict == Verdict::Pass && r.quantity.frozen) {
if let (Some(ours), Some(reference)) = (r.ours, r.quantity.reference) {
values.insert(
r.quantity.id.clone(),
@@ -513,6 +530,18 @@ fn ours_text(r: &Row) -> String {
}
}
/// docs/oracle.md's result column. A quantity that is not frozen depends on a generated mesh
/// that differs by platform, so the page names that instead of one platform's number (the run's
/// console prints the value).
fn page_ours(r: &Row) -> String {
if r.quantity.frozen { ours_text(r) } else { "mesh-dependent".into() }
}
/// docs/oracle.md's error column; see [`page_ours`].
fn page_error(r: &Row) -> String {
if r.quantity.frozen { error_of(r) } else { format!("within {}", r.quantity.tolerance.describe()) }
}
/// The error against the reference: relative where the reference is not zero. Errors inside the
/// fixture band are rounding and are shown as a bound.
fn error_of(r: &Row) -> String {
@@ -615,8 +644,8 @@ benchmark, {} experiment; {} pending cases wait for capabilities still being bui
"| [{case}](../tests/oracle/{case}/) | {} | {} | {} | {} | {} | {} | {} |",
cell(&r.quantity.description),
show(r.quantity.reference),
ours_text(r),
error_of(r),
page_ours(r),
page_error(r),
r.quantity.tolerance.describe(),
cell(&result),
page_fixture_text(&r.fixture),
@@ -668,9 +697,21 @@ mod tests {
reason: "r".into(),
known_failure: known.map(str::to_string),
fixture_relative: FIXTURE_RELATIVE,
frozen: true,
}
}
/// A quantity of a generated mesh is gated by its tolerance only: a stored fixture is ignored
/// and blessing skips it.
#[test]
fn unfrozen_quantities_skip_the_fixture() {
let t = Tolerance { relative: 0.03, absolute: 0.0 };
let q = Quantity { frozen: false, ..quantity(213.0, t, None) };
let old = Frozen { value: 213.3, band: 2e-7 };
assert_eq!(judge(&q, &Ok(212.4), Some(&old)), (Verdict::Pass, FixtureCheck::NotKept));
assert_eq!(judge(&q, &Ok(200.0), Some(&old)).0, Verdict::Fail, "the tolerance still gates it");
}
#[test]
fn verdicts_and_fixtures() {
let t = Tolerance { relative: 0.01, absolute: 0.0 };
+33 -23
View File
@@ -61,30 +61,40 @@ fn button_style(t: &Tokens) -> IconButtonStyle {
}
}
/// FieldCraft's mark: the application icon's bracket (`assets/app-icon/icon-small.svg`) drawn in
/// code from the SVG's own coordinates, without its tile, each face in the middle of its gradient.
/// FieldCraft's mark: the application icon's fringe cube (`assets/app-icon/icon-small.svg`, after
/// `xtask/src/app_icon.rs`) drawn in code without its tile or streamline: each visible face split
/// into small cells coloured by the fringe band at the cell's centre, shaded per face.
fn mark(p: &egui::Painter, c: egui::Pos2) {
// The bracket spans y 141–883 in the SVG; draw it 22 points tall around its centre.
let s = 22.0 / 742.0;
let at = |x: f32, y: f32| c + vec2((x - 512.0) * s, (y - 512.0) * s);
let face = |col: Color32, pts: &[(f32, f32)]| {
p.add(egui::Shape::convex_polygon(pts.iter().map(|&(x, y)| at(x, y)).collect(), col, Stroke::NONE));
};
let (top, front, end) = (Color32::from_rgb(0x69, 0xd4, 0xc2), Color32::from_rgb(0x19, 0xa0, 0x8d), Color32::from_rgb(0x0d, 0x66, 0x5d));
face(top, &[(512.0, 141.0), (833.3, 326.5), (730.48, 385.86), (409.19, 200.36)]);
face(front, &[(409.19, 200.36), (730.48, 385.86), (730.48, 638.14), (409.19, 452.64)]);
face(top, &[(409.19, 452.64), (730.48, 638.14), (512.0, 764.28), (190.7, 578.78)]);
face(front, &[(190.7, 578.78), (512.0, 764.28), (512.0, 883.0), (190.7, 697.5)]);
face(end, &[(730.48, 385.86), (833.3, 326.5), (833.3, 697.5), (730.48, 638.14)]);
face(end, &[(730.48, 638.14), (833.3, 697.5), (512.0, 883.0), (512.0, 764.28)]);
// The bolt hole.
let hole: Vec<egui::Pos2> = (0..24)
.map(|k| {
let a = k as f32 / 24.0 * std::f32::consts::TAU;
at(460.6 + 86.0 * a.cos(), 608.5 + 49.5 * a.sin())
})
.collect();
p.add(egui::Shape::convex_polygon(hole, Color32::from_rgb(0x08, 0x46, 0x3f), Stroke::NONE));
// The cube is 2 units tall on screen (half side 0.5, isometric); draw it 22 points tall.
const S: f32 = 11.0;
const N: usize = 8;
const FRINGE: [(u8, u8, u8); 7] =
[(0x2c, 0x4f, 0xbf), (0x2f, 0x86, 0xe0), (0x22, 0xbc, 0xd8), (0x3f, 0xc5, 0x84), (0xf0, 0xd1, 0x3a), (0xf2, 0x8a, 0x2b), (0xde, 0x3b, 0x2f)];
const RADII: [f32; 6] = [1.28, 1.07, 0.87, 0.67, 0.47, 0.27];
let at = |q: [f32; 3]| c + vec2((q[0] - q[1]) * 0.866 * S, ((q[0] + q[1]) * 0.5 - q[2]) * S);
// Each face: origin corner, two edge vectors, and how much it is darkened (0 to 1).
let faces: [([f32; 3], [f32; 3], [f32; 3], f32); 3] = [
([-0.5, -0.5, 0.5], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
([-0.5, 0.5, -0.5], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.12),
([0.5, -0.5, -0.5], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], 0.3),
];
let point = |o: [f32; 3], u: [f32; 3], v: [f32; 3], a: f32, b: f32| [0, 1, 2].map(|k| o[k] + u[k] * a + v[k] * b);
for (o, u, v, shade) in faces {
for i in 0..N {
for j in 0..N {
let (a0, a1, b0, b1) = (i as f32 / N as f32, (i + 1) as f32 / N as f32, j as f32 / N as f32, (j + 1) as f32 / N as f32);
let m = point(o, u, v, (a0 + a1) / 2.0, (b0 + b1) / 2.0);
let r = m.iter().map(|x| (0.5 - x) * (0.5 - x)).sum::<f32>().sqrt();
let band = RADII.iter().filter(|&&rad| r < rad).count();
let (cr, cg, cb) = FRINGE.get(band).copied().unwrap_or(FRINGE[0]);
let k = 1.0 - shade;
let col = Color32::from_rgb((cr as f32 * k) as u8, (cg as f32 * k) as u8, (cb as f32 * k) as u8);
let quad = vec![at(point(o, u, v, a0, b0)), at(point(o, u, v, a1, b0)), at(point(o, u, v, a1, b1)), at(point(o, u, v, a0, b1))];
// A hairline in the cell's own colour closes the seams between neighbouring cells.
p.add(egui::Shape::convex_polygon(quad, col, Stroke::new(0.5, col)));
}
}
}
}
/// Where the title bar's parts go.
+1 -1
View File
@@ -118,7 +118,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
if cancel {
app.dialog = None;
} else if run {
let _ = app.run_dialog();
app.submit_dialog();
} else if use_selection
&& let Err(e) = app.use_selection_in_dialog()
&& let Some(d) = app.dialog.as_mut()
+41
View File
@@ -16,6 +16,9 @@ use crate::{ModelApp, UiState};
/// `ui.render`'s image size when none is given.
const RENDER_DEFAULT: [usize; 2] = [1280, 800];
/// Most files one `ui.drop` drops, and the longest path it takes.
const MAX_DROP: usize = 16;
const MAX_DROP_PATH: usize = 4096;
pub struct ControlRequest {
pub method: String,
@@ -379,6 +382,20 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ok(Value::Null)
}
"ui.drop" => {
// Files dropped on the window: the next frame takes them with the window system's
// drops, and they open as a picked file opens (the native picker cannot be driven).
let paths: Option<Vec<String>> =
p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect());
match paths {
Some(paths) if !paths.is_empty() && paths.len() <= MAX_DROP && paths.iter().all(|p| !p.is_empty() && p.len() <= MAX_DROP_PATH) => {
let n = paths.len();
app.synthetic_drops.extend(paths);
ok(json!({"dropped": n}))
}
_ => err(format!("`paths` must be a list of 1 to {MAX_DROP} file paths")),
}
}
"ui.worldToScreen" => {
let pts: Vec<Value> = p
.get("points")
@@ -658,6 +675,29 @@ mod tests {
assert!(app.dialog.is_none() && app.session.model.node(3).is_some());
}
/// `ui.drop` stands in for the window system: the next frame opens the files as a drop does.
#[test]
fn dropped_files_open_as_a_drop_does() {
let dir = std::env::temp_dir().join(format!("fieldcraft-drop-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("plate.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut app = ModelApp::new(Session::default());
let r = call(&mut app, "ui.drop", json!({"paths": [design.to_string_lossy()]}));
assert_eq!(r["result"]["dropped"], 1, "{r}");
assert!(app.session.design.is_none(), "nothing opens before the next frame");
app.logic(&egui::Context::default());
assert_eq!(app.busy(), Some("Opening plate.solidcraft"));
app.wait_loaded();
assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1));
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Opened plate.solidcraft"));
for bad in [json!({}), json!({"paths": []}), json!({"paths": [""]}), json!({"paths": [3]}), json!({"paths": vec!["a.solidcraft"; 17]})] {
assert_eq!(call(&mut app, "ui.drop", bad.clone())["ok"], false, "{bad}");
}
assert!(app.synthetic_drops.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ui_render_writes_a_png_of_the_model() {
let mut app = ModelApp::new(Session::default());
@@ -729,6 +769,7 @@ mod tests {
"ui.select",
"ui.selection",
"ui.fullscreen",
"ui.drop",
] {
for p in &hostile {
let _ = call(&mut app, method, p.clone());
+125 -22
View File
@@ -106,6 +106,10 @@ fn kind_of_command(command: &str) -> Option<Kind> {
[Kind::Model, Kind::Design, Kind::Mesh].into_iter().find(|k| k.command() == command)
}
/// Commands a form runs in the background: they can take a while on a large part.
pub const BACKGROUND: &[&str] =
&["mesh.tet.generate", "mesh.hex.generate", "mesh.hex.sweep", "mesh.hex.mapped", "mesh.hex.blocks", "mesh.hex.octree"];
/// Most entries the recent-files list keeps, and the longest path it keeps.
pub const MAX_RECENT: usize = 10;
const MAX_RECENT_PATH: usize = 4096;
@@ -160,7 +164,8 @@ impl Recent {
}
}
/// A command running in the background on a copy of the session: a file opening, a solve.
/// A command running in the background on a copy of the session: a file opening, a solve, a
/// mesher started from its form.
pub struct Loading {
/// What the window shows while it runs ("Opening beam.fieldcraft").
pub title: String,
@@ -170,8 +175,15 @@ pub struct Loading {
recent: Option<String>,
/// Whether it brings a different model or new geometry, which the view then frames.
fit: bool,
/// The command form that started it: closed when it succeeds, showing the error when not.
form: Option<&'static str>,
/// The finished session copy and the command's reply.
rx: Receiver<(Box<Session>, Result<Value, String>)>,
/// The work, while it waits to run on the window thread (the web, which has no threads).
pending: Option<Work>,
/// Whether the window has drawn the job's progress: on the web the work runs (and the page
/// stops drawing) only once the user has seen that it runs.
shown: bool,
}
/// What waits for an answer about unsaved changes.
@@ -224,27 +236,45 @@ fn with_extension(path: String, ext: &str) -> String {
/// Run `work` off the window thread (inline on the web, which has no threads).
fn off_thread(name: String, work: impl FnOnce() + Send + 'static) {
#[cfg(not(target_arch = "wasm32"))]
if let Err(e) = std::thread::Builder::new().name(name.clone()).spawn(work) {
// The work went with the closure: a job then reports that it could not start.
log::warn!("{name}: no worker thread: {e}");
}
#[cfg(target_arch = "wasm32")]
{
let _ = name;
if let Some(work) = start(name, Box::new(work)) {
work();
}
}
/// Run `command` on `session` off the window thread.
fn spawn(session: Session, command: &'static str, params: Value) -> Receiver<(Box<Session>, Result<Value, String>)> {
/// A background job's work.
type Work = Box<dyn FnOnce() + Send>;
/// Start `work` on a worker thread. The web has no threads: the work comes back, to run on the
/// window thread once the window has shown that it runs ([`Loading::shown`]).
fn start(name: String, work: Work) -> Option<Work> {
#[cfg(not(target_arch = "wasm32"))]
{
if let Err(e) = std::thread::Builder::new().name(name.clone()).spawn(work) {
// The work went with the closure: a job then reports that it could not start.
log::warn!("{name}: no worker thread: {e}");
}
None
}
#[cfg(target_arch = "wasm32")]
{
let _ = name;
Some(work)
}
}
/// Run `command` on `session` off the window thread: the reply's receiver, and the work when it
/// waits to run on the window thread (the web).
fn spawn(session: Session, command: &'static str, params: Value) -> (Receiver<(Box<Session>, Result<Value, String>)>, Option<Work>) {
let (tx, rx) = std::sync::mpsc::channel();
off_thread(format!("fieldcraft {command}"), move || {
let mut s = session;
let r = s.execute(command, &params).map_err(|e| e.to_string());
let _ = tx.send((Box::new(s), r));
});
rx
let work = start(
format!("fieldcraft {command}"),
Box::new(move || {
let mut s = session;
let r = s.execute(command, &params).map_err(|e| e.to_string());
let _ = tx.send((Box::new(s), r));
}),
);
(rx, work)
}
impl ModelApp {
@@ -274,13 +304,31 @@ impl ModelApp {
return false;
}
let base = if fresh { Session::default() } else { self.session.clone() };
let rx = spawn(base, command, params);
let (rx, pending) = spawn(base, command, params);
self.set_status(format!("{title}…"), false);
let fit = fresh || recent.is_some();
self.loading = Some(Loading { title, done, recent, fit, rx });
self.loading = Some(Loading { title, done, recent, fit, form: None, rx, pending, shown: false });
true
}
/// The form's OK: run its command, in the background for the meshers ([`BACKGROUND`]) so the
/// window keeps drawing (with Cancel) while a large part meshes; the form closes when the
/// command succeeds and shows the error when it fails.
pub fn submit_dialog(&mut self) {
let Some(command) = self.dialog.as_ref().map(|d| d.command) else { return };
if !BACKGROUND.contains(&command) {
let _ = self.run_dialog();
return;
}
let Ok((command, params)) = self.dialog_params() else { return };
let label = crate::command_label(command).to_string();
if self.begin_job(command, params, false, label.clone(), format!("{label} done"), None)
&& let Some(l) = self.loading.as_mut()
{
l.form = Some(command);
}
}
/// File > Open: pick a model, design or tagged mesh and open it. `None` asks for any of them.
/// Without a picker service the command's form opens instead.
pub fn open_dialog(&mut self, kind: Option<Kind>) {
@@ -381,7 +429,13 @@ impl ModelApp {
/// Take in a finished background job (called every frame); `true` while one is running.
pub fn poll_loading(&mut self) -> bool {
let Some(l) = &self.loading else { return false };
let Some(l) = &mut self.loading else { return false };
if l.pending.is_some() && !l.shown {
return true;
}
if let Some(work) = l.pending.take() {
work();
}
let outcome = match l.rx.try_recv() {
Ok(done) => Some(done),
Err(TryRecvError::Empty) => return true,
@@ -400,6 +454,9 @@ impl ModelApp {
if let Some(p) = &l.recent {
self.recent.add(p);
}
if self.dialog.as_ref().is_some_and(|d| l.form == Some(d.command)) {
self.dialog = None;
}
let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
if warnings.is_empty() {
self.set_status(l.done, false);
@@ -410,6 +467,9 @@ impl ModelApp {
Some((_, Err(e))) => {
let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name);
self.set_status(format!("{what}: {e}"), true);
if let Some(d) = self.dialog.as_mut().filter(|d| l.form == Some(d.command)) {
d.error = Some(e);
}
}
None => self.set_status(format!("{}: stopped unexpectedly", l.title), true),
}
@@ -540,10 +600,12 @@ impl ModelApp {
true
}
/// Files dropped on the window open in turn; a hovered drop shows where it goes.
/// Files dropped on the window (and by the control channel's `ui.drop`) open in turn; a
/// hovered drop shows where it goes.
pub(crate) fn dropped_files(&mut self, ctx: &egui::Context) {
let dropped: Vec<String> = ctx
let mut dropped: Vec<String> = ctx
.input(|i| i.raw.dropped_files.iter().filter_map(|f| Some(f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty())).collect());
dropped.append(&mut self.synthetic_drops);
// A model first: it replaces the session, then designs and meshes go into it.
let (models, rest): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model));
for p in models.into_iter().take(1).chain(rest) {
@@ -583,6 +645,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
});
},
);
if let Some(l) = app.loading.as_mut() {
l.shown = true;
}
if cancel {
app.cancel_loading();
}
@@ -948,6 +1013,44 @@ mod tests {
assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error));
}
/// A first-time user's path: Open picks a SolidCraft design, the Tet Mesh button opens its
/// form, OK with the defaults meshes it in the background, and the mesh is there. A mesher
/// that does not fit says why in the form, which stays open.
#[test]
fn a_picked_design_meshes_from_the_ribbon_with_the_defaults() {
let dir = temp("path");
let design = dir.join("plate.solidcraft").to_string_lossy().to_string();
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let offered = Rc::new(RefCell::new(Vec::new()));
let mut app = ModelApp::new(Session::default());
app.services.pick_open = Some(answering(Some(design.clone()), offered));
app.open_dialog(None);
app.wait_loaded();
assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1), "{:?}", app.status);
assert!(!app.display().bounds.is_empty());
app.start("mesh.tet.generate").unwrap();
assert_eq!(app.dialog.as_ref().map(|d| (d.command, d.text.as_str())), Some(("mesh.tet.generate", "{}")));
app.submit_dialog();
assert_eq!(app.busy(), Some("Generate Tet Mesh"), "meshing runs off the window thread");
app.wait_loaded();
assert!(app.dialog.is_none(), "the form closes when it succeeded: {:?}", app.status);
assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Generate Tet Mesh done", false)));
let counts = app.session.model.element_counts();
assert!(matches!(counts.as_slice(), [(fieldcraft_model::ElementKind::Tet4, n)] if *n > 0), "{counts:?}");
assert_eq!(app.session.journal.len(), 2, "open and mesh, each an undo step");
// A plate with a hole is not one mapped block: the form stays with the reason.
app.start("mesh.hex.mapped").unwrap();
app.submit_dialog();
app.wait_loaded();
let error = app.dialog.as_ref().and_then(|d| d.error.clone()).unwrap_or_default();
assert!(error.contains("not a logical cube"), "{error}");
assert!(app.status.as_ref().is_some_and(|s| s.error));
assert_eq!(app.session.model.element_counts(), counts, "a failed mesher changes nothing");
let _ = std::fs::remove_dir_all(&dir);
}
/// The control channel's `ui.start` never opens a picker: it opens the form.
#[test]
fn programmatic_starts_never_open_a_picker() {
+37 -2
View File
@@ -289,6 +289,9 @@ pub struct ModelApp {
/// Input injected by the control channel, fed to egui one pointer event per frame.
pub synthetic: Vec<egui::Event>,
synthetic_mods: egui::Modifiers,
/// Files dropped on the window by the control channel (`ui.drop`): the next frame takes them
/// with the window system's drops.
pub synthetic_drops: Vec<String>,
control_rx: Option<Receiver<ControlRequest>>,
shots: Vec<Shot>,
shot_token: u64,
@@ -363,6 +366,7 @@ impl ModelApp {
styled: None,
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
synthetic_drops: Vec::new(),
control_rx: None,
shots: Vec::new(),
shot_token: 0,
@@ -434,6 +438,8 @@ impl ModelApp {
}
match &r {
Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false),
// A reply with a `summary` line shows it instead of its JSON.
Ok(v) if let Some(summary) = v.get("summary").and_then(Value::as_str) => self.set_status(format!("{label}: {summary}"), false),
Ok(v) => self.set_status(format!("{label}: {v}"), false),
Err(e) => self.set_status(format!("{label}: {e}"), true),
}
@@ -519,8 +525,8 @@ impl ModelApp {
/// Run the open parameter form; it closes when the command succeeds.
pub fn run_dialog(&mut self) -> Result<Value, String> {
let Some(d) = self.dialog.clone() else { return Err("no command form is open".into()) };
let r = serde_json::from_str::<Value>(&d.text).map_err(|e| format!("the parameters are not JSON: {e}")).and_then(|p| self.run(d.command, p));
let (command, params) = self.dialog_params()?;
let r = self.run(command, params);
match &r {
Ok(_) => self.dialog = None,
Err(e) => {
@@ -532,6 +538,22 @@ impl ModelApp {
r
}
/// The open form's command and parameters; parameters that are not JSON are the form's error.
pub(crate) fn dialog_params(&mut self) -> Result<(&'static str, Value), String> {
let Some(d) = self.dialog.as_mut() else { return Err("no command form is open".into()) };
match serde_json::from_str::<Value>(&d.text) {
Ok(params) => {
d.error = None;
Ok((d.command, params))
}
Err(e) => {
let e = format!("the parameters are not JSON: {e}");
d.error = Some(e.clone());
Err(e)
}
}
}
/// Probe a node of the results shown (`solve.probe`) and keep its values for the viewport.
pub fn probe_node(&mut self, node: u32) -> Result<(), String> {
let Some(view) = self.session.results_view.clone() else { return Err("no results are shown".into()) };
@@ -1207,6 +1229,19 @@ mod tests {
assert_eq!(app.ui.tab, Tab::Properties);
}
/// Mesh Quality reads as a sentence in the status bar, not as its JSON report.
#[test]
fn mesh_quality_reports_in_a_line() {
let mut app = ModelApp::new(Session::default());
app.run("mesh.quality", json!({})).unwrap();
assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Mesh Quality: no elements to measure"));
app.run_script(&fieldcraft_engine::sample::script()).unwrap();
app.run("mesh.quality", json!({})).unwrap();
let status = app.status.clone().unwrap();
assert!(status.text.starts_with("Mesh Quality: 226 elements, all valid; scaled Jacobian min "), "{}", status.text);
assert!(!status.error && !status.text.contains('{'));
}
#[test]
fn starting_a_command_opens_its_form_and_running_it_closes_the_form() {
let mut app = ModelApp::new(Session::default());
+2 -2
View File
@@ -30,11 +30,11 @@ crates in strictly lower layers.
| L2 | `fieldcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face. Uses `mesh` for the predicates and the boundary surface. | started |
| L3 | `fieldcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned |
| L4 | `fieldcraft-doc` | The document: geometry, mesh, model, results, history. | planned |
| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots`. Result files later. | started |
| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots` (in the browser, files kept in memory: `memory`). Result files later. | started |
| L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started |
| L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started |
| testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |
| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser. | desktop, cli started |
| apps | `fieldcraft`, `fieldcraft-cli`, `fieldcraft-web` | Desktop, command line, browser (docs/web.md). | started |
Rules beyond the layer order:
- Nothing below L7 uses egui, eframe, winit, wgpu or rfd.
+2 -1
View File
@@ -41,7 +41,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
| Method | Params | Notes |
|---|---|---|
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera |
| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) |
@@ -58,6 +58,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
| `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point |
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model; `Escape` clears the selection (and then cancels an open form) |
| `ui.text` | `text` | typed text (into the focused field) |
| `ui.drop` | `paths`: 1–16 file paths | drops the files on the window: the next frame opens them as a drop from the file manager does, and as a file picked in the Open dialog does (a `.fieldcraft` model, a `.solidcraft` design, a `.3mf` tagged mesh; `ui.inspect`'s `busy` while they open). The native picker cannot be driven over the channel; this is the same route |
| `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) |
| `ui.resize` | `width`, `height` | window inner size in points |
| `ui.fullscreen` | `on?` (default true) | into or out of full screen; on macOS the title bar's left group moves into the traffic lights' room while there are none |
+1 -1
View File
@@ -12,7 +12,7 @@ unzip tool reads them: `unzip -p bracket.fieldcraft model.json`):
| Part | Content |
|---|---|
| `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.1.0"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. |
| `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.5.5"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. |
| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads and steps. Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. |
| `mesh.bin` | Nodes, elements and geometry tags, binary (below). |
| `journal.json` | The commands that built the model, `[{"command": id, "params": {...}}, ...]`, one per line. It is a script `fieldcraft-cli run` replays. |
+2 -2
View File
@@ -28,9 +28,9 @@ Generated by `cargo xtask oracle` from [`tests/oracle/`](../tests/oracle/); the
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | volume of the tetrahedra | 47000 | 47000 | < 1e-7% | 1e-12 rel. | pass | matches |
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements that fail the certified Jacobian check | 0 | 0 | ≈ 0 | exact | pass | matches |
| [cdt-stepped-block](../tests/oracle/cdt-stepped-block/) | elements the Jacobian check could not decide | 0 | 0 | ≈ 0 | exact | pass | matches |
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | 213.31 | 0.144% | 3% | pass | matches |
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 3% | 3% | pass | — |
| [filleted-bracket-fillet-stress](../tests/oracle/filleted-bracket-fillet-stress/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | 213.31 | 0.144% | 3% | pass | matches |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | largest nodal von Mises stress on the fillet face (MPa) | 213 | mesh-dependent | within 3% | 3% | pass | — |
| [filleted-bracket-fillet-stress-coarse](../tests/oracle/filleted-bracket-fillet-stress-coarse/) | total reaction x (N) | -2000 | -2000 | < 1e-7% | 1e-9 rel. | pass | matches |
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | sixth frequency (the last rigid-body mode) | 0 | ≈ 0 | ≈ 0 | ± 8.1264e-5 | pass | matches |
| [free-beam-frequencies](../tests/oracle/free-beam-frequencies/) | first free–free bending frequency (mode 7) | 0.081264 | 0.080618 | -0.795% | 1.5% | pass | matches |
+11 -12
View File
@@ -1,9 +1,9 @@
# Releasing FieldCraft
Releases are deliberate. Development lands on `main`; pushing the chosen commit to the `release`
branch builds every package and creates a **draft** GitHub Release; a maintainer verifies the
draft and publishes it. Pushes to `main` and tags never start a release, and nothing bumps the
version automatically. The workflow is `.github/workflows/release.yml`; the packaging scripts
Releases are deliberate. Development lands on `develop`, the trunk; `main` holds released code
only. Fast-forwarding `main` to the chosen, verified `develop` commit builds every package and
creates a **draft** GitHub Release; a maintainer verifies the draft and publishes it. Pushes to
`develop` and tags never start a release, and nothing bumps the version automatically. The workflow is `.github/workflows/release.yml`; the packaging scripts
are in `packaging/` and run the same way on a developer machine.
## What a release contains
@@ -57,22 +57,21 @@ certificate is added.
notes); `-rc.N` for a release candidate. `1.0` is the owner's decision.
2. Set `[workspace.package] version` in the root `Cargo.toml` and run
`cargo update --workspace --offline`, which changes only the workspace crates' versions in
`Cargo.lock`. Land it on `main` through a pull request like any change, and let CI on `main`
pass. Note the commit to release.
`Cargo.lock`. Land it on `develop` through a pull request like any change, and let CI on
`develop` pass. Note the commit to release.
## Build the draft
```sh
git fetch origin
git switch release # the first time: git switch -c release origin/main
git merge --ff-only origin/main
git push origin release
git push origin <commit>:main # fast-forward main to the verified develop commit
```
Only fast-forward `release` and never force-push it. The run builds the three platforms; when
`main` holds released code only: it moves only by fast-forwarding to a verified `develop`
commit (a maintainer push; the branch is protected), never by merges, rewrites or force-pushes. The run builds the three platforms; when
all succeed, the last job checks that every file is there, writes `SHA256SUMS.txt` and the notes,
and creates the draft `FieldCraft v<version>` targeting the pushed commit. Pushing
`release` again with the same version rebuilds and replaces the draft's files (its notes are
`main` again with the same version rebuilds and replaces the draft's files (its notes are
kept). The job refuses a version whose tag exists or whose release is already published: bump
the version instead.
@@ -98,6 +97,6 @@ Publishing is a maintainer's call; an agent preparing a release stops at the dra
`gh workflow run release.yml -R jyviko/fieldcraft --ref <branch>` builds every package and
uploads them with the notes as the `release-assets` artifact of the run, without creating a
release. `-f draft=true` creates or updates the draft, and is accepted on `release` only.
release. `-f draft=true` creates or updates the draft, and is accepted on `main` only.
Locally, `packaging/macos/package.sh --arch aarch64` (or `x86_64`, `universal`) and
`packaging/linux/package.sh` write to `dist/release/`.
+3 -1
View File
@@ -65,7 +65,9 @@ cargo xtask oracle --quick # skip cases marked "slow"
- `fixture.json` holds each passing quantity's accepted `value` and its `band`
(10⁻⁹ of the larger of value and reference, plus 10⁻³ of an absolute tolerance; a quantity whose
reference is zero, which is rounding noise, keeps its absolute tolerance as its band; an
ill-conditioned quantity may set a wider `fixture_relative` with a `fixture_reason`): the result is
ill-conditioned quantity may set a wider `fixture_relative` with a `fixture_reason`; a quantity
that depends on a mesh generated from geometry, which is not bit-identical across platforms, sets
`"fixture": false` with a `fixture_reason` and is gated by its reference tolerance alone): the result is
deterministic, the band only absorbs last-digit differences of the system maths library.
The run fails when a quantity misses its reference (unless it is a `known_failure`, which is
+77
View File
@@ -0,0 +1,77 @@
# FieldCraft in the browser
`apps/fieldcraft-web` runs the desktop app's interface (`fieldcraft_ui_egui::ModelApp`) in a web
page through eframe's web runner, drawing with WebGPU where the browser has it and WebGL2
otherwise. The engine, the meshers and the solvers are the same code, compiled to
`wasm32-unknown-unknown`.
Live: <https://jyviko.github.io/fieldcraft/>, deployed from each release (a push to `main`,
`.github/workflows/pages.yml`).
## Run it locally
```sh
rustup target add wasm32-unknown-unknown
cargo install --locked trunk # or a release binary from github.com/trunk-rs/trunk
cd apps/fieldcraft-web
trunk serve # http://127.0.0.1:8790, rebuilt on every change
trunk serve --release # as deployed (optimised, wasm-opt)
trunk build --release # the site in dist/web, as Pages gets it
```
Trunk fetches the matching `wasm-bindgen` and `wasm-opt` itself.
Address flags: `?sample` (or `?sample=cantilever`, `?sample=impact`) opens a sample; `?webgl`
draws with WebGL2 even where WebGPU is available.
CI keeps the crate compiling for the browser: `cargo xtask wasm` (part of `cargo xtask ci`)
checks `fieldcraft-web` with the library crates, and `pages.yml` builds the whole site with
Trunk on every merge to `develop` that touches code, without deploying it.
## What works
Everything that runs on a model: opening samples, opening SolidCraft designs and 3MF tagged
meshes, every mesher, materials, sections, steps, loads and boundary conditions, the implicit,
explicit and lattice Boltzmann solvers, the results display (contours, deformed shape, probe,
reaction sums), undo and redo, the command line and command search, and writing Abaqus, Nastran
and LS-DYNA decks. The solvers run on one thread in the browser; `fieldcraft-parallel` gives the
same numbers for any thread count (docs/adr/0001), so results are the desktop's.
The browser has no file system the app can reach, so files live in the page
(`fieldcraft_io::fs` keeps them in memory) and the host services move them in and out:
| Desktop | Browser |
|---|---|
| File › Open, a native picker | The browser's file picker; pick a model together with the design and meshes it refers to |
| Drag and drop | Drop files on the page; drop a model together with its geometry files |
| Save, Save As | The `.fieldcraft` file is downloaded (each save downloads it again) |
| Write Deck | The deck is downloaded |
| Autosave to the recovery folder, File › Recover… | Autosaves are kept in the browser's local storage; a page closed or crashed with unsaved changes offers them at the next visit (File › Recover…) |
| Leaving with unsaved changes asks first | The browser's "leave site?" prompt |
| Recent files | This visit's files only |
A `.fieldcraft` model refers to its design and meshes by path. Paths in the model's own folder
are stored relative to it, so picking or dropping the model with those files opens them again; a
model whose geometry is elsewhere opens without it and says so in the status bar.
## What doesn't
- **Long jobs block the page while they run.** The browser build has no worker threads: a mesher
or a solve started from its form shows its progress first, then runs on the page's thread, and
the page stops responding until it finishes (seconds for the samples, longer for large parts).
Cancel does nothing once it runs. Worker threads need `SharedArrayBuffer`, which needs
cross-origin isolation headers that GitHub Pages cannot send.
- **No control channel or MCP bridge**: a page cannot listen on a socket. Agents use the desktop
app or `fieldcraft-cli mcp`.
- **Autosave space is small**: local storage holds a few megabytes per site, so the autosave of a
large mesh may not fit; the status bar then says so, and saving (a download) keeps the work.
- **Two tabs share one store**: a tab offers the autosave of another FieldCraft tab that is still
open as recoverable (recovering it opens a copy).
- **A lost GPU is not recovered**: if the browser takes the graphics device away, reload the page
(unsaved work is in the autosave).
- **Download size**: about 26 MB of WebAssembly (10 MB compressed), loaded once per release.
## Requirements
A current Chrome, Edge, Firefox or Safari with WebGPU or WebGL2 and graphics acceleration turned
on. Without either the page says so instead of starting.
+3
View File
@@ -0,0 +1,3 @@
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
source-path=SCRIPTDIR
external-sources=true
@@ -19,7 +19,9 @@
"tolerance": {
"relative": 0.03
},
"reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%"
"reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%",
"fixture": false,
"fixture_reason": "the peak depends on the tet mesh generated from SolidCraft's tessellation, which is not bit-identical across platforms (212.4 MPa on Linux CI against 213.3 on macOS for the same recipe; issue #115): the 3% reference tolerance gates it until meshing is reproducible across platforms"
},
{
"id": "reaction",
@@ -1,12 +1,8 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"fillet_peak": {
"band": 2.1330697455903942e-7,
"value": 213.30697455903942
},
"reaction": {
"band": 2.000000000000858e-6,
"band": 2.000000000000858e-06,
"value": -2000.0000000008579
}
}
@@ -19,7 +19,9 @@
"tolerance": {
"relative": 0.03
},
"reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%"
"reason": "the four-level study gives 213.3, 210.6, 214.3, 213.3 MPa (2k to 13.5k elements): the bending stress is converged across the fillet, and the peak nodal value moves only with which of the fillet's tessellation lines (fixed angles, through every level) carries the largest node, by under 2%",
"fixture": false,
"fixture_reason": "the peak depends on the tet mesh generated from SolidCraft's tessellation, which is not bit-identical across platforms (212.4 MPa on Linux CI against 213.3 on macOS for the same recipe; issue #115): the 3% reference tolerance gates it until meshing is reproducible across platforms"
},
{
"id": "reaction",
@@ -1,12 +1,8 @@
{
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
"values": {
"fillet_peak": {
"band": 2.1330693756948646e-7,
"value": 213.30693756948645
},
"reaction": {
"band": 2.0000000000001007e-6,
"band": 2.0000000000001007e-06,
"value": -2000.0000000001007
}
}
+165 -222
View File
@@ -1,25 +1,25 @@
//! `cargo xtask app-icon`: draw FieldCraft's application icon family with VectorCraft.
//!
//! The mark is an L-shaped bracket with a bolt hole, drawn in isometric view and covered by a
//! structured hexahedral mesh (an O-grid around the hole). The faces are separated by
//! transparent gaps instead of painted edge lines, so the backdrop shows through them in every
//! appearance. All geometry is computed here, turned into VectorCraft commands and run headlessly
//! with `vectorcraft-cli run` (`VECTORCRAFT_CLI`, else `vectorcraft-cli` on the PATH), which
//! saves each document's editable `.vectorcraft` source and exports its SVG (and PNG).
//! The mark is a hexahedral-meshed cube in isometric view whose faces carry a banded colour
//! fringe (blue to red, hottest at the cube's front corner) as a stress or temperature result
//! would. The faces are separated by transparent gaps instead of painted edge lines, so the
//! backdrop shows through them in every appearance. All geometry is computed here, turned into VectorCraft commands and
//! run headlessly with `vectorcraft-cli run` (`VECTORCRAFT_CLI`, else `vectorcraft-cli` on the
//! PATH), which saves each document's editable `.vectorcraft` source and exports its SVG (and PNG).
//!
//! Outputs, all under `assets/app-icon/` (see its README.md):
//! - `icon.vectorcraft`, `icon.svg`, `icon.png`: the full icon on its backdrop, 1024 × 1024.
//! - `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png`: simplified artwork (no mesh,
//! no floor grid, faces meeting edge to edge so the tones alone separate them) for 16–32 px.
//! - `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png`: simplified artwork for 16–32 px
//! (no mesh or floor grid, faces edge to edge).
//! - `icon.iconset/` (sips), `icon.icns` (iconutil) and `icon.ico` (ImageMagick).
//! - `FieldCraft.icon/`: an Icon Composer document with separate floor-grid (Default and Dark),
//! part and mesh layers; their editable sources are `layers/*.vectorcraft`.
//! and cube layers (the mesh shares the cube's); their editable sources are `layers/*.vectorcraft`.
//! - `Assets.car`: Apple's actool compilation of `FieldCraft.icon`, when Xcode 26 or later is
//! installed (otherwise the step is skipped with a note and the checked-in catalog stays).
//!
//! macOS only (sips, iconutil); ImageMagick (`magick`) writes the Windows icon.
use std::f64::consts::{FRAC_PI_2, TAU};
use std::f64::consts::FRAC_PI_2;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -30,33 +30,27 @@ type P3 = [f64; 3];
/// Canvas side in pixels; every document is one square artboard.
const CANVAS: f64 = 1024.0;
/// Pixels per model unit, and the screen point the model origin's bounding box centres on.
const SCALE: f64 = 371.0;
const CENTRE: P2 = [512.0, 512.0];
/// Pixels per model unit, and the screen point the model origin projects to.
const SCALE: f64 = 300.0;
const CENTRE: P2 = [512.0, 500.0];
const COS30: f64 = 0.866_025_403_784_438_6;
/// The bracket, in model units (x to the right-front, y to the left-front, z up): a base plate
/// `L × D × T` with an upright wall `L × T × H` along its back edge.
const L: f64 = 1.0;
const D: f64 = 1.0;
const H: f64 = 1.0;
const T: f64 = 0.32;
/// The bolt hole through the base plate, and the half-side of the O-grid block around it (the
/// block spans the plate's depth exactly).
const HOLE: P2 = [0.5, 0.66];
const HOLE_R: f64 = 0.19;
const BLOCK: f64 = 0.34;
/// The cube's half side, in model units (x to the right-front, y to the left-front, z up); the
/// cube is centred on the origin.
const A: f64 = 0.5;
/// Mesh cells along each edge.
const CELLS: usize = 4;
/// The fringe: a field that grows towards the hot point (the cube's front corner), shown as
/// bands of constant colour. `BAND_RADII[k]` is the distance from the hot point inside which
/// band `k + 1` of `FRINGE` starts; the coldest band fills the rest of the face.
const HOT: P3 = [A, A, A];
const FRINGE: [&str; 7] = ["#2c4fbf", "#2f86e0", "#22bcd8", "#3fc584", "#f0d13a", "#f28a2b", "#de3b2f"];
const BAND_RADII: [f64; 6] = [1.28, 1.07, 0.87, 0.67, 0.47, 0.27];
/// The macOS icon grid: the backdrop's square, inset 100 px on a 1024 canvas.
const BACKDROP: [f64; 2] = [100.0, 924.0];
/// Part palette: lit top faces, the left (+y) faces and the right (+x) faces, each a vertical
/// gradient, and the bolt hole's inner wall.
const TOP: [&str; 2] = ["#86e3d3", "#4cc4b1"];
const LEFT: [&str; 2] = ["#20b39f", "#118c7c"];
const RIGHT: [&str; 2] = ["#0f786d", "#0a544e"];
const BORE: [&str; 2] = ["#0b665e", "#04302c"];
#[derive(Clone, Copy, PartialEq)]
enum Appearance {
Light,
@@ -74,7 +68,7 @@ impl Detail {
/// Width of the transparent gap between neighbouring faces, in pixels on the 1024 canvas.
fn gap(self) -> f64 {
match self {
Detail::Full => 16.0,
Detail::Full => 14.0,
Detail::Small => 0.0,
}
}
@@ -191,32 +185,27 @@ fn inset(poly: &[P2], flagged: &[bool], by: f64) -> Result<Vec<P2>, String> {
Ok(out)
}
/// A circle of radius `r` around `c` on the horizontal plane at height `z`, projected: four
/// cubic arcs (projection is affine, so the projected Béziers are exact). `ccw` picks the
/// screen winding (positive shoelace area when true).
fn circle_d(c: P2, r: f64, z: f64, ccw: bool) -> String {
let arcs = |sign: f64| -> Vec<[P2; 3]> {
let k = 0.552_284_749_830_793_4 * r;
let at = |t: f64| [c[0] + r * t.cos(), c[1] + sign * r * t.sin()];
let tangent = |t: f64| [-t.sin(), sign * t.cos()];
(0..4)
.map(|i| {
let (t0, t1) = (i as f64 * FRAC_PI_2, (i + 1) as f64 * FRAC_PI_2);
let (p0, p1, d0, d1) = (at(t0), at(t1), tangent(t0), tangent(t1));
let c1 = [p0[0] + k * d0[0], p0[1] + k * d0[1]];
let c2 = [p1[0] - k * d1[0], p1[1] - k * d1[1]];
[project([c1[0], c1[1], z]), project([c2[0], c2[1], z]), project([p1[0], p1[1], z])]
})
.collect()
};
let mut segs = arcs(1.0);
let anchors: Vec<P2> = segs.iter().map(|s| s[2]).collect();
if (signed_area(&anchors) > 0.0) != ccw {
segs = arcs(-1.0);
}
let start = segs[3][2];
fn add3(a: P3, b: P3, k: f64) -> P3 {
[a[0] + b[0] * k, a[1] + b[1] * k, a[2] + b[2] * k]
}
fn dot3(a: P3, b: P3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// A circle of radius `r` around `c` in the plane spanned by the unit vectors `u` and `v`,
/// projected: four cubic arcs (projection is affine, so the projected Béziers are exact).
fn circle_d(c: P3, u: P3, v: P3, r: f64) -> String {
let k = 0.552_284_749_830_793_4 * r;
let at = |t: f64| add3(add3(c, u, r * t.cos()), v, r * t.sin());
let dir = |t: f64| add3(add3([0.0; 3], u, -t.sin()), v, t.cos());
let start = project(at(0.0));
let mut d = format!("M{} {}", num(start[0]), num(start[1]));
for [c1, c2, p] in &segs {
for i in 0..4 {
let (t0, t1) = (i as f64 * FRAC_PI_2, (i + 1) as f64 * FRAC_PI_2);
let c1 = project(add3(at(t0), dir(t0), k));
let c2 = project(add3(at(t1), dir(t1), -k));
let p = project(at(t1));
d.push_str(&format!(" C{} {} {} {} {} {}", num(c1[0]), num(c1[1]), num(c2[0]), num(c2[1]), num(p[0]), num(p[1])));
}
d.push_str(" Z");
@@ -233,42 +222,40 @@ enum Tone {
struct Face {
name: &'static str,
/// Corners in model space and, per edge (corner i to i + 1), whether another face of the
/// part meets it there (those edges get the gap).
corners: Vec<P3>,
shared: Vec<bool>,
/// cube meets it there (those edges get the gap).
corners: [P3; 4],
shared: [bool; 4],
/// The face's outward normal and two in-plane unit axes.
normal: P3,
axes: [P3; 2],
tone: Tone,
}
fn faces() -> Vec<Face> {
vec![
fn faces() -> [Face; 3] {
let shared = [false, true, true, false];
[
Face {
name: "wall top",
corners: vec![[0.0, 0.0, H], [L, 0.0, H], [L, T, H], [0.0, T, H]],
shared: vec![false, true, true, false],
name: "top",
corners: [[-A, -A, A], [A, -A, A], [A, A, A], [-A, A, A]],
shared,
normal: [0.0, 0.0, 1.0],
axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
tone: Tone::Top,
},
Face {
name: "base top",
corners: vec![[0.0, T, T], [L, T, T], [L, D, T], [0.0, D, T]],
shared: vec![true, true, true, false],
tone: Tone::Top,
},
Face {
name: "wall front",
corners: vec![[0.0, T, T], [L, T, T], [L, T, H], [0.0, T, H]],
shared: vec![true, true, true, false],
name: "left",
corners: [[-A, A, -A], [A, A, -A], [A, A, A], [-A, A, A]],
shared,
normal: [0.0, 1.0, 0.0],
axes: [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
tone: Tone::Left,
},
Face {
name: "base front",
corners: vec![[0.0, D, 0.0], [L, D, 0.0], [L, D, T], [0.0, D, T]],
shared: vec![false, true, true, false],
tone: Tone::Left,
},
Face {
name: "end",
corners: vec![[L, 0.0, 0.0], [L, D, 0.0], [L, D, T], [L, T, T], [L, T, H], [L, 0.0, H]],
shared: vec![false, true, true, true, true, false],
name: "right",
corners: [[A, -A, -A], [A, A, -A], [A, A, A], [A, -A, A]],
shared,
normal: [1.0, 0.0, 0.0],
axes: [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
tone: Tone::Right,
},
]
@@ -278,31 +265,39 @@ fn ccw(poly: Vec<P2>) -> Vec<P2> {
if signed_area(&poly) < 0.0 { poly.into_iter().rev().collect() } else { poly }
}
/// A face's outline as path data, with its tone and its vertical extent on screen.
struct Outline {
name: &'static str,
tone: Tone,
d: String,
top: f64,
bottom: f64,
/// A face's outline on screen, inset at the shared edges by half the gap, wound
/// counter-clockwise (positive area).
fn outline_d(face: &Face, detail: Detail) -> Result<String, String> {
let screen: Vec<P2> = face.corners.iter().map(|&p| project(p)).collect();
Ok(polyline(&ccw(inset(&screen, &face.shared, detail.gap() / 2.0)?), true))
}
/// Each face's outline on screen, inset at the shared edges by half the gap, wound
/// counter-clockwise (positive area); the base top carries the bolt hole wound the other way.
fn face_outlines(detail: Detail) -> Result<Vec<Outline>, String> {
let mut out = Vec::new();
for f in faces() {
let screen: Vec<P2> = f.corners.iter().map(|&p| project(p)).collect();
let poly = ccw(inset(&screen, &f.shared, detail.gap() / 2.0)?);
let (top, bottom) = poly.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
let mut d = polyline(&poly, true);
if f.name == "base top" {
d.push(' ');
d.push_str(&circle_d(HOLE, HOLE_R, T, false));
}
out.push(Outline { name: f.name, tone: f.tone, d, top, bottom });
/// The fringe's band boundaries on a face: for each band radius that reaches the face's plane,
/// the band's colour and the circle (centre, radius) where the sphere around the hot point cuts
/// the plane, largest first.
fn bands(face: &Face) -> Vec<(&'static str, P3, f64)> {
let dist = dot3(add3(HOT, face.corners[0], -1.0), face.normal);
let centre = add3(HOT, face.normal, -dist);
BAND_RADII.iter().zip(&FRINGE[1..]).filter(|(r, _)| **r > dist.abs()).map(|(r, colour)| (*colour, centre, (r * r - dist * dist).sqrt())).collect()
}
/// The hexahedral mesh's edges on the visible faces, as path data. The interior nodes are
/// shared, so lines run on across the face boundaries as a real structured mesh would.
fn mesh_d() -> String {
let cs: Vec<f64> = (1..CELLS).map(|i| -A + 2.0 * A * i as f64 / CELLS as f64).collect();
let mut segs: Vec<(P3, P3)> = Vec::new();
for &c in &cs {
// Top.
segs.push(([c, -A, A], [c, A, A]));
segs.push(([-A, c, A], [A, c, A]));
// Left (y = A).
segs.push(([c, A, -A], [c, A, A]));
segs.push(([-A, A, c], [A, A, c]));
// Right (x = A).
segs.push(([A, c, -A], [A, c, A]));
segs.push(([A, -A, c], [A, A, c]));
}
Ok(out)
segs.iter().map(|&(a, b)| polyline(&[project(a), project(b)], false)).collect::<Vec<_>>().join(" ")
}
/// Clip a segment to the axis-aligned square [lo, hi]² (Liang–Barsky).
@@ -326,80 +321,13 @@ fn clip_to_square(a: P2, b: P2, lo: f64, hi: f64) -> Option<(P2, P2)> {
(t0 < t1).then(|| ([a[0] + d[0] * t0, a[1] + d[1] * t0], [a[0] + d[0] * t1, a[1] + d[1] * t1]))
}
/// The hexahedral mesh's edges on the visible faces, as path data. The interior nodes are
/// shared, so lines run on across the face boundaries as a real structured mesh would.
fn mesh_d() -> String {
let (x0, x1) = (HOLE[0] - BLOCK, HOLE[0] + BLOCK);
let xs: Vec<f64> = (0..=4).map(|i| x0 + (x1 - x0) * i as f64 / 4.0).collect();
let ys: Vec<f64> = (1..4).map(|i| T + (D - T) * i as f64 / 4.0).collect();
let zs: Vec<f64> = (1..4).map(|i| T + (H - T) * i as f64 / 4.0).collect();
let mid = T / 2.0;
let mut segs: Vec<(P3, P3)> = Vec::new();
// Base top: the O-grid block's sides and the strips either side of it.
for x in [x0, x1] {
segs.push(([x, T, T], [x, D, T]));
}
for &y in &ys {
segs.push(([0.0, y, T], [x0, y, T]));
segs.push(([x1, y, T], [L, y, T]));
}
// Wall front and wall top.
for &x in &xs {
segs.push(([x, T, T], [x, T, H]));
segs.push(([x, 0.0, H], [x, T, H]));
}
for &z in &zs {
segs.push(([0.0, T, z], [L, T, z]));
}
segs.push(([0.0, mid, H], [L, mid, H]));
// Base front.
for &x in &xs {
segs.push(([x, D, 0.0], [x, D, T]));
}
segs.push(([0.0, D, mid], [L, D, mid]));
// End face (x = L), an L shape.
segs.push(([L, mid, 0.0], [L, mid, H]));
for &y in std::iter::once(&T).chain(&ys) {
segs.push(([L, y, 0.0], [L, y, T]));
}
segs.push(([L, 0.0, mid], [L, D, mid]));
segs.push(([L, 0.0, T], [L, T, T]));
for &z in &zs {
segs.push(([L, 0.0, z], [L, T, z]));
}
let mut parts: Vec<String> = segs.iter().map(|&(a, b)| polyline(&[project(a), project(b)], false)).collect();
// O-grid around the hole: spokes from the block's boundary nodes to the hole, and one ring
// halfway between the hole and the block's square.
for i in -2_i32..=2 {
for j in -2_i32..=2 {
if i.abs() != 2 && j.abs() != 2 {
continue;
}
let node = [HOLE[0] + BLOCK * i as f64 / 2.0, HOLE[1] + BLOCK * j as f64 / 2.0];
let t = (j as f64).atan2(i as f64);
let rim = [HOLE[0] + HOLE_R * t.cos(), HOLE[1] + HOLE_R * t.sin()];
parts.push(polyline(&[project([rim[0], rim[1], T]), project([node[0], node[1], T])], false));
}
}
let ring: Vec<P2> = (0..96)
.map(|k| {
let t = k as f64 * TAU / 96.0;
let square = BLOCK / t.cos().abs().max(t.sin().abs());
let r = HOLE_R + 0.5 * (square - HOLE_R);
project([HOLE[0] + r * t.cos(), HOLE[1] + r * t.sin(), T])
})
.collect();
parts.push(polyline(&ring, true));
parts.join(" ")
}
/// The floor grid on the ground plane under the part, clipped to the backdrop's square.
/// The floor grid on the ground plane under the cube, clipped to the backdrop's square.
fn floor_d() -> String {
let step = 1.0 / 3.0;
let step = 1.0 / 4.0;
let mut parts = Vec::new();
for k in -12..=15 {
let c = k as f64 * step;
for (a, b) in [([c, -6.0, 0.0], [c, 6.0, 0.0]), ([-6.0, c, 0.0], [6.0, c, 0.0])] {
for k in -24..=24 {
let c = A + k as f64 * step;
for (a, b) in [([c, -8.0, -A], [c, 8.0, -A]), ([-8.0, c, -A], [8.0, c, -A])] {
if let Some((p, q)) = clip_to_square(project(a), project(b), BACKDROP[0], BACKDROP[1]) {
parts.push(polyline(&[p, q], false));
}
@@ -442,36 +370,55 @@ fn linear(stops: [&str; 2], top: f64, bottom: f64) -> Value {
"start": [CANVAS / 2.0, top], "end": [CANVAS / 2.0, bottom]}})
}
fn part_layer(detail: Detail) -> Result<Layer, String> {
let mut shapes = Vec::new();
for Outline { name, tone, d, top, bottom } in face_outlines(detail)? {
let colours = match tone {
Tone::Top => TOP,
Tone::Left => LEFT,
Tone::Right => RIGHT,
};
shapes.push(Shape::filled(name, d, linear(colours, top, bottom)));
fn colour(c: &str) -> Value {
json!({"color": c})
}
/// One face of the cube: the coldest band filling it, the warmer bands as nested discs, and a
/// translucent shade that sets the face's tone, all clipped to the face.
fn face_layer(face: &Face, detail: Detail) -> Result<Layer, String> {
let outline = outline_d(face, detail)?;
let mut shapes = vec![Shape::filled(face.name, outline.clone(), colour(FRINGE[0]))];
for (i, (c, centre, r)) in bands(face).into_iter().enumerate() {
shapes.push(Shape::filled(&format!("{} band {}", face.name, i + 1), circle_d(centre, face.axes[0], face.axes[1], r), colour(c)));
}
let centre = project([HOLE[0], HOLE[1], T]);
let half = HOLE_R * SCALE * 0.5_f64.sqrt();
shapes.push(Shape::filled("bolt hole", circle_d(HOLE, HOLE_R, T, true), linear(BORE, centre[1] - half, centre[1] + half)));
Ok(Layer { name: "Part", shapes, clip: None })
let shade = match face.tone {
Tone::Top => Some(("#ffffff", 0.1)),
Tone::Left => Some(("#000000", 0.12)),
Tone::Right => Some(("#000000", 0.3)),
};
if let Some((c, opacity)) = shade {
let mut s = Shape::filled(&format!("{} shade", face.name), outline.clone(), colour(c));
s.opacity = opacity;
shapes.push(s);
}
let name = match face.tone {
Tone::Top => "Top face",
Tone::Left => "Left face",
Tone::Right => "Right face",
};
Ok(Layer { name, shapes, clip: Some(outline) })
}
fn part_layers(detail: Detail) -> Result<Vec<Layer>, String> {
faces().iter().map(|f| face_layer(f, detail)).collect()
}
fn mesh_layer() -> Result<Layer, String> {
let clip = face_outlines(Detail::Full)?.into_iter().map(|o| o.d).collect::<Vec<_>>().join(" ");
let stroke = Stroke { paint: json!({"color": "#ffffff"}), width: 4.5, cap: "butt" };
Ok(Layer { name: "Mesh", shapes: vec![Shape::stroked("hexahedral mesh", mesh_d(), stroke, 0.5)], clip: Some(clip) })
let clip = faces().iter().map(|f| outline_d(f, Detail::Full)).collect::<Result<Vec<_>, _>>()?.join(" ");
let stroke = Stroke { paint: colour("#0b2236"), width: 3.5, cap: "butt" };
Ok(Layer { name: "Mesh", shapes: vec![Shape::stroked("hexahedral mesh", mesh_d(), stroke, 0.45)], clip: Some(clip) })
}
fn floor_layer(appearance: Appearance) -> Layer {
let colour = match appearance {
Appearance::Light => "#7fa8a2",
Appearance::Dark => "#4f8a82",
Appearance::Light => "#8aa0b4",
Appearance::Dark => "#4f6a86",
};
let fade = [(0.0, 1.0), (0.3, 0.9), (0.65, 0.35), (1.0, 0.0)];
let stops: Vec<Value> = fade.iter().map(|(o, a)| json!({"offset": o, "color": colour, "opacity": a})).collect();
let paint = json!({"gradient": {"kind": "radial", "stops": stops, "start": [512.0, 720.0], "end": [1090.0, 720.0], "aspect": 50}});
let floor = project([0.0, 0.0, -A]);
let paint = json!({"gradient": {"kind": "radial", "stops": stops, "start": floor, "end": [floor[0] + 560.0, floor[1]], "aspect": 50}});
let stroke = Stroke { paint, width: 3.0, cap: "butt" };
// Clipped to the backdrop, so the flattened icon's grid stops at its rounded corners (Icon
// Composer masks its own layers the same way).
@@ -480,8 +427,8 @@ fn floor_layer(appearance: Appearance) -> Layer {
fn backdrop_layer(appearance: Appearance) -> Layer {
let (colours, shadow) = match appearance {
Appearance::Light => (["#fbfdfd", "#dbe5e4"], "#1d3b38"),
Appearance::Dark => (["#2b3433", "#141a1a"], "#000000"),
Appearance::Light => (["#fbfcfe", "#dce3ec"], "#1d2b3b"),
Appearance::Dark => (["#2a303a", "#13171d"], "#000000"),
};
let mut shape = Shape::filled("backdrop", backdrop_d(), linear(colours, BACKDROP[0], BACKDROP[1]));
shape.shadow = Some(json!({"mode": "multiply", "opacity": 22, "x": 0, "y": 10, "blur": 14, "color": shadow}));
@@ -493,7 +440,7 @@ fn composite(detail: Detail, appearance: Appearance) -> Result<Doc, String> {
if detail == Detail::Full {
layers.push(floor_layer(appearance));
}
layers.push(part_layer(detail)?);
layers.extend(part_layers(detail)?);
if detail == Detail::Full {
layers.push(mesh_layer()?);
}
@@ -599,13 +546,14 @@ fn icon_json() -> Value {
"translucency": {"enabled": false, "value": 0.3}})
};
let position = json!({"scale": 0.9, "translation-in-points": [0, 0]});
let plain = |file: &str| {
json!({"image-name": format!("{file}.svg"), "name": file, "position": position,
"glass-specializations": [{"appearance": "tinted", "value": true}]})
};
json!({
"fill": "system-light",
"groups": [
group("03 Mesh", json!({"image-name": "03-mesh.svg", "name": "03-mesh", "position": position,
"glass-specializations": [{"appearance": "tinted", "value": true}]}), false),
group("02 Part", json!({"image-name": "02-part.svg", "name": "02-part", "position": position,
"glass-specializations": [{"appearance": "tinted", "value": true}]}), true),
group("02 Part", plain("02-part"), true),
group("01 Floor", json!({"image-name-specializations": [{"value": "01-floor.svg"}, {"appearance": "dark", "value": "01-floor-dark.svg"}],
"name": "01-floor", "fill": "none", "position": position,
"glass-specializations": [{"value": false}, {"appearance": "tinted", "value": true}]}), false),
@@ -637,12 +585,14 @@ pub fn run(root: &Path) -> Result<(), String> {
vectorcraft(&composite(Detail::Small, Appearance::Dark)?, &work.join("icon-small-dark.vectorcraft"), &[work.join("icon-small-dark.png")])?;
// Icon Composer layers: the system supplies the backdrop.
// The mesh shares the cube's Icon Composer group.
let single = |title, layer| Doc { title, layers: vec![layer] };
let mut part_with_mesh = part_layers(Detail::Full)?;
part_with_mesh.push(mesh_layer()?);
let layer_docs = [
("01-floor", single("FieldCraft icon floor grid", floor_layer(Appearance::Light))),
("01-floor-dark", single("FieldCraft icon floor grid (Dark)", floor_layer(Appearance::Dark))),
("02-part", single("FieldCraft icon part", part_layer(Detail::Full)?)),
("03-mesh", single("FieldCraft icon mesh", mesh_layer()?)),
("02-part", Doc { title: "FieldCraft icon cube", layers: part_with_mesh }),
];
for (name, doc) in &layer_docs {
vectorcraft(doc, &layers.join(format!("{name}.vectorcraft")), &[assets.join(format!("{name}.svg"))])?;
@@ -717,21 +667,13 @@ mod tests {
}
#[test]
fn the_hole_winds_against_its_face() {
for o in face_outlines(Detail::Full).unwrap() {
assert!(o.d.starts_with('M') && o.d.contains('Z'), "{}: {}", o.name, o.d);
fn every_face_shows_every_band_hottest_last() {
for f in faces() {
let b = bands(&f);
assert_eq!(b.len(), BAND_RADII.len(), "{}", f.name);
assert!(b.windows(2).all(|w| w[0].2 > w[1].2), "{}", f.name);
assert_eq!(b.last().map(|x| x.0), Some(FRINGE[FRINGE.len() - 1]));
}
let anchors = |d: &str| -> Vec<P2> {
d.split('C')
.skip(1)
.map(|s| {
let n: Vec<f64> = s.split_whitespace().filter_map(|x| x.trim_end_matches('Z').parse().ok()).collect();
[n[4], n[5]]
})
.collect()
};
assert!(signed_area(&anchors(&circle_d(HOLE, HOLE_R, T, true))) > 0.0);
assert!(signed_area(&anchors(&circle_d(HOLE, HOLE_R, T, false))) < 0.0);
}
#[test]
@@ -746,8 +688,9 @@ mod tests {
let steps = commands(&doc, Path::new("/tmp/icon.vectorcraft"));
assert_eq!(steps.first().map(|s| s.0), Some("file.new"));
assert_eq!(steps.last().map(|s| s.0), Some("document.save"));
assert_eq!(steps.iter().filter(|s| s.0 == "layer.new").count(), 3);
// The floor grid is clipped to the backdrop and the mesh to the faces.
assert_eq!(steps.iter().filter(|s| s.0 == "layer.clippingMask.toggle").count(), 2);
// Floor, three faces, mesh.
assert_eq!(steps.iter().filter(|s| s.0 == "layer.new").count(), 5);
// The floor grid is clipped to the backdrop, each face's fringe and the mesh to the faces.
assert_eq!(steps.iter().filter(|s| s.0 == "layer.clippingMask.toggle").count(), 5);
}
}
+14 -4
View File
@@ -43,7 +43,7 @@ commands:
against their references and fixtures; write docs/oracle.md after a full run;
--bless freezes a passing case's results in its fixture.json; --quick skips
cases marked slow (release build)
wasm cargo check every library crate for wasm32-unknown-unknown
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
(not the heavy ones) with the wasm check alongside (stops at the first failing
step)
@@ -160,7 +160,8 @@ fn cmd_layers() -> Result<(), String> {
}
}
/// Never break wasm: every layered crate (everything below the apps) must build for the browser.
/// Never break wasm: every layered crate (everything below the apps) and the web app must build
/// for the browser.
fn cmd_wasm() -> Result<(), String> {
let (c, n) = wasm_check()?;
run(c, &format!("cargo check --target wasm32-unknown-unknown ({n} crates)"))?;
@@ -168,12 +169,21 @@ fn cmd_wasm() -> Result<(), String> {
Ok(())
}
/// The app that runs in the browser (apps/fieldcraft-web), checked with the layered crates.
const WEB_APP: &str = "fieldcraft-web";
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
/// scripts and proc macros built for clippy and the tests are reused.
fn wasm_check() -> Result<(Command, usize), String> {
let crates = layers::from_metadata(&metadata()?)?;
let set: Vec<String> =
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
let set: Vec<String> = crates
.iter()
.filter(|c| c.name == WEB_APP || matches!(layers::classify(&c.name), Some(layers::Class::Layer(_))))
.map(|c| c.name.clone())
.collect();
if !set.iter().any(|c| c == WEB_APP) {
return Err(format!("wasm: the web app `{WEB_APP}` is not in the workspace"));
}
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
for p in &set {