Compare commits

..
36 Commits
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 507e8e32d2 release: version 0.5.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 02:57:11 -04:00
jyvikoandClaude Opus 5.5 491b3f6212 engine: the heated-bracket guard checks its element count, not wall time
The 60 s limit failed on the Linux CI heavy job (106 s in the test profile) while the
mesh was within its band; the count is what #303's blow-up changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 02:37:31 -04:00
jyvikoandClaude Opus 5.5 111a6f1a94 Mesh: a face edge split that encroaches a feature edge splits the feature edge
Delaunay refinement split a boundary face edge at its midpoint even when that
midpoint lay inside the diametral sphere of a feature edge beside it. Each such
split halves the face vertices' distance to the curve, so they piled up against
it (the heated bracket grew face vertices 0.016 mm from a 4 mm-sized edge), the
improvement schedule could not lift the tetrahedra there to its target, and
inserted vertex after vertex around them: 29257 elements and about two minutes
to open instead of 23641 and 6 s. The SolidCraft pin move (4c91f9b) added one
interior vertex to the bracket's end faces, which changed the CDT enough to
expose it.

Following Shewchuk (1998), an encroaching midpoint now splits the encroached
feature edge instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 02:17:00 -04:00
jyvikoandClaude Opus 5.5 43e2144090 Docs: the crank's one-datum workflow, its gallery images and the Decompose status
examples.md and mcp.md tell the one-datum workflow and its numbers; the
crank gallery captures and Start card thumbnails are taken again on this
build; ROADMAP marks the automatic blocks of extruded cross-sections done,
and features.md's Decompose entry says what is left (3D parts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:50:49 -04:00
jyvikoandClaude Opus 5.5 8e45f2c275 Samples: the crank meshed from one datum, its half extruded in automatic blocks
crank-arm and crank-mirror now take one datum (Sym) and its partition:
mesh.hex.generate extrudes the half CrankArm/a, cutting its cross-section
into blocks itself, with no seeds, and mesh.mirror reflects it. 8 664
hex20, scaled Jacobian min 0.371; root sigma_xx -62.44/+62.44 MPa against
M/Z 62.6 (0.25 %), spindle reaction 1500 N. The seven partitions and
eleven exact seeds go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:50:49 -04:00
jyvikoandClaude Opus 5.5 86ba1bda5c Release: sign SHA256SUMS.txt with the release key; docs/updates.md (#231)
The release job signs SHA256SUMS.txt into SHA256SUMS.txt.minisig with rsign2 when the
FIELDCRAFT_SIGNING_KEY secret exists, and checks the signature against the public key the app
is built with; without the secret the draft is unsigned, the run warns and the notes say the
app cannot install that release itself. docs/releasing.md says how the key is set up, kept
and rotated; docs/updates.md describes checking, verifying and installing, the web app's
Reload to update, and FIELDCRAFT_UPDATE_HOST for tests. privacy.md, control-protocol.md,
web.md and the roadmap say what changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 93b2ae343c Web: offer Reload to update when a new deployment is out (#231)
Every half hour the page fetches its own address past the browser's cache; when it loads
another content-hashed build of the app than the running one, the status bar offers "Reload
to update" (the page's unsaved-changes question still guards the reload). Rust only, no
service worker: the browser build has no other update path and none of the desktop's
download code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 7dd50a3ca4 App: run update jobs in the background and swap the release in after closing (#231)
The desktop app checks, downloads, verifies and stages on its own threads for the window, and
once the window has closed (autosave closed) swaps a staged release in and starts it. Staging
folders an earlier update left are removed at start-up. Automatic checks stay off under
--control automation and CI unless FIELDCRAFT_UPDATE_HOST names a test server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 2e28a69c04 UI: update notice, Help › Check for Updates and Reinstall, Settings › Updates (#231)
The window checks at start-up at most once a day when automatic checks are on (Settings and
the first run's question), never while that question is open and never without a host, so the
browser and tests make no network call. A newer release shows a quiet notice in the status bar
("FieldCraft X is available — What's new · Install · Skip"); Install downloads and verifies
through the host, then asks to restart, saving or asking about unsaved changes first, and the
host swaps the release in after the window closes. A .deb/.rpm install offers the system's
installer and the command instead. Skip this version is kept in the preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 69c1440cbe CLI: fieldcraft-cli update --check | --install (#231)
--check runs app.update.check and prints its JSON; --install downloads the latest release for
this install, verifies the checksum list's signature and the asset's checksum, and swaps it in
(or, for .deb/.rpm, prints the package manager command for the verified download). Tested
against a local release server through FIELDCRAFT_UPDATE_HOST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 12487794a3 Engine: app.update.check reports whether a newer release is published (#231)
The same check as the app's Help › Check for Updates and fieldcraft-cli update --check: the
latest release, whether it is newer, its page, whether it is signed and the asset this install
would download. It only reports. Checking reaches the network, so only the desktop app and the
CLI switch it on; tests, scratch sessions and the browser build refuse without asking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 8fe31545ea Update: check, verify and stage releases in a new fieldcraft-update crate (#231)
Versions ordered as SemVer, the GitHub Releases API reply read as hostile input, how this
copy is installed (macOS app or CLI, Windows folder, AppImage, tar.gz folder, .deb/.rpm) and
which release asset updates it, the checksum list's minisign signature checked against the
built-in release key, the asset's SHA-256 checked against the list, archives unpacked inside
the staging folder, and the swap planned and applied by renames with undo on failure. The
network and unpacking parts are native only; a mock release server drives the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00
jyvikoandClaude Opus 5.5 4c18322a34 Engine: the half crank extrudes in blocks through mesh.hex.generate and mesh.cells
The extrusion's automatic cross-section blocks reach both commands; the
half crank arm, cut only on its symmetry plane, meshes all-structured
(sJ >= 0.37) with no seeds either way, and mesh.hex.generate's
description says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:26:30 -04:00
jyvikoandClaude Opus 5.5 02172f9a7e Mesh: swept cross-sections cut into blocks automatically
A region of an extrusion's (or revolution's) cross-section that no
structured scheme fits is now cut by straight lines into blocks that fit,
before interval assignment, so the cuts are curves of the arrangement and
their counts are assigned with the rest; nothing reaches the model
(hex::decompose, after Tam and Armstrong's medial-axis subdivision and Li,
Armstrong and McKeag's primitives).

Per region: several holes are separated by lines across the gaps between
neighbouring holes; a piece with one hole gets the O-grid layout whose
pieces score best (lines from the outer corners towards the hole or along
their bisectors, from the hole's corners, or along four directions from
the hole); pieces without holes are cut by a two-ply search over lines
from concave corners and fillets and from convex corners. Candidates are
scored by meshing the pieces with their schemes (smallest corner scaled
Jacobian). A region's blocks are kept when fair (at least 0.5) or no worse
than its free mesh, and cuts never end inside a seeded curve.

This replaces the cuts from reflex corners along an edge. Tests on an L,
a T, a plate with a hole, with a slot and with three holes, and the half
crank's cross-section: every region structured, the quads conforming and
the smallest corner scaled Jacobian bounded. The half crank arm now
extrudes in 12 structured regions with a hex20 minimum scaled Jacobian of
0.37 (the arm meeting the bosses 20 degrees off tangent bounds it) where
it had 2 free regions and 0.009.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:17:30 -04:00
jyvikoandClaude Opus 5.5 a0b4a69452 Mesh: the swept cross-section's quad mesh as its own module
The schemes, interval constraints, curve nodes and region meshes of a
multi-level sweep's cross-section move out of layered::mesh into
hex::section, with the curve placement (seeds, projection onto walls)
passed in, so the cross-section can be meshed on its own.
region::mesh_structured gives a scheme's mesh or None where it would fold,
without falling back to the free mesher. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:17:30 -04:00
jyvikoandClaude Opus 5.5 c83b803f63 web: the deployed browser build carries the PostHog key (#305)
Pages builds main with the repository secret POSTHOG_KEY, so web users are asked
the same consent question as desktop users; develop builds stay keyless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:13:55 -04:00
jyvikoandClaude Opus 5.5 db80b75814 Docs: privacy.md lists every usage event; release builds get the key (#238)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:07:28 -04:00
jyvikoandClaude Opus 5.5 31bc886c7a Web: usage through fetch, preferences in local storage (#238)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:07:28 -04:00
jyvikoandClaude Opus 5.5 ed9ec7748d App: send usage batches to PostHog Cloud EU from a background thread (#238)
ureq over rustls (ring) with the OS certificate verifier. The project key
is the build-time FIELDCRAFT_POSTHOG_KEY; without it nothing is asked or
sent. FIELDCRAFT_TELEMETRY=0 turns it off; --control and CI runs only with
FIELDCRAFT_TELEMETRY=1. A failed post drops its batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:07:28 -04:00
jyvikoandClaude Opus 5.5 79d583ea0f UI: ask once about anonymous usage; Help › Settings › Privacy (#238)
The first run asks whether to share anonymous usage (only when the host
says this run may), the answer and a random install id are kept in the
preferences, and Help › Settings… changes it. Commands are counted by
where they came from (ui, palette, script; mcp when the MCP server says
so), and cancelled forms and clicks on commands that cannot run yet are
recorded. The preferences go through a PrefsStore (a file, or the
browser's storage). Control channel: ui.settings, ui.prompt answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:07:28 -04:00
jyvikoandClaude Opus 5.5 162bb421f1 Engine: anonymous usage events, counted in Session::execute (#238)
fieldcraft_engine::usage holds the events docs/privacy.md lists, typed so
that only registry names (command ids, sample names, requirements) and
durations can go in them, a bounded queue that records nothing until the
host enables it, and the PostHog batch body. Session::execute counts each
top-level command of a session with an origin (ui, palette, script, mcp):
its id, kind, duration and error class, and the funnel's milestones and
opened samples. Nothing here touches the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:07:28 -04:00
jyvikoandClaude Opus 5.5 54f50f9518 Gallery: every example image captured again on the current build
All six docs/media/examples images at 1400 x 860 in the light theme, the
solved sample in the home view (the crank mirror from below, as its text
says), with the wireframe symbols, no SETUP card and the crank's planes
hidden; the Start card thumbnails cropped to the model from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:01:43 -04:00
jyvikoandClaude Opus 5.5 a0d11f1c22 Partitions hide their datum plane
geometry.partition hides the plane it cuts by (view state, saved with the
model): the cut shows as edges on the cells. The eye or selecting it in
the browser shows it again. The crank samples so open without their
planes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:01:43 -04:00
jyvikoandClaude Opus 5.5 dddb2d0584 Datums: drawn at the model's extent, never framed, the selected one shown
A datum plane is drawn as the model's extent within it (where it cuts the
model's box, in the plane's axes) plus a 5 % margin, a datum axis along the
model's extent plus the margin, instead of squares 1.2 diagonals wide.
Fit, Home, the standard views and headless pictures frame the model
without the datums (they stay inside the clipping radius). The datum
selected in the browser is drawn highlighted and on top, even when hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:01:43 -04:00
jyvikoandClaude Opus 5.5 b19277ae7a Docs: crash reports, the log file, Report a Bug and privacy (#107)
docs/crash-reports.md: what is kept where, what a bug report carries
(and only when ticked), the CLI and commands for agents; README's
privacy note; the control protocol's new prompt answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00
jyvikoandClaude Opus 5.5 e06c85eb9e App: crash reports and a log file; Help › Report a Bug (#107)
The desktop app installs the crash reporter and a log file at start
(both in a temporary folder in --control and CI runs, #266), journals
the window's commands (background jobs included) and every control
request's method, and ends the journal on a clean exit. A start after a
crash asks: "FieldCraft closed unexpectedly", Report It… or Not Now.

Help › Report a Bug (also Tools › Help and ui.start
diagnostics.bug_report) shows exactly what the GitHub bug form will be
filled with: the version, the OS, the last error, and for a crash where
it happened. Share my session adds the replayable commands, Include the
model file the model. Open GitHub Issue opens the form in the browser
through the system's opener; files to attach are saved to
bug-reports/<time>/ and a long session goes on the clipboard. Nothing is
sent without that click and the user's submit on GitHub.

The control channel answers the new prompts (crashed: report, close;
report_bug: set, close) and ui.inspect shows the draft; it never opens
the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00
jyvikoandClaude Opus 5.5 cb7e3a2711 CLI: fieldcraft-cli crashes [list|show|replay|dir|clear] (#107)
Lists, shows, replays and deletes the crash reports the desktop app
wrote. A replay opens the report's model, runs its commands with writes
confined to a scratch folder, and says whether the same internal error
happens again. Adapted from SolidCraft's solidcraft-cli crashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00
jyvikoandClaude Opus 5.5 080ff048c6 Engine: crash reports, a log file and bug report drafts (#107)
crash: a panic hook and a locked per-run command journal (adapted from
SolidCraft's crash reports). A panic in a command becomes a report with
the model from before it and the command; one outside commands, or an
unclean exit found at the next start (abort, segfault, a crash in a
graphics driver, a kill: what #248 needs), a report with the model the
journal starts from (autosave, file or a copy) and the commands since,
the last control request and the dead run's log lines. Reports are
bounded, deduplicated by signature and replayable (crash::replay, with
writes confined to a scratch folder). The crash folder follows the
automation temp area in --control and CI runs.

logfile: a small size-rotated file logger behind the log facade.

bug_report: GitHub bug-form drafts. Version, OS and what happened by
default, home folder shown as ~; the command journal only with Share my
session, the model only with Include the model as well; a session too
long for the link becomes a file to attach and the clipboard text.

Commands: diagnostics.crash_reports, diagnostics.crash_report,
diagnostics.bug_report (Tools › Help, with its icon) and
diagnostics.crash_test (FIELDCRAFT_CRASH_TEST=1 only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00
jyvikoandClaude Opus 5.5 3d51bde9dd io: the app's data folder, and confining writes alone
recovery::data_dir() names the per-user data folder (the temporary
fieldcraft-automation folder in automated runs) that the recovery, crash
and log folders live in. Roots::writes_within lets reads go anywhere
while writes stay in one folder, for replaying a crash report safely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00
jyvikoandClaude Opus 5.5 d3ed8ffb05 Start card: every sample as a card with its thumbnail, flagship first, scrolling (#245)
The Start card and File › Samples show a grid of sample cards (thumbnail, name,
one-line description, the full text on hover) in a scroll area with a solid
scrollbar; Help › Samples keeps rows in the same order. The card stays clear of
the view cube and the navigation bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:06:20 -04:00
jyvikoandClaude Opus 5.5 072139353f Samples: 320 px thumbnails cropped from the gallery captures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:06:20 -04:00
jyvikoandClaude Opus 5.5 08f669acad Browser and viewport: item menu, per-item eyes, Delete key with confirmation
Right-click on any browser item (body, surface, datum, partition, set,
material, section, step, boundary condition, load, reference point,
coupling, contact, fluid domain) opens its menu: Edit…, Hide/Show,
Isolate, Show All, Active in step (boundary conditions and loads) and
Delete…. A right click on a datum or a partition's cut face in the
viewport opens the same menu. Each item the viewport draws on its own
has an eye; hidden items are neither drawn nor picked. Datums and
partitions are selectable with properties. Delete… and the Delete key
ask first (the prompt lists what uses the item: a datum's partitions
are deleted with it, other definitions in use are refused); ui.prompt
answers it. Every entry runs the same command as the ribbon and forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:51:01 -04:00
jyvikoandClaude Opus 5.5 d7ec7f486a View state: hide, show and isolate viewport items, saved with the model
view.hide, view.show, view.isolate and view.show_all change which
bodies, surfaces, datums, boundary conditions, loads, reference points
and couplings the viewport draws. The hidden items are view state: the
manifest's `view` keeps them, a change marks the model unsaved, but it
is neither journalled nor undone and leaves results current. Items that
go away are forgotten. Headless pictures leave hidden items out; the
glyph builders take a filter. Tools > View > Show All claims the
commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:51:01 -04:00
jyvikoandClaude Opus 5.5 d9824de4a5 Engine: geometry.datum.point; planes and axes through datum points; datum edits and cascading delete
A datum point by coordinates, at a mesh vertex, a fraction along a
design edge, at a face's centre, or where a datum axis crosses a datum
plane. Datum planes take three datum points or a normal to a datum axis;
axes take two datum points. geometry.datum.edit, .axis.edit and
.point.edit redefine a datum of their kind (partitions cut again);
geometry.datum.delete with cascade also deletes the partitions that cut
by it. The ribbon shows Datum Plane, Datum Axis, Datum Point and
Partition, each with its own icon; edit and delete leave the ribbon
(still commands, claimed by their entries).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:51:01 -04:00
jyvikoandClaude Opus 5.5 fbaba5fecd Geometry: a face's area centroid and a point a fraction along an edge
Shape::face_centre and Edge::point_at place datum points on a design's
named faces and edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:51:01 -04:00
jyvikoandClaude Opus 5.5 0bf150633c Model: datum points, drawn as a small cross; each definition's users are public
Datum::Point joins planes and axes (checked like them, refused by
partitions with the kind named). The users each remove_* refuses with
(sets, materials, boundary conditions, loads, reference points, fluid
domains, datums) are public functions, so a caller can list them before
deleting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 23:51:01 -04:00
132 changed files with 13277 additions and 705 deletions

No files matched your search

+1 -1
View File
@@ -41,6 +41,6 @@ body:
id: version
attributes:
label: Version and platform
placeholder: "v0.5.8 (or commit), macOS 15 arm64"
placeholder: "v0.5.9 (or commit), macOS 15 arm64"
validations:
required: true
+2
View File
@@ -21,6 +21,8 @@ modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft
To start with a sample model, run the app with `--sample cantilever` (macOS:
`/Applications/FieldCraft.app/Contents/MacOS/FieldCraft --sample cantilever`).
**Updates.** {update_signing}
**Signing.** {macos_signing} The Linux and Windows builds are not signed; on Windows, SmartScreen
may say "Windows protected your PC": choose **More info**, then **Run anyway**.
+4
View File
@@ -53,6 +53,10 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the web app
working-directory: apps/fieldcraft-web
env:
# Usage analytics only in the deployed build (main), and only with the user's consent
# (docs/privacy.md); develop builds get an empty key, which sends nothing.
FIELDCRAFT_POSTHOG_KEY: ${{ github.ref == 'refs/heads/main' && secrets.POSTHOG_KEY || '' }}
run: trunk build --release --public-url /fieldcraft/
- name: Bundle size
run: |
+44 -3
View File
@@ -3,7 +3,8 @@
#
# 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
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt (signed with the release key
# into SHA256SUMS.txt.minisig when the FIELDCRAFT_SIGNING_KEY secret exists) and generated notes. The
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
# pre-release. A person verifies the draft and publishes it; this workflow never publishes,
# and pushes to develop or tags never start it.
@@ -97,6 +98,8 @@ jobs:
- name: Build, sign, notarize and package (the dmg is mounted and checked)
id: package
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -139,6 +142,9 @@ jobs:
cargo install --locked --version "$CARGO_DEB_VERSION" cargo-deb
cargo install --locked --version "$CARGO_GENERATE_RPM_VERSION" cargo-generate-rpm
- name: Build and package (tar.gz, AppImage, deb, rpm)
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -211,6 +217,9 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build and package (zip)
shell: pwsh
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
run: ./packaging/windows/package.ps1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -237,7 +246,7 @@ jobs:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Check the set, checksums and notes
- name: Check the set and write the checksums
run: |
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
@@ -246,13 +255,45 @@ jobs:
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
done
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
- name: Sign SHA256SUMS.txt with the release key (skipped without FIELDCRAFT_SIGNING_KEY)
id: sign
env:
FIELDCRAFT_SIGNING_KEY: ${{ secrets.FIELDCRAFT_SIGNING_KEY }}
# rsign2 (MIT), a minisign-compatible signer, pinned.
RSIGN_VERSION: 0.6.7
run: |
if [ -z "$FIELDCRAFT_SIGNING_KEY" ]; then
echo "::warning::FIELDCRAFT_SIGNING_KEY is not set: SHA256SUMS.txt is not signed, so the app cannot install this release itself (docs/releasing.md)"
echo "signed=false" >>"$GITHUB_OUTPUT"
exit 0
fi
cargo install --locked --quiet --root "$RUNNER_TEMP/rsign" rsign2 --version "$RSIGN_VERSION"
rsign="$RUNNER_TEMP/rsign/bin/rsign"
key="$RUNNER_TEMP/release.key"
(umask 077 && printf '%s\n' "$FIELDCRAFT_SIGNING_KEY" >"$key")
"$rsign" sign -W -s "$key" -t "FieldCraft v$VERSION SHA256SUMS.txt" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
rm -f "$key"
# The signature must verify with the public key the app is built with.
public="$(sed -n 's/^pub const PUBLIC_KEY: &str = "\(.*\)";$/\1/p' crates/update/src/lib.rs)"
"$rsign" verify -q -P "$public" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
echo "signed=true" >>"$GITHUB_OUTPUT"
- name: Write the notes
env:
SIGNED: ${{ steps.sign.outputs.signed }}
run: |
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
case "$MACOS_SIGNING" in
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
esac
awk -v v="$VERSION" -v m="$macos" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); print }' \
# The updates paragraph says whether the app can install this release itself.
if [ "$SIGNED" = true ]; then
updates="\`SHA256SUMS.txt\` is signed with the FieldCraft release key (\`SHA256SUMS.txt.minisig\`), so FieldCraft's Help › Check for Updates can verify and install this release."
else
updates="This release's \`SHA256SUMS.txt\` is **not signed**: FieldCraft's in-app updates can't verify it and won't install it; download it from this page instead."
fi
awk -v v="$VERSION" -v m="$macos" -v u="$updates" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); gsub(/\{update_signing\}/, u); print }' \
.github/release-notes.md >notes.md
cat notes.md >>"$GITHUB_STEP_SUMMARY"
- name: Upload the dry-run artifacts
+11
View File
@@ -24,12 +24,19 @@ is committed and listed here with its exports.
| docs/media/examples/bracket-hex.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved bracket-hex sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/bracket-hex.png | FieldCraft contributors | Original: the viewport of docs/media/examples/bracket-hex.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the bracket-hex sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/crank-arm.png | FieldCraft contributors | Original: the viewport of docs/media/examples/crank-arm.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-arm sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/crank-mirror.png | FieldCraft contributors | Original: the viewport of docs/media/examples/crank-mirror.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the crank-mirror sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/drop-test.png | FieldCraft contributors | Original: the viewport of docs/media/examples/drop-test.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the drop-test sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/heated-bracket.png | FieldCraft contributors | Original: the viewport of docs/media/examples/heated-bracket.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the heated-bracket sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/rubber-seal.png | FieldCraft contributors | Original: the viewport of docs/media/examples/rubber-seal.png cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the rubber-seal sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.write.abaqus.svg, assets/icons/deck.write.abaqus.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.abaqus.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.write.lsdyna.svg, assets/icons/deck.write.lsdyna.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.lsdyna.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.write.nastran.svg, assets/icons/deck.write.nastran.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.nastran.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/diagnostics.bug_report.svg, assets/icons/diagnostics.bug_report.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/diagnostics.bug_report.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/edit.redo.svg, assets/icons/edit.redo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.redo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/edit.undo.svg, assets/icons/edit.undo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.undo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/file.open.svg, assets/icons/file.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -38,6 +45,8 @@ is committed and listed here with its exports.
| assets/icons/file.save.svg, assets/icons/file.save.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.bodies.svg, assets/icons/geometry.bodies.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.bodies.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.svg, assets/icons/geometry.datum.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.axis.svg, assets/icons/geometry.datum.axis.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.axis.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.datum.point.svg, assets/icons/geometry.datum.point.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.datum.point.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.import.mesh.svg, assets/icons/geometry.import.mesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.import.mesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.midsurface.svg, assets/icons/geometry.midsurface.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.midsurface.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.open.svg, assets/icons/geometry.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -141,6 +150,7 @@ 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/icons/view.show_all.svg, assets/icons/view.show_all.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/view.show_all.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 (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 |
@@ -177,6 +187,7 @@ is committed and listed here with its exports.
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 |
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
Generated
+400 -30
View File
@@ -431,6 +431,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -661,6 +667,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -731,6 +747,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -744,7 +770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@@ -757,7 +783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@@ -846,6 +872,12 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ct-codecs"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -890,6 +922,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -1170,7 +1203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1237,7 +1270,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fieldcraft"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"eframe",
"egui",
@@ -1245,24 +1278,29 @@ dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-ui-egui",
"fieldcraft-update",
"getrandom 0.3.4",
"log",
"rfd",
"rustls",
"serde_json",
"ureq",
]
[[package]]
name = "fieldcraft-cli"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
"fieldcraft-mcp",
"fieldcraft-update",
"serde_json",
]
[[package]]
name = "fieldcraft-decks"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"serde_json",
@@ -1271,7 +1309,7 @@ dependencies = [
[[package]]
name = "fieldcraft-engine"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-decks",
"fieldcraft-explicit",
@@ -1284,6 +1322,7 @@ dependencies = [
"fieldcraft-parallel",
"fieldcraft-render",
"fieldcraft-solver",
"fieldcraft-update",
"log",
"png",
"serde",
@@ -1294,7 +1333,7 @@ dependencies = [
[[package]]
name = "fieldcraft-explicit"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-fem",
"fieldcraft-model",
@@ -1305,7 +1344,7 @@ dependencies = [
[[package]]
name = "fieldcraft-fem"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"thiserror 2.0.21",
@@ -1313,7 +1352,7 @@ dependencies = [
[[package]]
name = "fieldcraft-geometry"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"quick-xml",
@@ -1329,7 +1368,7 @@ dependencies = [
[[package]]
name = "fieldcraft-io"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"log",
@@ -1342,7 +1381,7 @@ dependencies = [
[[package]]
name = "fieldcraft-lbm"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-mesh",
"fieldcraft-model",
@@ -1352,7 +1391,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mcp"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-engine",
"fieldcraft-io",
@@ -1363,7 +1402,7 @@ dependencies = [
[[package]]
name = "fieldcraft-mesh"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"num-bigint",
@@ -1375,7 +1414,7 @@ dependencies = [
[[package]]
name = "fieldcraft-model"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"serde",
"serde_json",
@@ -1384,14 +1423,14 @@ dependencies = [
[[package]]
name = "fieldcraft-parallel"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"rayon",
]
[[package]]
name = "fieldcraft-render"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-model",
"png",
@@ -1401,7 +1440,7 @@ dependencies = [
[[package]]
name = "fieldcraft-solver"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-fem",
"fieldcraft-mesh",
@@ -1413,7 +1452,7 @@ dependencies = [
[[package]]
name = "fieldcraft-testkit"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"fieldcraft-engine",
"fieldcraft-geometry",
@@ -1424,7 +1463,7 @@ dependencies = [
[[package]]
name = "fieldcraft-ui-egui"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"craft-ui",
"egui",
@@ -1435,17 +1474,36 @@ dependencies = [
"fieldcraft-geometry",
"fieldcraft-model",
"fieldcraft-render",
"fieldcraft-update",
"log",
"png",
"resvg",
"serde",
"serde_json",
]
[[package]]
name = "fieldcraft-web"
version = "0.5.8"
name = "fieldcraft-update"
version = "0.5.9"
dependencies = [
"base64",
"flate2",
"log",
"minisign",
"minisign-verify",
"rustls",
"serde_json",
"sha2 0.11.1",
"tar",
"thiserror 2.0.21",
"ureq",
"zip",
]
[[package]]
name = "fieldcraft-web"
version = "0.5.9"
dependencies = [
"base64 0.22.1",
"eframe",
"egui",
"fieldcraft-engine",
@@ -1460,6 +1518,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1632,8 +1700,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
@@ -1820,6 +1890,31 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -1885,6 +1980,15 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -2135,6 +2239,24 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minisign"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586"
dependencies = [
"ct-codecs",
"getrandom 0.4.3",
"rpassword",
"scrypt",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2627,6 +2749,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2685,6 +2813,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac",
]
[[package]]
name = "peniko"
version = "0.6.1"
@@ -3148,6 +3286,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "robust"
version = "1.2.0"
@@ -3163,6 +3315,27 @@ dependencies = [
"memchr",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3207,7 +3380,81 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
@@ -3216,6 +3463,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3225,6 +3481,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -3237,6 +3502,40 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2",
"salsa20",
"sha2 0.10.9",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -3625,6 +3924,12 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
@@ -3667,6 +3972,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3677,7 +3992,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4032,7 +4347,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4059,6 +4374,40 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "usvg"
version = "0.48.1"
@@ -4078,6 +4427,12 @@ dependencies = [
"tiny-skia-path",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "uuid"
version = "1.28.0"
@@ -4378,6 +4733,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -4573,7 +4937,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4866,7 +5230,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -4918,7 +5282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4992,7 +5356,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.8"
version = "0.5.9"
dependencies = [
"serde_json",
]
@@ -5123,6 +5487,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zip"
version = "3.0.0"
+2 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.8"
version = "0.5.9"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -27,6 +27,7 @@ fieldcraft-testkit = { path = "crates/testkit" }
fieldcraft-render = { path = "crates/render" }
fieldcraft-ui-egui = { path = "crates/ui-egui" }
fieldcraft-mcp = { path = "crates/mcp" }
fieldcraft-update = { path = "crates/update" }
# foundation
thiserror = "2"
+10
View File
@@ -135,6 +135,16 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci
- [Releasing](docs/releasing.md): how a release is built, verified and published
- [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md)
## Privacy
FieldCraft sends nothing until you agree. The first start asks once whether to share anonymous
usage (which tools you use, nothing from your models); Help › Settings… changes it, and
`FIELDCRAFT_TELEMETRY=0` turns it off. File names, paths, geometry, model contents, parameter
values and messages are never sent: [docs/privacy.md](docs/privacy.md) lists every event and
property. Crash reports and the log stay on your computer; Help › Report a Bug opens a GitHub issue
you review and submit yourself, with your session or model attached only if you tick them
([docs/crash-reports.md](docs/crash-reports.md)).
## License
Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. Notices
+2 -2
View File
@@ -31,7 +31,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.fieldcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress |
| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `fieldcraft-fem` kernels, `fieldcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion; 1D elements I: rod2/rod3 trusses, point masses, rotary inertia, springs and dashpots in both solvers and all three decks, verified by the method of joints, the three-bar truss, spring–mass, two-mass chain and torsional pendulum frequencies, and damped decay; 2D elements: plane stress, plane strain and axisymmetric tri3, tri6, quad4 (incompatible modes QM6), quad8 with two degrees of freedom per node, centrifugal loads, `mesh.planar` 2D meshes of polygons and planar faces, verified by patch tests, Kirsch, Lamé (plane strain and axisymmetric), rotating discs and cylinders, and a plane stress cantilever; deck mapping to Abaqus CPS/CPE/CAX, Nastran membranes, LS-DYNA ELFORM 12/13); shells and beams (done: quad4 MITC4 and tri3 MITC3 flat shells with a Hughes–Brezzi drilling rotation, two-node Timoshenko beams with profile properties and orientations, six degrees of freedom at shell and beam nodes shared with solids and trusses, section forces and surface stresses, verified by shell patch tests, Navier's plate, cantilever and portal-frame beams, and the Scordelis–Lo and pinched-cylinder benchmarks); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: the half cut into seven cells, each swept structured, seeded to merge, and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); pyramid transitions; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started |
| M5 Structured hex | 2D quad meshing of faces (`fieldcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions (trivariate TFI, curved faces) and sweep meshing (multi-level extrusions with imprinted cross-sections, revolutions, one-to-one sweeps through linking faces; interval assignment, hex8/hex20 on the geometry, B-rep face tags, `mesh.hex.generate`/`sweep`/`mapped`: done, 16 of 21 corpus parts all-hex); conforming interfaces between cells (done: `mesh.hex.blocks` meshes a part split with SolidCraft's Split Body into mapped blocks as one conforming mesh — composite faces for seams and tangent faces, interval assignment over all cells, shared faces meshed once, grading off holes and fillets, free-internal-face check; the filleted bracket in 30 cells, hex20 within 0.1 % of a fine tet10 tip deflection); partitions inside FieldCraft (done: datum planes and axes, `geometry.partition` cuts the open design by a datum plane, kept in the model and replayed when the design opens, the two faces of each cut paired by name); cells meshed each with its own method into one mesh (done: `mesh.cells` — hex where a method fits, else tet; matching interfaces merged node for node, others, such as hex next to tet, tied by dual mortar, verified by a tied hex–tet bar in tension to 1e-6); meshing half a symmetric part and mirroring it (done: `mesh.mirror` reflects hex8/hex20/tet4/tet10/wedge6 across a datum plane, right-handed, nodes on the plane merged, tags carried to the image half; a mirrored half bar equals the whole bar's mesh and bending deflection to 1e-12; samples `crank-arm` and `crank-mirror`: one datum, the half extruded in automatic blocks and mirrored, scaled Jacobian ≥ 0.37; or solving the half: `model.boundary_condition.symmetry` on a datum plane, a half bar with half the load bends as the whole to 1e-11); three-sided sweep regions mapped as triangle primitives (done: midpoint subdivision, dominance constraints in interval assignment); automatic blocks of extruded cross-sections (done: `hex::decompose` — holes separated, O-grids, cuts from concave corners and fillets chosen by block quality, kept only when no worse than the free mesh; nothing added to the model); pyramid transitions; automatic decomposition of 3D parts into sweepable and mappable regions | started |
| M6 Automatic hex | Octree/grid all-hex for any part (first version: done — `fieldcraft-mesh::octree`, `mesh.hex.octree`: 3-refinement grid with conforming templates refined by curvature, thickness and narrow faces, a core cleared to a manifold boundary, feature map onto it, padding and a pillow per patch capturing every sharp curve, foldover-free fitting, certified validity, B-rep face tags; the corpus hex column and the filleted-bracket oracle case; next: transitions for concave refined regions so thin and curved parts stop over-refining); hex-dominant by tet recombination; polycube for blocky parts; layers through thin walls; mesh editing and orphan-mesh import | started |
| M7 Assemblies, contact and friction | Needs M15. Part instances from SolidCraft occurrences; tie between non-matching meshes (mortar: done — `interaction.tie`, dual mortar constraints in the implicit analyses, Abaqus `*TIE` and LS-DYNA tied contact, the tie patch test); implicit contact: surface-to-surface mortar and node-to-surface, penalty and augmented Lagrange, self-contact, automatic pair detection (mortar contact pairs with Coulomb friction in nonlinear static steps: done — segment-to-segment mortar with the consistent tangent by automatic differentiation of the segmentation, augmented Lagrangian with Uzawa updates to a gap tolerance, semismooth active set with a chattering cutback, small sliding without nonlinear geometry and finite sliding with it, overclosure adjusted or refused, Coulomb friction (stick/slip return map, static and kinetic coefficients, symmetrised tangent), pressure, gap, status, shear and slip output; verified by the contact patch test, Hertz cylinder and sphere, Newton's quadratic rate and sliding blocks; the linear analyses refuse contact by name); friction: Coulomb with stick–slip, static/kinetic, anisotropic, pressure-, rate- and temperature-dependent coefficients; contact output (pressure, slip, status); thermal contact conductance and frictional heating; wear (Archard); bolt pretension, connectors with behaviour (elastic, damped, stops, locks); amplitudes, multiple steps, restart; nonlinear steps in all three decks. Verified by Hertz (sphere and cylinder), the Cattaneo–Mindlin partial-slip problem, the sliding block, NAFEMS contact benchmarks | started |
| M8 Materials, explicit, shells, composites | Plasticity, hyperelasticity, damage data per solver; explicit dynamics decks (Abaqus/Explicit, LS-DYNA); midsurface shells; composite layups and material orientations, continuum shells on swept hex; hybrid (mixed u–p) hex8/hex20/tet10 for nearly incompressible materials (done); heat transfer (solids done: steady and θ-method transient conduction with flux, convection and sources, thermal strain in static steps (solids and 2D) and explicit dynamics (with plasticity), coupled temperature–displacement, `solve.heat`; deck output, 2D/shell/beam conduction planned) | started |
@@ -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, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done; in-app updates — a daily check of GitHub Releases, Help › Check for Updates and Reinstall, downloads verified against the release key's signature of SHA256SUMS.txt and swapped in after closing, `app.update.check`, `fieldcraft-cli update`, Reload to update in the browser, docs/updates.md: done), command schema as the public API | started |
## Next
+1320 -30
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -12,4 +12,8 @@ workspace = true
fieldcraft-engine = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-mcp = { workspace = true }
fieldcraft-update = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
fieldcraft-update = { workspace = true, features = ["mock"] }
+80
View File
@@ -0,0 +1,80 @@
//! `fieldcraft-cli crashes`: the crash reports the desktop app wrote on this computer
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
use std::path::Path;
use fieldcraft_engine::crash;
use serde_json::json;
pub const USAGE: &str = "usage: fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system (set FIELDCRAFT_CRASH_DIR)")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
if args.iter().any(|a| a == "--json") {
return print_json(&json!({ "dir": dir.to_string_lossy(), "reports": reports }));
}
println!("crash reports in {}", dir.display());
if reports.is_empty() {
println!(" (none)");
}
for r in reports {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
let replay = if r.replayable { " [replayable]" } else { "" };
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
}
Ok(())
}
Some("show") => print_json(&crash::read(&dir, id()?)?),
Some("replay") => replay(&dir, id()?),
Some("dir") => {
println!("{}", dir.display());
Ok(())
}
Some("clear") => {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
}
}
fn print_json(v: &serde_json::Value) -> Result<(), String> {
println!("{}", serde_json::to_string_pretty(v).map_err(|e| e.to_string())?);
Ok(())
}
/// Run a report's commands on its model and say whether the crash happens again. Files the
/// commands write go to a scratch folder, never over the user's files.
fn replay(dir: &Path, id: &str) -> Result<(), String> {
let report = crash::read(dir, id)?;
let scratch = std::env::temp_dir().join(format!("fieldcraft-replay-{}", std::process::id()));
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
if report["kind"] == "unclean_exit" {
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
}
println!("replaying on {}", report["model_path"].as_str().unwrap_or("a new model"));
let r = crash::replay(&report, &scratch);
let _ = std::fs::remove_dir_all(&scratch);
let r = r?;
for (i, (cmd, outcome)) in r.steps.iter().enumerate() {
match outcome {
Ok(()) => println!(" {:>3}. {cmd}: ok", i + 1),
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
}
}
let said = |k: &str| report[k].as_str().unwrap_or_default().to_string();
if r.reproduced {
println!("reproduced: the same internal error ({}) at {}", said("message"), said("location"));
} else if let Some((l, m)) = r.panics.first() {
println!("an internal error happened, but not the reported one: {m} at {l}");
println!("(the report says: {} at {})", said("message"), said("location"));
} else {
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
}
Ok(())
}
+15
View File
@@ -2,10 +2,15 @@
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
//! fieldcraft-cli update --check | --install
//! fieldcraft-cli --version
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod crashes;
mod update;
use std::path::Path;
use std::process::ExitCode;
@@ -28,6 +33,14 @@ usage:
--automation-read-root DIR files may be read only inside DIR
--automation-write-root DIR files may be written only inside DIR (with either root,
the access a root left out is not granted)
fieldcraft-cli crashes [list|show <id>|replay <id>|dir|clear [<id>]]
the crash reports the desktop app wrote on this
computer (docs/crash-reports.md): list them (--json),
show one, replay one (does the crash happen again?),
print their folder, delete them
fieldcraft-cli update --check is a newer FieldCraft published? (app.update.check; JSON)
fieldcraft-cli update --install download the latest release, verify its signature and
checksum, and install it over this copy (docs/updates.md)
fieldcraft-cli --version print the version
";
@@ -52,6 +65,8 @@ fn main() -> ExitCode {
Ok(())
}
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
Some("update") => update::run(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
+56
View File
@@ -0,0 +1,56 @@
//! `fieldcraft-cli update --check | --install` (docs/updates.md): the same check as the app's
//! Help › Check for Updates (`app.update.check`), and installing the latest release over this
//! copy of the CLI's install, verified against the release key first. `FIELDCRAFT_UPDATE_HOST`
//! points both at a local test server.
use fieldcraft_engine::Session;
use fieldcraft_update::install::{self, Finish};
use fieldcraft_update::net::Client;
use fieldcraft_update::stage::{self, Options};
use serde_json::json;
pub const USAGE: &str = "usage: fieldcraft-cli update --check | --install";
pub fn run(args: &[String]) -> Result<(), String> {
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["--check"] => check(),
["--install"] => install(),
_ => Err(USAGE.into()),
}
}
fn check() -> Result<(), String> {
fieldcraft_engine::enable_update_checks(true);
let report = Session::default().execute("app.update.check", &json!({})).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
Ok(())
}
fn install() -> Result<(), String> {
let client = Client::from_env();
let check = client.check().map_err(|e| e.to_string())?;
if !check.newer() {
println!("FieldCraft {} is up to date (the latest release is {}).", check.current, check.latest.version);
return Ok(());
}
let here = install::here();
let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates"));
eprintln!("Downloading FieldCraft {} for {}…", check.latest.version, here.describe());
let progress = |done: u64, total: u64| {
if total > 0 && done == total {
eprintln!("Downloaded {} MB; verifying…", total / (1024 * 1024));
}
};
let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress };
let staged = stage::stage(&client, &check.latest, &here, &options).map_err(|e| e.to_string())?;
match install::plan(&staged, false).map_err(|e| e.to_string())? {
Finish::Swap(plan) => {
stage::apply(&plan).map_err(|e| e.to_string())?;
println!("Installed FieldCraft {} ({}). A running FieldCraft app uses it from its next start.", staged.version, here.describe());
}
Finish::Package { file, command } => {
println!("Downloaded and verified {}.\nInstall it with the system's package manager:\n {command}", file.display());
}
}
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
//! `fieldcraft-cli crashes` on a crash folder of its own (`FIELDCRAFT_CRASH_DIR`).
use std::path::{Path, PathBuf};
use std::process::Command;
use fieldcraft_engine::Session;
use serde_json::{Value, json};
const ID: &str = "20261014-101500-00000000deadbeef";
/// A replayable report: a model with two nodes and a replay that adds a third.
fn folder() -> PathBuf {
let d = std::env::temp_dir().join(format!("fieldcraft-cli-crashes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let r = d.join(ID);
std::fs::create_dir_all(&r).unwrap();
let mut s = Session::default();
for l in 1..=2 {
s.execute("mesh.node.add", &json!({"label": l, "xyz": [l, 0, 0]})).unwrap();
}
std::fs::write(r.join("model.fieldcraft"), s.file_bytes(None).unwrap()).unwrap();
let replay = json!({"commands": [
{"command": "mesh.node.add", "params": {"label": 3, "xyz": [3, 0, 0]}},
{"command": "file.save", "params": {"path": d.join("overwritten.fieldcraft")}},
]});
std::fs::write(r.join("replay.json"), replay.to_string()).unwrap();
let report = json!({
"format": "fieldcraft-crash/1", "id": ID, "kind": "unclean_exit", "signature": "00000000deadbeef", "count": 2,
"first_seen": "2026-10-14T10:15:00Z", "message": "FieldCraft ended without a clean exit while running `mesh.node.add`",
"location": "unclean exit in mesh.node.add", "command": {"command": "mesh.node.add", "params": {}},
"app": {"version": "9.9.9"}, "backtrace": [],
});
std::fs::write(r.join("report.json"), report.to_string()).unwrap();
d
}
fn cli(dir: &Path, args: &[&str]) -> (String, bool) {
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_CRASH_DIR", dir).output().unwrap();
(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)), out.status.success())
}
#[test]
fn crashes_list_show_replay_and_clear() {
let d = folder();
let (out, ok) = cli(&d, &["crashes"]);
assert!(ok, "{out}");
assert!(out.contains(ID) && out.contains("unclean_exit ×2 in mesh.node.add") && out.contains("[replayable]"), "{out}");
let (out, ok) = cli(&d, &["crashes", "list", "--json"]);
assert!(ok);
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["reports"][0]["id"], ID);
let (out, ok) = cli(&d, &["crashes", "show", ID]);
assert!(ok);
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v["model_path"].as_str().unwrap().ends_with("model.fieldcraft"));
let (out, ok) = cli(&d, &["crashes", "replay", ID]);
assert!(ok, "{out}");
assert!(out.contains("1. mesh.node.add: ok"), "{out}");
// A command that writes a file is refused outside the scratch folder: the replay never
// writes over the user's files.
assert!(out.contains("2. file.save: ") && out.contains("outside the write root"), "{out}");
assert!(!d.join("overwritten.fieldcraft").exists());
assert!(out.contains("did not reproduce"), "{out}");
let (out, ok) = cli(&d, &["crashes", "show", "../etc"]);
assert!(!ok && out.contains("no crash report"), "{out}");
let (out, ok) = cli(&d, &["crashes", "dir"]);
assert!(ok && out.trim() == d.to_string_lossy(), "{out}");
let (out, ok) = cli(&d, &["crashes", "clear"]);
assert!(ok && out.contains("deleted 1 report(s)"), "{out}");
let (out, _) = cli(&d, &["crashes"]);
assert!(out.contains("(none)"), "{out}");
let (out, ok) = cli(&d, &["crashes", "nope"]);
assert!(!ok && out.contains("usage"), "{out}");
let _ = std::fs::remove_dir_all(&d);
}
+36
View File
@@ -0,0 +1,36 @@
//! `fieldcraft-cli update --check` against a local release server (FIELDCRAFT_UPDATE_HOST).
use std::process::Command;
use fieldcraft_update::mock::Server;
use serde_json::{Value, json};
fn cli(host: &str, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_UPDATE_HOST", host).output().unwrap()
}
#[test]
fn update_check_reports_the_latest_release() {
let server = Server::start().unwrap();
server.release("latest", &json!({"tag_name": "v99.0.0", "html_url": "https://github.com/jyviko/fieldcraft/releases/tag/v99.0.0", "assets": []}));
let out = cli(&server.url(), &["update", "--check"]);
assert!(out.status.success(), "{out:?}");
let report: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["current"], env!("CARGO_PKG_VERSION"));
assert_eq!((report["latest"].as_str(), report["newer"].as_bool(), report["signed"].as_bool()), (Some("99.0.0"), Some(true), Some(false)));
// A test build is not a release package: it checks but would not install.
assert_eq!(report["asset"], Value::Null);
assert_eq!(server.requests(), 1);
// The same version: not newer, and --install has nothing to do.
server.release("latest", &json!({"tag_name": format!("v{}", env!("CARGO_PKG_VERSION")), "assets": []}));
let out = cli(&server.url(), &["update", "--check"]);
assert_eq!(serde_json::from_slice::<Value>(&out.stdout).unwrap()["newer"], false);
let out = cli(&server.url(), &["update", "--install"]);
assert!(out.status.success() && String::from_utf8_lossy(&out.stdout).contains("is up to date"), "{out:?}");
// Unreachable: an error, not a crash.
let out = cli("http://127.0.0.1:1", &["update", "--check"]);
assert!(!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("could not reach"), "{out:?}");
assert!(!cli(&server.url(), &["update"]).status.success());
}
+1 -1
View File
@@ -29,6 +29,6 @@ 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"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "RequestCache", "Response"] }
# Autosaves kept in the browser's local storage, which holds text.
base64 = "0.22"
+75
View File
@@ -0,0 +1,75 @@
//! A new deployment of the web app: every half hour the page's own address is fetched past the browser's cache, and when it loads another build
//! of the app than the one running (Trunk names each build's files by their content hash), the
//! status bar offers "Reload to update" (docs/updates.md). Reloading asks first when the model
//! has unsaved changes, as closing the page does.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
/// How often the page looks for a new deployment (seconds).
pub const INTERVAL: f64 = 30.0 * 60.0;
/// Watches for a new deployment.
pub struct Watch {
/// The build this page runs.
running: Option<String>,
/// A fetch found another build.
found: Rc<Cell<bool>>,
/// App time of the last look.
pub looked_at: f64,
}
impl Watch {
pub fn start() -> Watch {
let html = web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element()).map(|e| e.outer_html()).unwrap_or_default();
let running = crate::build_in_page(&html).map(str::to_string);
if running.is_none() {
log::info!("deploy: no hashed build in the page (a development server?); not watching");
}
Watch { running, found: Rc::default(), looked_at: 0.0 }
}
/// Whether a newer deployment was found.
pub fn found(&self) -> bool {
self.found.get()
}
/// Fetch the page again and compare its build with the running one.
pub fn look(&self) {
let (Some(running), Some(window)) = (self.running.clone(), web_sys::window()) else { return };
if self.found.get() {
return;
}
let url = window.location().pathname().unwrap_or_else(|_| "./".into());
let init = web_sys::RequestInit::new();
init.set_cache(web_sys::RequestCache::NoStore);
let promise = window.fetch_with_str_and_init(&url, &init);
let found = self.found.clone();
wasm_bindgen_futures::spawn_local(async move {
let text = async {
let response: web_sys::Response = wasm_bindgen_futures::JsFuture::from(promise).await.ok()?.dyn_into().ok()?;
if !response.ok() {
return None;
}
wasm_bindgen_futures::JsFuture::from(response.text().ok()?).await.ok()?.as_string()
};
// Offline or refused: look again next time.
if let Some(html) = text.await
&& let Some(deployed) = crate::build_in_page(&html)
&& deployed != running
{
log::info!("deploy: {deployed} is deployed (running {running})");
found.set(true);
}
});
}
}
/// Reload the page (the new deployment loads).
pub fn reload() {
if let Some(w) = web_sys::window() {
let _ = w.location().reload();
}
}
+37 -1
View File
@@ -8,7 +8,8 @@
//! - 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`]);
//! - autosaves for recovery and the preferences are kept in the browser's local storage
//! ([`storage`], [`usage`]), and anonymous usage (when agreed) goes out with `fetch`;
//! - 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.
@@ -17,9 +18,13 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod deploy;
#[cfg(target_arch = "wasm32")]
mod storage;
#[cfg(target_arch = "wasm32")]
mod usage;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(target_arch = "wasm32")]
@@ -42,6 +47,25 @@ fn sample_in_query(query: &str) -> Option<String> {
})
}
/// The build of the app a page loads: the content-hashed name Trunk gives its WebAssembly
/// (`fieldcraft-web-<hash>_bg.wasm`). A new deployment loads another one.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn build_in_page(html: &str) -> Option<&str> {
const HEAD: &str = "fieldcraft-web-";
const TAIL: &str = "_bg.wasm";
let mut rest = html;
while let Some(at) = rest.find(HEAD) {
let candidate = rest.get(at..)?;
let hash = candidate.get(HEAD.len()..)?;
let len = hash.bytes().take_while(u8::is_ascii_alphanumeric).count();
if len > 0 && hash.get(len..)?.starts_with(TAIL) {
return candidate.get(..HEAD.len() + len + TAIL.len());
}
rest = candidate.get(HEAD.len()..)?;
}
None
}
/// 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))]
@@ -64,6 +88,18 @@ mod tests {
assert_eq!(sample_in_query("?samples=x"), None);
}
#[test]
fn the_running_build_is_found_in_the_page() {
let page = r#"<link rel="modulepreload" href="/fieldcraft/fieldcraft-web-1a2b3c.js"><link rel="preload" href="/fieldcraft/fieldcraft-web-1a2b3c_bg.wasm" as="fetch">"#;
assert_eq!(build_in_page(page), Some("fieldcraft-web-1a2b3c_bg.wasm"));
let next = page.replace("1a2b3c", "9f8e7d");
assert_ne!(build_in_page(page), build_in_page(&next));
// A development page (no hash) and other text: nothing to compare.
assert_eq!(build_in_page(r#"<script src="fieldcraft-web.js"></script>"#), None);
assert_eq!(build_in_page("fieldcraft-web-"), None);
assert_eq!(build_in_page(""), None);
}
#[test]
fn file_names_stay_in_their_folder() {
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
+86
View File
@@ -0,0 +1,86 @@
//! Anonymous usage in the browser (docs/privacy.md): the same events and the same consent as the
//! desktop app, the choice kept in the browser's local storage with the other preferences. Queued
//! events are posted with `fetch` every half minute and when the page is closed or left (`keepalive`, so
//! a closing page still sends them); a failed post is dropped silently.
//!
//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`: a build without one asks nothing
//! and sends nothing.
use fieldcraft_engine::usage;
use fieldcraft_ui_egui::files::PrefsStore;
use fieldcraft_ui_egui::privacy::UsageHost;
use wasm_bindgen::JsCast as _;
const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY");
/// Where the preferences are kept in local storage.
const PREFS_KEY: &str = "fieldcraft-prefs";
/// How often queued events are sent (seconds).
pub const INTERVAL: f64 = 30.0;
fn key() -> Option<&'static str> {
KEY.map(str::trim).filter(|k| !k.is_empty())
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok().flatten()
}
/// The preferences in local storage (navigation, privacy).
pub fn prefs_store() -> PrefsStore {
PrefsStore {
load: Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten().map(String::into_bytes)),
save: Box::new(|bytes| {
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
local_storage().ok_or("browser storage is turned off")?.set_item(PREFS_KEY, text).map_err(crate::web::js_err)
}),
}
}
/// What the window needs to ask and record, when the build has a key.
pub fn host() -> Option<UsageHost> {
// The browser's clock for command durations (the plain wasm target has none).
usage::set_clock(js_sys::Date::now);
key().is_some().then(|| UsageHost {
platform: "web",
os: "web",
arch: "wasm32",
new_id: Box::new(|| web_sys::window()?.crypto().ok().map(|c| c.random_uuid())),
})
}
/// Post what is queued.
pub fn send() {
let (Some(key), Some(window)) = (key(), web_sys::window()) else { return };
let url = format!("{}/batch/", usage::DEFAULT_HOST);
while let Some(batch) = usage::take_batch() {
let init = web_sys::RequestInit::new();
init.set_method("POST");
init.set_body(&wasm_bindgen::JsValue::from_str(&batch.body(key)));
// The Fetch standard's `keepalive` (a closing page still sends it); web-sys has no setter.
let _ = js_sys::Reflect::set(&init, &"keepalive".into(), &true.into());
let headers = web_sys::Headers::new().ok();
if let Some(h) = &headers {
let _ = h.set("Content-Type", "application/json");
init.set_headers(h);
}
let promise = window.fetch_with_str_and_init(&url, &init);
wasm_bindgen_futures::spawn_local(async move {
// Offline or refused: the batch is dropped.
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
log::debug!("usage: not sent: {}", crate::web::js_err(e));
}
});
}
}
/// When the page is closed or left: the session's length, then send.
pub fn send_when_hidden(window: &web_sys::Window) {
let on_hide = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(|| {
usage::closing();
send();
});
if let Err(e) = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref()) {
log::warn!("usage: {}", crate::web::js_err(e));
}
on_hide.forget();
}
+27 -3
View File
@@ -74,6 +74,7 @@ pub fn start() {
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);
crate::usage::send_when_hidden(&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;
@@ -86,6 +87,10 @@ pub fn start() {
Box::new(|cc| {
let inbox = Inbox::default();
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
if let Some(host) = crate::usage::host() {
app = app.with_usage(host);
}
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);
@@ -102,7 +107,8 @@ pub fn start() {
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 }))
let deploy = crate::deploy::Watch::start();
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false, sent_at: 0.0, deploy }))
}),
)
.await;
@@ -188,8 +194,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
// The recent list holds the page's files, which go with the page.
recent_path: None,
// Preferences last for the page's run.
prefs_path: None,
// Preferences are kept in the browser's local storage.
prefs: Some(crate::usage::prefs_store()),
// egui opens links itself in the browser.
open_url: None,
}
}
@@ -204,6 +212,10 @@ struct Shell {
autosaved_at: f64,
/// The last autosave did not fit in the browser's storage (said once until one fits).
storage_error: bool,
/// App time usage events were last sent.
sent_at: f64,
/// Looks for a new deployment of the app.
deploy: crate::deploy::Watch,
}
impl Shell {
@@ -269,7 +281,19 @@ impl eframe::App for Shell {
self.open_arrived();
self.app.logic(ctx);
self.autosave(ctx);
if self.app.now - self.sent_at >= crate::usage::INTERVAL {
self.sent_at = self.app.now;
crate::usage::send();
}
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
if self.app.now - self.deploy.looked_at >= crate::deploy::INTERVAL || self.app.now < self.deploy.looked_at {
self.deploy.looked_at = self.app.now;
self.deploy.look();
}
if self.deploy.found() && !self.app.reload_offered() {
self.app.offer_reload(Box::new(crate::deploy::reload));
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64(crate::deploy::INTERVAL));
}
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
+6
View File
@@ -13,11 +13,17 @@ workspace = true
fieldcraft-engine = { workspace = true }
fieldcraft-ui-egui = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-update = { workspace = true }
rfd = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Usage analytics (docs/privacy.md): an HTTPS POST with rustls on ring, checked against the
# operating system's certificates (no bundled root list), and a random install id.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
getrandom = "0.3"
[build-dependencies]
# The Windows executable's icon resource (build.rs).
+89 -6
View File
@@ -12,9 +12,11 @@
#![forbid(unsafe_code)]
mod control_server;
mod update;
mod usage;
use fieldcraft_engine::Session;
use fieldcraft_engine::autosave::{self, Autosaver};
use fieldcraft_engine::{Session, crash, logfile};
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
const USAGE: &str =
@@ -47,6 +49,10 @@ struct App {
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
/// The first frame was drawn (the crash journal says the app is up).
running: bool,
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
usage: Option<usage::Sender>,
}
impl App {
@@ -61,6 +67,10 @@ impl App {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.running {
self.running = true;
crash::activity("running");
}
self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
self.autosaved_at = self.app.now;
@@ -76,6 +86,16 @@ impl eframe::App for App {
{
log::warn!("autosave: {e}");
}
self.app.usage_closing();
if let Some(u) = self.usage.take() {
u.finish();
}
// An update the user chose to install: swapped in now that the window has closed.
if let Some(staged) = self.app.take_staged_update() {
update::finish(&staged);
}
log::info!("FieldCraft closed");
crash::end_session();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
@@ -95,10 +115,57 @@ fn services() -> Services {
})),
// Beside the recovery folder in the per-user data folder.
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))),
prefs: fieldcraft_io::recovery::default_dir()
.and_then(|d| d.parent().map(|p| fieldcraft_ui_egui::files::PrefsStore::file(p.join("prefs.json")))),
open_url: Some(Box::new(open_url)),
}
}
/// Open a web link in the default browser, with the system's own opener: `open` (macOS),
/// `xdg-open` (Linux and the BSDs), `explorer` (Windows). The link is one argument, never
/// passed through a shell. Only https links are opened.
fn open_url(url: &str) -> Result<(), String> {
if !url.starts_with("https://") {
return Err(format!("not a web link: {}", url.chars().take(80).collect::<String>()));
}
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let mut child = std::process::Command::new(opener).arg(url).spawn().map_err(|e| format!("{opener}: {e}"))?;
// Reaped off the window thread; its exit status says nothing (`explorer` exits with 1 even
// when it opened the link).
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
/// Crash reports and the log file (docs/crash-reports.md): reports of earlier runs that crashed
/// come back, for the window to offer. Both live in the app's data folder (a temporary one in
/// runs driven by automation); `FIELDCRAFT_CRASH_DIR` moves the crash folder.
fn start_reporting() -> Vec<crash::Summary> {
// First the crash reports: an unclean exit's report takes the dead run's lines from the log.
let crashed = crash::default_dir().map(|dir| crash::install(dir, crash::Build::current())).unwrap_or_default();
match fieldcraft_io::recovery::data_dir().map(|d| logfile::install(&d.join("logs"), log::LevelFilter::Info)) {
Some(Ok(path)) => eprintln!("fieldcraft: log {}", path.display()),
Some(Err(e)) => eprintln!("fieldcraft: no log file: {e}"),
None => {}
}
let b = crash::Build::current();
log::info!(
"FieldCraft {} started ({}), crash reports in {}",
b.version,
b.version_line(),
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
);
crash::activity("startup");
crashed
}
/// Command-line options.
#[derive(Debug, Default, PartialEq)]
struct Args {
@@ -152,9 +219,10 @@ fn main() -> eframe::Result {
};
if args.control.is_some() {
// A run driven by automation keeps its autosaves (and recent files and preferences, which
// live beside them) in a temporary folder, never the user's.
// live beside them), its crash reports and its log in a temporary folder, never the user's.
fieldcraft_io::recovery::use_automation_dir();
}
let crashed = start_reporting();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
@@ -177,12 +245,26 @@ fn main() -> eframe::Result {
"FieldCraft",
options,
Box::new(move |cc| {
let mut app = ModelApp::new(Session::default()).with_services(services());
let mut session = Session::default();
// The window's model: its commands go into the crash journal.
session.crash_journal = crash::enabled();
let mut app = ModelApp::new(session).with_services(services());
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
let usage = match usage::host(args.control.is_some()) {
Some(host) => {
app = app.with_usage(host);
usage::start()
}
None => None,
};
// Updates (docs/updates.md): checked at most daily when on, installed only on a click.
fieldcraft_engine::enable_update_checks(true);
app = app.with_updates(update::host(cc.egui_ctx.clone(), args.control.is_some()));
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info();
eprintln!("fieldcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
}
if let Some(port) = args.control {
@@ -202,7 +284,8 @@ fn main() -> eframe::Result {
}
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
});
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 }))
app.announce_crashes(&crashed);
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage }))
}),
)
}
+105
View File
@@ -0,0 +1,105 @@
//! The desktop app's side of updates (docs/updates.md): it runs the window's update jobs
//! ([`fieldcraft_ui_egui::updates`]) on background threads with [`fieldcraft_update`], and swaps
//! a staged release in after the window has closed ([`finish`]).
//!
//! Automatic checks are off in runs driven by automation (`--control`) and under CI, so test
//! and agent runs never reach GitHub on their own; `FIELDCRAFT_UPDATE_HOST` (a local test
//! server) turns them back on. Help › Check for Updates works in every run.
use std::cell::Cell;
use std::path::Path;
use std::sync::mpsc;
use fieldcraft_ui_egui::updates::{Event, Job, UpdateHost};
use fieldcraft_update::install::{self, Finish, Install};
use fieldcraft_update::net::{Client, Which};
use fieldcraft_update::stage::{self, Options};
use fieldcraft_update::{Release, Staged};
/// What the window needs for updates.
pub fn host(ctx: egui::Context, automation: bool) -> UpdateHost {
let here = install::here();
log::info!("updates: {}", here.describe());
stage::clean_stale(&here);
let env = |k: &str| std::env::var_os(k).is_some_and(|v| !v.is_empty());
let automatic = env(fieldcraft_update::HOST_ENV) || !(automation || env("CI"));
let (tx, events) = mpsc::channel::<Event>();
let unsupported = here.unsupported();
let start = move |job: Job| {
let (tx, ctx, here) = (tx.clone(), ctx.clone(), here.clone());
let spawned = std::thread::Builder::new().name("fieldcraft update".into()).spawn(move || {
let send = |e: Event| {
let _ = tx.send(e);
ctx.request_repaint();
};
let client = Client::from_env();
match job {
Job::Check { manual } => send(Event::Checked { manual, result: client.check().map_err(|e| e.to_string()) }),
Job::Stage(release) => send(Event::Staged(stage_release(&client, &release, &here, &send))),
Job::Reinstall => {
let staged = client
.release(&Which::Version(fieldcraft_update::current()))
.map_err(|e| e.to_string())
.and_then(|r| stage_release(&client, &r, &here, &send));
send(Event::Staged(staged));
}
}
});
if let Err(e) = spawned {
log::warn!("updates: no thread: {e}");
}
};
UpdateHost { unsupported, automatic, start: Box::new(start), events, now: Box::new(now), open_file: Box::new(open_file) }
}
/// Download, verify and stage `release`, reporting each whole percent.
fn stage_release(client: &Client, release: &Release, here: &Install, send: &dyn Fn(Event)) -> Result<Staged, String> {
let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates"));
let last = Cell::new(u64::MAX);
let progress = |done: u64, total: u64| {
let step = done.saturating_mul(100).checked_div(total).unwrap_or(done / (1024 * 1024));
if last.replace(step) != step {
send(Event::Progress(done, total));
}
};
let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress };
let staged = stage::stage(client, release, here, &options).map_err(|e| e.to_string());
match &staged {
Ok(s) => log::info!("updates: FieldCraft {} verified and staged at {}", s.version, s.payload.display()),
Err(e) => log::warn!("updates: FieldCraft {} not staged: {e}", release.version),
}
staged
}
/// Swap `staged` in and start it: the window has closed, its autosave is closed.
pub fn finish(staged: &Staged) {
match install::plan(staged, true) {
Ok(Finish::Swap(plan)) => match stage::apply(&plan) {
Ok(()) => log::info!("updates: FieldCraft {} installed ({})", staged.version, staged.install.describe()),
Err(e) => log::error!("updates: FieldCraft {} not installed, the previous version is kept: {e}", staged.version),
},
Ok(Finish::Package { .. }) => {}
Err(e) => log::error!("updates: {e}"),
}
}
/// Seconds since the Unix epoch.
fn now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Open a downloaded package with the system's handler for it (Linux: the software installer).
fn open_file(path: &Path) -> Result<(), String> {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let mut child = std::process::Command::new(opener).arg(path).spawn().map_err(|e| format!("{opener}: {e}"))?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
+116
View File
@@ -0,0 +1,116 @@
//! Sending the anonymous usage events (docs/privacy.md) to PostHog Cloud EU: a background
//! thread posts the queued events ([`fieldcraft_engine::usage`]) in batches every half minute and
//! when the window closes. A failed post (offline, a slow network) drops its batch silently;
//! nothing waits on it but the thread.
//!
//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`, which only the release workflow
//! sets: local, development and fork builds have none and send nothing.
use std::sync::mpsc;
use std::time::Duration;
use fieldcraft_engine::usage;
use fieldcraft_ui_egui::privacy::UsageHost;
/// The PostHog project key the release build was made with.
const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY");
/// How often queued events are sent.
const INTERVAL: Duration = Duration::from_secs(30);
/// The longest one post may take.
const TIMEOUT: Duration = Duration::from_secs(10);
/// How long closing the window waits for the last batch.
const LAST_WAIT: Duration = Duration::from_secs(2);
fn key() -> Option<&'static str> {
KEY.map(str::trim).filter(|k| !k.is_empty())
}
/// What the window needs to ask and record, when this run may share usage: the build has a key,
/// `FIELDCRAFT_TELEMETRY` is not `0`, and neither CI nor `--control` automation runs it (unless
/// `FIELDCRAFT_TELEMETRY=1`).
pub fn host(automation: bool) -> Option<UsageHost> {
let telemetry = std::env::var("FIELDCRAFT_TELEMETRY").ok();
let ci = std::env::var_os("CI").is_some_and(|v| !v.is_empty());
usage::allowed(key().is_some(), telemetry.as_deref(), automation, ci).then(|| UsageHost {
platform: "desktop",
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
new_id: Box::new(new_id),
})
}
/// A random (version 4) UUID from the operating system's random source.
fn new_id() -> Option<String> {
let mut b = [0u8; 16];
getrandom::fill(&mut b).ok()?;
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
Some(format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]))
}
/// The sending thread; [`Sender::finish`] sends what is left and stops it.
pub struct Sender {
stop: mpsc::Sender<()>,
finished: mpsc::Receiver<()>,
}
/// Start the sending thread (only when the build has a key). `FIELDCRAFT_TELEMETRY_HOST`
/// replaces PostHog's host, for checking the payload against a local listener.
pub fn start() -> Option<Sender> {
let key = key()?;
let host = std::env::var("FIELDCRAFT_TELEMETRY_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| usage::DEFAULT_HOST.into());
let url = format!("{}/batch/", host.trim_end_matches('/'));
// rustls's process-wide crypto (ring); another part of the app may have installed it first.
let _ = rustls::crypto::ring::default_provider().install_default();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
.build()
.into();
let (stop, stopped) = mpsc::channel::<()>();
let (done, finished) = mpsc::channel::<()>();
let spawned = std::thread::Builder::new().name("fieldcraft usage".into()).spawn(move || {
loop {
let last = !matches!(stopped.recv_timeout(INTERVAL), Err(mpsc::RecvTimeoutError::Timeout));
while let Some(batch) = usage::take_batch() {
if let Err(e) = agent.post(&url).header("Content-Type", "application/json").send(batch.body(key)) {
// Offline or refused: this batch is dropped, the rest waits for the next try.
log::debug!("usage: {} event(s) not sent: {e}", batch.events.len());
break;
}
}
if last {
let _ = done.send(());
return;
}
}
});
match spawned {
Ok(_) => Some(Sender { stop, finished }),
Err(e) => {
log::warn!("usage: no sending thread: {e}");
None
}
}
}
impl Sender {
/// Send what is queued (waiting briefly) and stop.
pub fn finish(self) {
let _ = self.stop.send(());
let _ = self.finished.recv_timeout(LAST_WAIT);
}
}
#[cfg(test)]
mod tests {
#[test]
fn install_ids_are_random_version_4_uuids() {
let (a, b) = (super::new_id().unwrap(), super::new_id().unwrap());
assert_ne!(a, b);
assert_eq!(a.len(), 36);
assert_eq!(a.as_bytes()[14], b'4');
assert!(matches!(a.as_bytes()[19], b'8' | b'9' | b'a' | b'b'), "{a}");
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>diagnostics.bug_report</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M12 8.5 L12 8.5 C14.209 8.5 16 10.291 16 12.5 L16 15.5 C16 17.709 14.209 19.5 12 19.5 L12 19.5 C9.791 19.5 8 17.709 8 15.5 L8 12.5 C8 10.291 9.791 8.5 12 8.5 Z" fill="none" stroke="#dde2ea" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.9 5.8 C9.9 4.64 10.84 3.7 12 3.7 C13.16 3.7 14.1 4.64 14.1 5.8 C14.1 6.96 13.16 7.9 12 7.9 C10.84 7.9 9.9 6.96 9.9 5.8 Z" fill="none" stroke="#dde2ea" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 10.5 L12 18" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11.5 L4.8 9.8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 14.5 L4.4 14.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.3 17.4 L5 19.4" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 11.5 L19.2 9.8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 14.5 L19.6 14.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.7 17.4 L19 19.4" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.datum.axis</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M7 17 L19 5" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 4 L15 5.2 M20 4 L18.8 9" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 18.5 L2.5 21.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
<path d="M8.8 12 C8.8 10.233 10.233 8.8 12 8.8 C13.767 8.8 15.2 10.233 15.2 12 C15.2 13.767 13.767 15.2 12 15.2 C10.233 15.2 8.8 13.767 8.8 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6 12 C10.6 11.227 11.227 10.6 12 10.6 C12.773 10.6 13.4 11.227 13.4 12 C13.4 12.773 12.773 13.4 12 13.4 C11.227 13.4 10.6 12.773 10.6 12 Z" fill="#f0b44a"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.datum.point</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 16 L9 10 L21 10 L15 16 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 11 M8 7 L16 7" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.2 7 C10.2 6.006 11.006 5.2 12 5.2 C12.994 5.2 13.8 6.006 13.8 7 C13.8 7.994 12.994 8.8 12 8.8 C11.006 8.8 10.2 7.994 10.2 7 Z" fill="#f0b44a"/>
<path d="M12 12.5 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.
@@ -0,0 +1 @@
{"shapes": [{"rect": [8, 8.5, 8, 11], "radius": 4, "stroke": "line", "width": 1.6}, {"circle": [12, 5.8, 2.1], "stroke": "line", "width": 1.6}, {"line": [12, 10.5, 12, 18], "stroke": "accent", "width": 1.5}, {"line": [8, 11.5, 4.8, 9.8], "stroke": "line"}, {"line": [8, 14.5, 4.4, 14.5], "stroke": "line"}, {"line": [8.3, 17.4, 5, 19.4], "stroke": "line"}, {"line": [16, 11.5, 19.2, 9.8], "stroke": "line"}, {"line": [16, 14.5, 19.6, 14.5], "stroke": "line"}, {"line": [15.7, 17.4, 19, 19.4], "stroke": "line"}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M7 17 L19 5", "stroke": "accent", "width": 2}, {"d": "M20 4 L15 5.2 M20 4 L18.8 9", "stroke": "accent", "width": 2}, {"d": "M5.5 18.5 L2.5 21.5", "stroke": "line", "dash": [1.5, 2]}, {"circle": [12, 12, 3.2], "fill": "soft", "stroke": "line", "width": 1.25}, {"circle": [12, 12, 1.4], "fill": "warm", "stroke": null}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 16 L9 10 L21 10 L15 16 Z", "fill": "soft", "stroke": "line", "width": 1.25}, {"d": "M12 3 L12 11 M8 7 L16 7", "stroke": "accent", "width": 2}, {"circle": [12, 7, 1.8], "fill": "warm", "stroke": null}, {"d": "M12 12.5 L12 20", "stroke": "line", "dash": [1.5, 2]}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M2.5 13 C5.5 7.5 18.5 7.5 21.5 13 C18.5 18.5 5.5 18.5 2.5 13 Z", "fill": "soft", "stroke": "line"}, {"circle": [12, 13, 2.6], "fill": "accent", "stroke": "accent", "width": 1}, {"d": "M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5", "stroke": "warm", "width": 1.5}]}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>view.show_all</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M2.5 13 C5.5 7.5 18.5 7.5 21.5 13 C18.5 18.5 5.5 18.5 2.5 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.4 13 C9.4 11.564 10.564 10.4 12 10.4 C13.436 10.4 14.6 11.564 14.6 13 C14.6 14.436 13.436 15.6 12 15.6 C10.564 15.6 9.4 14.436 9.4 13 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.
+2
View File
@@ -25,6 +25,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# app.update.check (docs/updates.md); the browser build has no update check.
fieldcraft-update = { workspace = true }
[dev-dependencies]
png = { workspace = true }
+1
View File
@@ -47,6 +47,7 @@ impl Autosaver {
let bytes = s.file_bytes(None)?;
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
self.saved = Some(s.revision());
crate::crash::autosaved(s, &self.slot.file());
Ok(true)
}
+393
View File
@@ -0,0 +1,393 @@
//! Bug report drafts (Help › Report a Bug, and Report it after a crash): a GitHub new-issue link
//! that fills in the bug form (`.github/ISSUE_TEMPLATE/bug.yml`), and the files to attach.
//!
//! Nothing here sends anything. The user reviews the draft, then their browser opens the issue
//! form, which they submit or not.
//!
//! What a draft holds by default: the version, the OS, what went wrong (the user's words, the
//! last error or the crash's message) and, for a crash, where it happened. The user's home
//! folder is shown as `~`. Only with **Share my session** does it also carry the commands that
//! built the model (the replayable journal, which can name the user's files), and only with
//! **Include the model** as well the model file. A session too long for the link (GitHub
//! refuses long new-issue links) goes out as a file to attach and on the clipboard instead; the
//! model file is always one to attach.
use serde_json::{Value, json};
use crate::crash::without_home;
/// The issue form.
pub const ISSUE_URL: &str = "https://github.com/jyviko/fieldcraft/issues/new";
/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
pub const MAX_URL: usize = 7000;
/// The session file a draft attaches.
pub const SESSION_FILE: &str = "fieldcraft-session.json";
/// The model file a draft attaches.
pub const MODEL_FILE: &str = "model.fieldcraft";
/// Backtrace frames a crash's draft quotes.
const FRAMES: usize = 12;
/// A field's text is cut at this many bytes.
const MAX_FIELD: usize = 3000;
/// What to put in a draft.
#[derive(Clone, Debug, Default)]
pub struct Input {
pub title: String,
/// What happened: the user's words, or the last error.
pub observed: String,
/// A crash report (`crash::read`), when reporting one.
pub crash: Option<Value>,
/// Attach the replayable commands.
pub share_session: bool,
/// The commands (each `{command, params}`) that rebuild the model from `model` (or from an
/// empty model when there is none).
pub commands: Vec<Value>,
/// Attach the model file too (only with `share_session`).
pub include_model: bool,
pub model: Option<Vec<u8>>,
}
/// A file the user attaches to the issue.
#[derive(Clone, Debug, PartialEq)]
pub struct Attachment {
pub name: String,
pub bytes: Vec<u8>,
/// What it is, for the review.
pub what: String,
}
/// A draft ready for review.
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub title: String,
/// The bug form's fields by id (`observed`, `reproduction`, `evidence`, `version`), as the
/// link fills them.
pub fields: Vec<(&'static str, String)>,
/// The link that opens the filled form.
pub url: String,
/// Files to attach (the session when too long for the link, the model).
pub attachments: Vec<Attachment>,
/// Text to put on the clipboard (the session, when it is an attachment).
pub clipboard: Option<String>,
}
impl Draft {
/// The draft as JSON (attachments by name, size and what they are).
pub fn to_json(&self) -> Value {
json!({
"title": self.title,
"fields": self.fields.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<serde_json::Map<String, Value>>(),
"url": self.url,
"attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::<Vec<_>>(),
"clipboard": self.clipboard.as_ref().map(String::len),
})
}
}
/// `v0.5.8 (abc1234), macos aarch64`.
pub fn version_line(version: &str, commit: &str) -> String {
let commit = if commit.is_empty() { String::new() } else { format!(" ({commit})") };
format!("v{version}{commit}, {} {}", std::env::consts::OS, std::env::consts::ARCH)
}
/// Percent-encode `s` for a query value (RFC 3986 unreserved characters stay).
pub fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(char::from(b));
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn cut(s: &str, n: usize) -> String {
if s.len() <= n {
return s.to_string();
}
let mut end = n;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n[cut: {} bytes in all]", s.get(..end).unwrap_or_default(), s.len())
}
fn link(title: &str, fields: &[(&'static str, String)]) -> String {
let mut url = format!("{ISSUE_URL}?template=bug.yml&title={}", encode(title));
for (k, v) in fields {
if !v.is_empty() {
url.push_str(&format!("&{k}={}", encode(v)));
}
}
url
}
/// What a crash report says, for the evidence field: its kind, message, place, command (its id;
/// its parameters only with the session) and the top of its backtrace.
fn crash_evidence(report: &Value, share: bool) -> String {
let s = |k: &str| report.get(k).and_then(Value::as_str).unwrap_or("?");
let mut out =
format!("Crash report `{}` ({}), seen {} time(s)\n\n", s("id"), s("kind"), report.get("count").and_then(Value::as_u64).unwrap_or(1));
out.push_str(&format!("Message: {}\nAt: {}\n", without_home(s("message")), without_home(s("location"))));
if let Some(c) = report.get("command").filter(|c| !c.is_null()) {
let id = c.get("command").and_then(Value::as_str).unwrap_or("?");
match c.get("params").filter(|_| share) {
Some(p) => out.push_str(&format!("Command: `{id}` {}\n", without_home(&p.to_string()))),
None => out.push_str(&format!("Command: `{id}`\n")),
}
}
if let Some(a) = report.get("last_activity").and_then(Value::as_str) {
out.push_str(&format!("Last activity: {a}\n"));
}
let frames: Vec<&str> = report.get("backtrace").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).take(FRAMES).collect();
if !frames.is_empty() {
out.push_str("\nBacktrace (top):\n```\n");
for f in frames {
out.push_str(&without_home(f));
out.push('\n');
}
out.push_str("```\n");
}
out
}
/// The draft for `input`, by the [`version_line`] `version`.
pub fn draft(input: &Input, version: &str) -> Draft {
let crash = input.crash.as_ref();
let mut title = input.title.trim().to_string();
if title.is_empty() {
title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
Some(_) => "Internal error".to_string(),
None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
Some(line) => format!("Bug: {}", line.chars().take(100).collect::<String>()),
None => "Bug report".to_string(),
},
};
if let Some(c) = crash.and_then(|c| c.get("command")).and_then(|c| c.get("command")).and_then(Value::as_str) {
title.push_str(&format!(" in {c}"));
}
}
let title = cut(&without_home(&title), 200);
let mut observed = without_home(input.observed.trim());
if observed.is_empty()
&& let Some(m) = crash.and_then(|c| c.get("message")).and_then(Value::as_str)
{
observed = without_home(m);
}
let observed = cut(&observed, MAX_FIELD);
let share = input.share_session;
let mut evidence = crash.map(|c| crash_evidence(c, share)).unwrap_or_default();
let mut attachments = Vec::new();
let mut clipboard = None;
let mut reproduction = String::new();
// The session: the replayable script, from the attached model when there is one.
let model = input.model.as_ref().filter(|_| share && input.include_model);
let script = share.then(|| {
let mut steps = Vec::new();
if model.is_some() {
steps.push(json!({"command": "file.open", "params": {"path": MODEL_FILE}}));
}
steps.extend(input.commands.iter().cloned());
Value::Array(steps)
});
if let Some(m) = model {
attachments.push(Attachment { name: MODEL_FILE.into(), bytes: m.clone(), what: format!("the model ({} KB)", m.len().div_ceil(1024)) });
}
if let Some(script) = &script {
let n = script.as_array().map_or(0, Vec::len);
reproduction = format!("Replay the attached session ({n} command(s)): `fieldcraft-cli run {SESSION_FILE}`");
if model.is_some() {
reproduction.push_str(&format!(" in a folder holding `{MODEL_FILE}` too"));
} else if crash.is_some_and(|c| c.get("files").and_then(|f| f.get("model")).is_some()) {
reproduction.push_str(" (the crash replays from the model, which is not attached)");
}
reproduction.push('.');
}
let inline = |evidence: &str, script: &Value| {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
format!("{evidence}\nSession ({SESSION_FILE}):\n```json\n{text}\n```\n")
};
let fields = |evidence: String| {
vec![("observed", observed.clone()), ("reproduction", reproduction.clone()), ("evidence", evidence), ("version", version.to_string())]
};
// The session in the link when it fits, else a file to attach (also on the clipboard).
let mut url = String::new();
if let Some(script) = &script {
let candidate = fields(inline(&evidence, script));
let u = link(&title, &candidate);
if u.len() <= MAX_URL {
evidence = inline(&evidence, script);
url = u;
} else {
let text = serde_json::to_string_pretty(script).unwrap_or_default();
evidence.push_str(&format!(
"\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
));
attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
clipboard = Some(text);
}
}
if model.is_some() {
evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
}
if url.is_empty() || model.is_some() {
let mut f = fields(evidence.clone());
url = link(&title, &f);
// Still too long (a very long message): cut the longest field until it fits.
while url.len() > MAX_URL {
let Some((i, len)) = f.iter().enumerate().map(|(i, (_, v))| (i, v.len())).max_by_key(|(_, l)| *l) else { break };
if len < 200 {
break;
}
if let Some((_, v)) = f.get_mut(i) {
*v = cut(v, len.saturating_sub(url.len() - MAX_URL + 64).min(len / 2 + 1));
}
url = link(&title, &f);
}
evidence = f.get(2).map(|(_, v)| v.clone()).unwrap_or_default();
}
let fields = fields(evidence);
// The fields as the link carries them (a cut field included).
let fields = if url == link(&title, &fields) { fields } else { decode_fields(&url) };
Draft { title, fields, url, attachments, clipboard }
}
/// Write a draft's attachments to `bug-reports/<time>/` in the app's data folder (the user
/// attaches them from there); returns the folder.
pub fn save_attachments(draft: &Draft) -> Result<std::path::PathBuf, String> {
let root = fieldcraft_io::recovery::data_dir().ok_or("no data folder on this system")?;
let dir = root.join("bug-reports").join(crate::crash::stamp_now());
fieldcraft_io::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for a in &draft.attachments {
fieldcraft_io::fs::write_atomic(&dir.join(&a.name), &a.bytes, false).map_err(|e| e.to_string())?;
}
Ok(dir)
}
/// The fields a link carries (for the review, when a field had to be cut).
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
let query = url.split_once('?').map_or("", |(_, q)| q);
["observed", "reproduction", "evidence", "version"]
.into_iter()
.map(|k| {
let v = query.split('&').find_map(|kv| kv.strip_prefix(k).and_then(|r| r.strip_prefix('='))).unwrap_or_default();
(k, decode(v))
})
.collect()
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (c, hex) {
(b'%', Some(v)) => {
out.push(v);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/someone".into())
}
fn crash() -> Value {
json!({
"id": "20261014-101500-00000000deadbeef", "kind": "command_panic", "count": 2,
"message": format!("index out of bounds in {}/model.fieldcraft", home()),
"location": "crates/mesh/src/tet.rs:42",
"command": {"command": "mesh.tet.generate", "params": {"body": "Plate", "path": format!("{}/secret/plate.step", home())}},
"backtrace": ["fieldcraft_mesh::tet::insert (crates/mesh/src/tet.rs:42)"],
"files": {"model": "model.fieldcraft", "replay": "replay.json"},
})
}
#[test]
fn without_sharing_a_draft_names_no_paths_commands_or_model() {
let input = Input {
observed: format!("cannot open {}/parts/bracket.solidcraft", home()),
crash: Some(crash()),
commands: vec![json!({"command": "geometry.open", "params": {"path": format!("{}/parts/bracket.solidcraft", home())}})],
model: Some(b"model bytes".to_vec()),
include_model: true,
..Default::default()
};
let d = draft(&input, "v1.2.3, testos x");
assert!(d.url.starts_with("https://github.com/jyviko/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
assert!(!all.contains(&home()), "no home folder: {all}");
assert!(!all.contains("secret") && !all.contains("geometry.open"), "no parameters or commands: {all}");
assert!(all.contains("~/parts/bracket.solidcraft"), "the error, its home shown as ~: {all}");
assert!(all.contains("mesh.tet.generate") && all.contains("tet.rs:42") && all.contains("v1.2.3, testos x"), "{all}");
assert_eq!(d.title, "Internal error in mesh.tet.generate");
assert_eq!(decode_fields(&d.url), d.fields, "the review shows what the link carries");
}
#[test]
fn a_short_session_goes_in_the_link() {
let input = Input {
title: "Solve fails".into(),
share_session: true,
commands: vec![json!({"command": "file.sample", "params": {"name": "cantilever"}}), json!({"command": "solve.run", "params": {}})],
..Default::default()
};
let d = draft(&input, "v1");
assert!(d.attachments.is_empty() && d.clipboard.is_none());
let evidence = &d.fields.iter().find(|(k, _)| *k == "evidence").unwrap().1;
assert!(evidence.contains("\"command\": \"solve.run\""), "{evidence}");
assert!(d.url.len() <= MAX_URL);
assert!(decode(&d.url).contains("file.sample"));
}
#[test]
fn a_long_session_and_the_model_are_attachments() {
let commands: Vec<Value> = (0..400).map(|i| json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": [i, 0, 0]}})).collect();
let input =
Input { share_session: true, include_model: true, commands, model: Some(vec![7; 3000]), crash: Some(crash()), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
let names: Vec<&str> = d.attachments.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, [SESSION_FILE, MODEL_FILE]);
let session: Value = serde_json::from_slice(&d.attachments[0].bytes).unwrap();
assert_eq!(session[0], json!({"command": "file.open", "params": {"path": MODEL_FILE}}), "the replay opens the attached model");
assert_eq!(session.as_array().unwrap().len(), 401);
assert_eq!(d.clipboard.as_deref().map(str::as_bytes), Some(d.attachments[0].bytes.as_slice()));
let text = decode(&d.url);
assert!(text.contains("too long for this form") && text.contains(MODEL_FILE) && !text.contains("\"label\": 399"), "{text}");
// With the session shared, the crash's command parameters are shown (home as ~).
assert!(text.contains("~/secret/plate.step"), "{text}");
}
#[test]
fn a_huge_message_is_cut_to_fit_the_link() {
let input = Input { observed: "é".repeat(20_000), ..Default::default() };
let d = draft(&input, "v1");
assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
assert!(d.fields[0].1.contains("[cut:"));
assert_eq!(decode_fields(&d.url), d.fields);
}
#[test]
fn encoding_round_trips() {
let s = "a b&c=d/é\n```json";
assert_eq!(decode(&encode(s)), s);
assert_eq!(encode("a b&"), "a%20b%26");
}
}
+49 -7
View File
@@ -155,8 +155,9 @@ pub struct Capability {
pub status: Status,
/// Related commands in the entry's drop-down (edit, delete…).
pub more: &'static [&'static str],
/// Commands the entry's own form covers (an option of it does the same): claimed by the entry
/// for the command search and the ribbon's stage, but not listed in the ribbon.
/// Commands the entry's own form covers (an option of it does the same), or that act on what
/// it makes from the browser's item menu (edit, delete): claimed by the entry for the command
/// search and the ribbon's stage, but not listed in the ribbon.
pub covers: &'static [&'static str],
}
@@ -246,15 +247,32 @@ pub static CATALOGUE: &[Capability] = &[
"Inspect",
"Weld, closed, manifold, orientation and self-intersection checks; feature edges",
),
ready_more(
// Edit and delete of datums and partitions are on the browser's item menu, not the ribbon.
covering(
"geometry.datum",
"Datum Plane",
Geometry,
"Prepare",
"Datum planes (principal plane and offset, three points, point and normal) and axes",
&["geometry.datum.axis", "geometry.datum.delete"],
"A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis",
&["geometry.datum.edit", "geometry.datum.delete"],
),
ready_more(
covering(
"geometry.datum.axis",
"Datum Axis",
Geometry,
"Prepare",
"A datum axis: principal axis, two points or datum points, point and direction",
&["geometry.datum.axis.edit"],
),
covering(
"geometry.datum.point",
"Datum Point",
Geometry,
"Prepare",
"A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane",
&["geometry.datum.point.edit"],
),
covering(
"geometry.partition",
"Partition",
Geometry,
@@ -307,7 +325,14 @@ pub static CATALOGUE: &[Capability] = &[
"One mesh of a partitioned part, a method per cell (hex, tet, or all hex as conforming blocks); cells joined across partitions",
&["mesh.hex.blocks"],
),
soon("mesh.hex.decompose", "Decompose", Mesh, "Structured Hex", "M5", "Find sweepable and mappable regions and propose the splits"),
soon(
"mesh.hex.decompose",
"Decompose",
Mesh,
"Structured Hex",
"M5",
"Find sweepable and mappable regions of 3D parts (extruded cross-sections are already cut into blocks by Hex Mesh)",
),
soon("mesh.transition", "Transition", Mesh, "Structured Hex", "M5", "Conforming tet–hex interfaces with pyramids"),
ready(
"mesh.hex.octree",
@@ -562,8 +587,25 @@ pub static CATALOGUE: &[Capability] = &[
"Open a model autosaved by an app that crashed or closed with unsaved changes",
&["file.recovery.list", "file.recovery.discard"],
),
// Hide, show and isolate act on the browser's items, from their eyes and item menus.
covering(
"view.show_all",
"Show All",
Tools,
"View",
"Show every item hidden in the viewport (hide, show and isolate items from the browser)",
&["view.hide", "view.show", "view.isolate"],
),
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
ready_more(
"diagnostics.bug_report",
"Report a Bug",
Tools,
"Help",
"Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it",
&["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_test", "app.update.check"],
),
];
/// The catalogue entry with this id.
+21
View File
@@ -371,6 +371,27 @@ mod tests {
assert_eq!(s.model.ties().len(), 1);
}
/// The half crank arm (cut on its symmetry plane only) has no cell that maps or sweeps as
/// the design gives it; the extrusion decomposes its cross-section into blocks itself, alike
/// through mesh.hex.generate (auto) and mesh.cells, with no seeds.
#[test]
fn the_half_crank_extrudes_in_blocks_without_partitions() {
let bytes = fieldcraft_geometry::script_design(crate::sample::CRANK_ARM).unwrap();
let (_dir, path) = crate::tests::design_file(&bytes);
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
s.execute("geometry.datum", &json!({"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}})).unwrap();
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Sym"})).unwrap();
let r = s.execute("mesh.hex.generate", &json!({"body": "CrankArm/a", "size": 3})).unwrap();
let body = &r["bodies"][0];
assert_eq!(body["method"], "extrude", "{r}");
assert!(body["description"].as_str().unwrap().contains(" 0 free)"), "{r}");
assert!(body["quality"]["min_scaled_jacobian"].as_f64().unwrap() >= 0.37, "{r}");
let r = s.execute("mesh.cells", &json!({"bodies": ["CrankArm/a"], "default": "extrude", "size": 3})).unwrap();
assert_eq!(r["cells"][0]["method"], "extrude", "{r}");
assert_eq!(r["elements"], body["elements"], "{r}");
}
#[test]
fn auto_falls_back_to_tet_and_says_why() {
let (_dir, mut s) = cut_plate();
+372 -28
View File
@@ -1,6 +1,10 @@
//! Datum planes and axes, and partitions of the open design by datum planes.
//! Datum planes, axes and points, and partitions of the open design by datum planes.
//!
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A partition cuts the
//! Datums and partitions are part of the model (`fieldcraft_model::datums`). A datum point is
//! placed by coordinates, at a mesh vertex, along a design edge, at a design face's centre or
//! where a datum axis crosses a datum plane; planes and axes can be made through datum points.
//! Each takes the coordinates of what it is made from when it is made (a later edit of a datum
//! point leaves the planes and axes made through it where they are). A partition cuts the
//! design in memory (`fieldcraft_geometry::partition`); the model's partitions are applied again,
//! in order, whenever the design is opened (`geometry.open`, `file.open`, samples), so an edit
//! of the design followed by a re-open replays them.
@@ -9,6 +13,7 @@ use crate::schema::ProblemKind;
use std::sync::Arc;
use fieldcraft_geometry::{Cut, Design};
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
use fieldcraft_model::{Datum, Model, Partition};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -19,7 +24,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const DATUM_PLANE: CommandSpec = CommandSpec {
id: "geometry.datum",
label: "Datum Plane",
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points (normal (b − a) × (c − a)), or through a point with a normal. Partitions (geometry.partition) cut by it; shown in the viewport"#,
params: r#"{"name": "Mid", "by": {"type": "offset", "plane": "yz" | "zx" | "xy", "offset": 15} | {"type": "three_points", "a": [x, y, z], "b": […], "c": […]} | {"type": "point_normal", "point": [x, y, z], "normal": [nx, ny, nz]} | {"type": "datum_points", "a": "P1", "b": "P2", "c": "P3"} | {"type": "normal_to_axis", "axis": "Spindle", "point"?: "P1"}} — a named datum plane (model coordinates, mm for SolidCraft geometry): a principal plane moved along its normal (yz: normal +x, zx: normal +y, xy: normal +z), through three points or three datum points (normal (b − a) × (c − a)), through a point with a normal, or normal to a datum axis through a datum point (the axis's point by default). Partitions (geometry.partition) cut by it; shown in the viewport"#,
form: Some(super::forms::DATUM_PLANE),
kind: CommandKind::Model,
requires: &[],
@@ -29,17 +34,57 @@ pub const DATUM_PLANE: CommandSpec = CommandSpec {
pub const DATUM_AXIS: CommandSpec = CommandSpec {
id: "geometry.datum.axis",
label: "Datum Axis",
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]}} — a named datum axis, shown in the viewport"#,
params: r#"{"name": "Spindle", "by": {"type": "principal", "axis": "x" | "y" | "z", "through"?: [x, y, z] (default the origin)} | {"type": "two_points", "a": [x, y, z], "b": […]} | {"type": "point_direction", "point": [x, y, z], "direction": [dx, dy, dz]} | {"type": "datum_points", "a": "P1", "b": "P2"}} — a named datum axis, shown in the viewport"#,
form: Some(super::forms::DATUM_AXIS),
kind: CommandKind::Model,
requires: &[],
run: datum_axis,
};
pub const DATUM_POINT: CommandSpec = CommandSpec {
id: "geometry.datum.point",
label: "Datum Point",
params: r#"{"name": "P1", "by": {"type": "coordinates", "point": [x, y, z]} | {"type": "vertex", "body": "Body1", "vertex": "v3"} | {"type": "edge", "body": "Body1", "edge": "F1:top|F2:side", "fraction"?: 0.5} | {"type": "face_centre", "body": "Body1", "face": "F2:top"} | {"type": "axis_plane", "axis": "Spindle", "plane": "Mid"}} — a named datum point, shown in the viewport as a small cross: at coordinates, at a vertex of the mesh (as its geometry tags name it), a fraction along an edge of the open design (0.5: its midpoint), at the area centre of a face of the open design, or where a datum axis crosses a datum plane. Datum planes and axes can be made through datum points"#,
form: Some(super::forms::DATUM_POINT),
kind: CommandKind::Model,
requires: &[],
run: datum_point,
};
pub const DATUM_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.edit",
label: "Edit Datum Plane",
params: r#"{"name": "Mid", "by": …} — redefines a datum plane (the parameters of geometry.datum); the partitions that cut by it cut again"#,
form: Some(super::forms::DATUM_PLANE),
kind: CommandKind::Model,
requires: &[Requirement::DatumPlane],
run: |s, p| datum_edit(s, p, "plane"),
};
pub const DATUM_AXIS_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.axis.edit",
label: "Edit Datum Axis",
params: r#"{"name": "Spindle", "by": …} — redefines a datum axis (the parameters of geometry.datum.axis)"#,
form: Some(super::forms::DATUM_AXIS),
kind: CommandKind::Model,
requires: &[Requirement::DatumAxis],
run: |s, p| datum_edit(s, p, "axis"),
};
pub const DATUM_POINT_EDIT: CommandSpec = CommandSpec {
id: "geometry.datum.point.edit",
label: "Edit Datum Point",
params: r#"{"name": "P1", "by": …} — redefines a datum point (the parameters of geometry.datum.point); planes and axes made through it stay where they are"#,
form: Some(super::forms::DATUM_POINT),
kind: CommandKind::Model,
requires: &[Requirement::DatumPoint],
run: |s, p| datum_edit(s, p, "point"),
};
pub const DATUM_DELETE: CommandSpec = CommandSpec {
id: "geometry.datum.delete",
label: "Delete Datum",
params: r#"{"name": "Mid"} — deletes a datum plane or axis no partition uses"#,
params: r#"{"name": "Mid", "cascade"?: false} — deletes a datum plane, axis or point; one a partition cuts by is refused (naming the partitions), unless `cascade` deletes those partitions too"#,
form: Some(super::forms::DATUM_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::Datum],
@@ -91,6 +136,16 @@ enum PlaneBy {
point: [f64; 3],
normal: [f64; 3],
},
DatumPoints {
a: String,
b: String,
c: String,
},
NormalToAxis {
axis: String,
#[serde(default)]
point: Option<String>,
},
}
#[derive(Deserialize)]
@@ -117,13 +172,74 @@ enum AxisBy {
point: [f64; 3],
direction: [f64; 3],
},
DatumPoints {
a: String,
b: String,
},
}
fn half() -> f64 {
0.5
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum PointBy {
Coordinates {
point: [f64; 3],
},
Vertex {
body: String,
vertex: String,
},
Edge {
body: String,
edge: String,
#[serde(default = "half")]
fraction: f64,
},
FaceCentre {
body: String,
face: String,
},
AxisPlane {
axis: String,
plane: String,
},
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn plane_datum(by: PlaneBy) -> Result<Datum> {
/// The coordinates of datum point `name`.
fn datum_point_at(m: &Model, name: &str) -> Result<[f64; 3]> {
match m.datum(name) {
Some(d) => d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind()))),
None => Err(EngineError::Params(format!("no datum point `{name}`"))),
}
}
/// The point and unit direction of datum axis `name`.
fn datum_axis_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> {
match m.datum(name) {
Some(d) => d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind()))),
None => Err(EngineError::Params(format!("no datum axis `{name}`"))),
}
}
/// A plane through three points; collinear points are refused relative to their spread.
fn three_points(a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Result<Datum> {
let (u, v) = (sub(b, a), sub(c, a));
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
return Err(EngineError::Params("the three points must not lie on one line".into()));
}
Ok(Datum::Plane { point: a, normal: n })
}
fn plane_datum(m: &Model, by: PlaneBy) -> Result<Datum> {
Ok(match by {
PlaneBy::Offset { plane, offset } => {
if !offset.is_finite() {
@@ -136,22 +252,22 @@ fn plane_datum(by: PlaneBy) -> Result<Datum> {
};
Datum::Plane { point: normal.map(|n| n * offset), normal }
}
PlaneBy::ThreePoints { a, b, c } => {
let (u, v) = (sub(b, a), sub(c, a));
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
// Collinear points: refused relative to the points' spread.
let size = u.iter().chain(&v).map(|x| x * x).sum::<f64>();
if !(n.iter().map(|x| x * x).sum::<f64>() > 1e-20 * size * size) {
return Err(EngineError::Params("the three points must not lie on one line".into()));
}
Datum::Plane { point: a, normal: n }
}
PlaneBy::ThreePoints { a, b, c } => three_points(a, b, c)?,
PlaneBy::PointNormal { point, normal } => Datum::Plane { point, normal },
PlaneBy::DatumPoints { a, b, c } => three_points(datum_point_at(m, &a)?, datum_point_at(m, &b)?, datum_point_at(m, &c)?)?,
PlaneBy::NormalToAxis { axis, point } => {
let (on_axis, direction) = datum_axis_of(m, &axis)?;
let point = match point {
Some(p) => datum_point_at(m, &p)?,
None => on_axis,
};
Datum::Plane { point, normal: direction }
}
})
}
fn axis_datum(by: AxisBy) -> Datum {
match by {
fn axis_datum(m: &Model, by: AxisBy) -> Result<Datum> {
Ok(match by {
AxisBy::Principal { axis, through } => {
let direction = match axis {
Axis::X => [1.0, 0.0, 0.0],
@@ -162,6 +278,80 @@ fn axis_datum(by: AxisBy) -> Datum {
}
AxisBy::TwoPoints { a, b } => Datum::Axis { point: a, direction: sub(b, a) },
AxisBy::PointDirection { point, direction } => Datum::Axis { point, direction },
AxisBy::DatumPoints { a, b } => {
let a = datum_point_at(m, &a)?;
Datum::Axis { point: a, direction: sub(datum_point_at(m, &b)?, a) }
}
})
}
/// Most mesh nodes a vertex is looked for among.
const MAX_VERTEX_NODES: usize = 10_000_000;
fn point_datum(s: &Session, by: PointBy) -> Result<Datum> {
let shape = |body: &str| -> Result<&fieldcraft_geometry::Shape> {
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
design.body(body).ok_or_else(|| EngineError::Params(format!("the design has no body `{body}`")))
};
let point = match by {
PointBy::Coordinates { point } => point,
PointBy::Vertex { body, vertex } => {
let m = &s.model;
let at = fieldcraft_model::GeometryRef::new(body.clone(), fieldcraft_model::Entity::Vertex(vertex.clone()))?;
let tags = m.classification().tags_selected_by(&at);
let node =
m.classification().nodes().iter().take(MAX_VERTEX_NODES).find(|(_, t)| tags.contains(t)).and_then(|(n, _)| m.node(*n)).ok_or_else(
|| EngineError::Params(format!("vertex `{vertex}` of body `{body}` is on no mesh node (vertices are named on meshed bodies)")),
)?;
node.xyz
}
PointBy::Edge { body, edge, fraction } => {
if !(0.0..=1.0).contains(&fraction) {
return Err(EngineError::invalid("fraction", "is a number from 0 to 1".to_string()));
}
let edges = shape(&body)?.edges()?;
let e = edges.iter().find(|e| e.name == edge).ok_or_else(|| EngineError::Params(format!("body `{body}` has no edge `{edge}`")))?;
e.point_at(fraction).ok_or_else(|| EngineError::Params(format!("edge `{edge}` of body `{body}` has no points")))?
}
PointBy::FaceCentre { body, face } => shape(&body)?.face_centre(&face)?,
PointBy::AxisPlane { axis, plane } => {
let (p, d) = datum_axis_of(&s.model, &axis)?;
let (q, n) = match s.model.datum(&plane) {
Some(x) => x.plane().ok_or_else(|| EngineError::Params(format!("datum `{plane}` is a datum {}, not a plane", x.kind())))?,
None => return Err(EngineError::Params(format!("no datum plane `{plane}`"))),
};
let along = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let across = along(d, n);
// Unit vectors: the sine of the angle between the axis and the plane.
if across.abs() < 1e-9 {
return Err(EngineError::Params(format!("axis `{axis}` is parallel to plane `{plane}`: they do not cross")));
}
let t = along(sub(q, p), n) / across;
[p[0] + t * d[0], p[1] + t * d[1], p[2] + t * d[2]]
}
};
Ok(Datum::Point { point })
}
/// The datum `by` defines for a datum of `kind` (`plane`, `axis` or `point`).
fn datum_of(s: &Session, kind: &str, p: &Value) -> Result<Datum> {
match kind {
"plane" => plane_datum(&s.model, super::req(p, "by")?),
"axis" => axis_datum(&s.model, super::req(p, "by")?),
_ => point_datum(s, super::req(p, "by")?),
}
}
/// The parameters of the command that edits datum `name`, describing it as it is (a plane by
/// its point and normal, an axis by its point and direction, a point by its coordinates); and
/// that command.
pub fn edit_params(name: &str, d: &Datum) -> (&'static str, Value) {
match *d {
Datum::Plane { point, normal } => (DATUM_EDIT.id, json!({"name": name, "by": {"type": "point_normal", "point": point, "normal": normal}})),
Datum::Axis { point, direction } => {
(DATUM_AXIS_EDIT.id, json!({"name": name, "by": {"type": "point_direction", "point": point, "direction": direction}}))
}
Datum::Point { point } => (DATUM_POINT_EDIT.id, json!({"name": name, "by": {"type": "coordinates", "point": point}})),
}
}
@@ -173,24 +363,55 @@ fn datum_json(name: &str, d: &Datum) -> Value {
v
}
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
fn datum_create(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let name = super::name(p)?;
let datum = plane_datum(super::req(p, "by")?)?;
let datum = datum_of(s, kind, p)?;
s.model.add_datum(name, datum)?;
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
}
fn datum_plane(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "plane")
}
fn datum_axis(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "axis")
}
fn datum_point(s: &mut Session, p: &Value) -> Result<Value> {
datum_create(s, p, "point")
}
/// Redefine datum `name` of `kind`; the partitions that cut by a plane cut again.
fn datum_edit(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let name = super::name(p)?;
let datum = axis_datum(super::req(p, "by")?);
s.model.add_datum(name, datum)?;
match s.model.datum(name) {
Some(d) if d.kind() == kind => {}
Some(d) => return Err(EngineError::Params(format!("datum `{name}` is a datum {}, not a {kind}", d.kind()))),
None => return Err(EngineError::Params(format!("no datum {kind} `{name}`"))),
}
let datum = datum_of(s, kind, p)?;
s.model.replace_datum(name, datum)?;
if !s.model.datum_users(name).is_empty() {
recut(s)?;
}
Ok(datum_json(name, s.model.datum(name).unwrap_or(&datum)))
}
fn datum_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let cascade: bool = super::opt(p, "cascade")?.unwrap_or(false);
let partitions: Vec<String> = s.model.partitions().iter().filter(|x| x.datum == name).map(|x| x.name.clone()).collect();
if cascade {
for partition in &partitions {
s.model.remove_partition(partition)?;
}
}
s.model.remove_datum(name)?;
Ok(json!({"deleted": name}))
if cascade && !partitions.is_empty() {
recut(s)?;
}
Ok(json!({"deleted": name, "partitions": if cascade { partitions } else { Vec::new() }}))
}
/// The model's partitions as cuts of the geometry.
@@ -223,7 +444,7 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let datum: String = super::req(p, "datum")?;
let bodies: Vec<String> = super::opt(p, "bodies")?.unwrap_or_default();
s.model.add_partition(Partition { name: name.clone(), datum, bodies })?;
s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies })?;
recut(s)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let feature = format!("partition {name}");
@@ -236,13 +457,19 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
if split.is_empty() {
return Err(EngineError::Params(format!("partition `{name}`: the plane divides no body of the design")));
}
let cells: Vec<&str> = design.bodies.iter().map(|b| b.name.as_str()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
let cells: Vec<String> = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
// The cut shows as edges on the cells, so the plane steps out of the way (view state, #295):
// selecting it in the browser or its eye shows it again.
if s.view.hidden.len() < MAX_HIDDEN {
s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum });
}
Ok(json!({
"name": name,
"split": split,
"cells": cells,
"cut_faces": report.map_or(0, |r| r.faces),
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
"cut_faces": faces,
"bodies": bodies,
}))
}
@@ -330,8 +557,13 @@ pub(crate) mod tests {
let body = r["split"][0].as_str().unwrap().to_string();
assert_eq!(r["cells"], json!([format!("{body}/a"), format!("{body}/b")]));
assert_eq!(r["cut_faces"], json!(4));
// The datum is in use.
// The datum is in use, and hidden: the cut shows as edges on the cells (#295). Its eye
// shows it again.
assert!(s.execute("geometry.datum.delete", &json!({"name": "Mid"})).is_err());
assert!(!s.shows(ItemKind::Datum, "Mid"));
s.execute("view.show", &json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
assert!(s.shows(ItemKind::Datum, "Mid"));
s.execute("view.hide", &json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
// A set on a cut face selects the cut face's pieces.
let r = s.execute("geometry.bodies", &json!({})).unwrap();
@@ -344,6 +576,7 @@ pub(crate) mod tests {
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert_eq!(bodies(&t), bodies(&s));
assert!(!t.shows(ItemKind::Datum, "Mid"), "still hidden when opened again");
// geometry.open again keeps the model, so its partition.
let design = dir.join("plate.solidcraft");
let r = t.execute("geometry.open", &json!({"path": design.to_str().unwrap()})).unwrap();
@@ -362,6 +595,117 @@ pub(crate) mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn datum_points_by_each_definition_and_planes_and_axes_through_them() {
let (mut s, dir) = plate();
let point = |s: &Session, n: &str| s.model.datum(n).and_then(Datum::point).unwrap();
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
s.execute("geometry.datum.point", &json!({"name": "O", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
s.execute("geometry.datum.point", &json!({"name": "X", "by": {"type": "coordinates", "point": [10, 0, 0]}})).unwrap();
s.execute("geometry.datum.point", &json!({"name": "Y", "by": {"type": "coordinates", "point": [0, 10, 0]}})).unwrap();
// The plate's edge along x at y = 0, z = 0, its midpoint and a quarter along it.
let body = s.design.as_ref().unwrap().bodies[0].clone();
let edge = body.edges().unwrap().into_iter().find(|e| e.points.iter().all(|p| p[1].abs() < 1e-9 && p[2].abs() < 1e-9)).unwrap();
let by = |fraction: f64| json!({"type": "edge", "body": body.name, "edge": edge.name, "fraction": fraction});
s.execute("geometry.datum.point", &json!({"name": "Mid", "by": {"type": "edge", "body": body.name, "edge": edge.name}})).unwrap();
assert!(close(point(&s, "Mid"), [20.0, 0.0, 0.0]), "{:?}", point(&s, "Mid"));
s.execute("geometry.datum.point", &json!({"name": "Q", "by": by(0.25)})).unwrap();
let q = point(&s, "Q");
assert!((q[0] - 10.0).abs() < 1e-6 || (q[0] - 30.0).abs() < 1e-6, "{q:?}");
// The centre of the face at x = 0.
let side =
body.face_surfaces().unwrap().iter().position(
|f| matches!(f, Some(fieldcraft_geometry::FaceSurface::Plane { normal, point }) if normal[0] < -0.5 && point[0].abs() < 1e-9),
);
let face = body.faces()[side.unwrap()].name.clone();
s.execute("geometry.datum.point", &json!({"name": "C", "by": {"type": "face_centre", "body": body.name, "face": face}})).unwrap();
assert!(close(point(&s, "C"), [0.0, 15.0, 5.0]), "{:?}", point(&s, "C"));
// Planes and axes through datum points; a point where an axis crosses a plane.
let r = s.execute("geometry.datum", &json!({"name": "Base", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Y"}})).unwrap();
assert_eq!(r["normal"], json!([0.0, 0.0, 1.0]));
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "datum_points", "a": "O", "b": "X"}})).unwrap();
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [1.0, 0.0, 0.0] }));
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "normal_to_axis", "axis": "Ax", "point": "Mid"}})).unwrap();
assert_eq!(s.model.datum("Cut"), Some(&Datum::Plane { point: point(&s, "Mid"), normal: [1.0, 0.0, 0.0] }));
s.execute("geometry.datum.point", &json!({"name": "Hit", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Cut"}})).unwrap();
assert!(close(point(&s, "Hit"), [20.0, 0.0, 0.0]));
let journal = s.journal.len();
for p in [
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Ax", "plane": "Base"}}),
json!({"name": "Z", "by": {"type": "axis_plane", "axis": "Base", "plane": "Cut"}}),
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": edge.name, "fraction": 2}}),
json!({"name": "Z", "by": {"type": "edge", "body": body.name, "edge": "nope"}}),
json!({"name": "Z", "by": {"type": "edge", "body": "Nope", "edge": edge.name}}),
json!({"name": "Z", "by": {"type": "face_centre", "body": body.name, "face": "nope"}}),
// No mesh: no vertex is named.
json!({"name": "Z", "by": {"type": "vertex", "body": body.name, "vertex": "v1"}}),
json!({"name": "Z", "by": {"type": "coordinates", "point": [f64::MAX, 0, 0]}}),
json!({"name": "Z", "by": {"type": "coordinates", "point": [0, 0]}}),
] {
assert!(s.execute("geometry.datum.point", &p).is_err(), "{p}");
}
for (command, p) in [
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Mid"}})),
("geometry.datum", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "X", "c": "Ax"}})),
("geometry.datum", json!({"name": "Z", "by": {"type": "normal_to_axis", "axis": "Nope"}})),
("geometry.datum.axis", json!({"name": "Z", "by": {"type": "datum_points", "a": "O", "b": "O"}})),
] {
assert!(s.execute(command, &p).is_err(), "{p}");
}
assert_eq!(s.journal.len(), journal);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_vertex_point_is_at_the_tagged_mesh_node() {
let mut s = Session::default();
s.execute(
"mesh.block",
&json!({"body": "Bar", "kind": "hex8", "corners": [[0,0,0],[4,0,0],[4,1,0],[0,1,0],[0,0,1],[4,0,1],[4,1,1],[0,1,1]], "divisions": [2, 1, 1], "vertices": {"7": "tip"}}),
)
.unwrap();
s.execute("geometry.datum.point", &json!({"name": "Tip", "by": {"type": "vertex", "body": "Bar", "vertex": "tip"}})).unwrap();
assert_eq!(s.model.datum("Tip").and_then(Datum::point), Some([4.0, 1.0, 1.0]));
}
#[test]
fn editing_and_deleting_datums_follow_their_partitions() {
let (mut s, dir) = plate();
s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
s.execute("geometry.datum.axis", &json!({"name": "Ax", "by": {"type": "principal", "axis": "x"}})).unwrap();
s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap();
let cut_at = |s: &Session| s.design.as_ref().unwrap().bodies[0].measure().unwrap().bbox_max[0];
assert!((cut_at(&s) - 15.0).abs() < 1e-6);
// Moving the plane cuts again; an edit of the wrong kind or to an axis is refused.
s.execute("geometry.datum.edit", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 25}})).unwrap();
assert!((cut_at(&s) - 25.0).abs() < 1e-6);
assert!(s.execute("geometry.datum.axis.edit", &json!({"name": "Mid", "by": {"type": "principal", "axis": "y"}})).is_err());
assert!(s.execute("geometry.datum.edit", &json!({"name": "Ax", "by": {"type": "offset", "plane": "xy"}})).is_err());
s.execute("geometry.datum.axis.edit", &json!({"name": "Ax", "by": {"type": "principal", "axis": "y"}})).unwrap();
assert_eq!(s.model.datum("Ax"), Some(&Datum::Axis { point: [0.0; 3], direction: [0.0, 1.0, 0.0] }));
// The edit commands take the parameters edit_params gives back.
for name in ["Mid", "Ax"] {
let (command, params) = edit_params(name, s.model.datum(name).unwrap());
s.execute(command, &params).unwrap();
}
// Deleting the plane names its partition, or deletes it too.
let e = s.execute("geometry.datum.delete", &json!({"name": "Mid"})).unwrap_err().to_string();
assert!(e.contains("partition `Half`"), "{e}");
let r = s.execute("geometry.datum.delete", &json!({"name": "Mid", "cascade": true})).unwrap();
assert_eq!(r["partitions"], json!(["Half"]));
assert!(s.model.partitions().is_empty() && s.model.datum("Mid").is_none());
assert_eq!(bodies(&s).len(), 1);
// One undo step brings both back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((s.model.partitions().len(), bodies(&s).len()), (1, 2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_partition_that_cuts_nothing_changes_nothing() {
let (mut s, dir) = plate();
+133
View File
@@ -0,0 +1,133 @@
//! Crash reports and bug report drafts (docs/crash-reports.md): list and read the reports the
//! desktop app wrote, draft a bug report, and (with `FIELDCRAFT_CRASH_TEST=1`) crash on purpose
//! to check that reporting works.
use std::path::PathBuf;
use fieldcraft_io::roots::Access;
use serde_json::{Value, json};
use super::{file_param, opt, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, bug_report, crash};
pub const CRASH_REPORTS: CommandSpec = CommandSpec {
id: "diagnostics.crash_reports",
label: "Crash Reports",
params: r#"{"limit"?: 20, "dir"?: "…"} — the crash reports the desktop app wrote on this computer, newest first: id, kind (command_panic, recovered_panic, panic, unclean_exit), time, message, command, count, version, path and whether it can be replayed (default folder: FIELDCRAFT_CRASH_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
form: Some(super::forms::CRASH_REPORTS),
kind: CommandKind::Session,
requires: &[],
run: crash_reports,
};
pub const CRASH_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.crash_report",
label: "Crash Report",
params: r#"{"id": "20261014-101500-…", "dir"?: "…"} — one crash report: what happened, where, the backtrace, the command and recent commands, and the paths of its model, replay and log files"#,
form: Some(super::forms::CRASH_REPORT),
kind: CommandKind::Session,
requires: &[],
run: crash_report,
};
pub const BUG_REPORT: CommandSpec = CommandSpec {
id: "diagnostics.bug_report",
label: "Report a Bug",
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a GitHub bug report: the issue link (the bug form filled with the version, the OS, what happened and, for a crash, where), and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the desktop app's Help › Report a Bug shows the draft and opens the link when the user asks"#,
form: Some(super::forms::BUG_REPORT),
kind: CommandKind::Session,
requires: &[],
run: bug_report_draft,
};
pub const CRASH_TEST: CommandSpec = CommandSpec {
id: "diagnostics.crash_test",
label: "Crash Test",
params: r#"{"mode": "panic|recovered|abort"} — crashes on purpose to check that crash reports work (only with FIELDCRAFT_CRASH_TEST=1): panic, an internal error in this command; recovered, one caught inside it; abort, the app ends at once (reported at its next start)"#,
form: Some(super::forms::CRASH_TEST),
kind: CommandKind::Session,
requires: &[],
run: crash_test,
};
fn crash_dir(s: &Session, p: &Value) -> Result<PathBuf> {
if p.get("dir").is_some() {
return Ok(PathBuf::from(file_param(s, p, "dir", Access::Read)?));
}
crash::default_dir().ok_or_else(|| EngineError::Params("no crash folder: set FIELDCRAFT_CRASH_DIR or give `dir`".into()))
}
fn crash_reports(s: &mut Session, p: &Value) -> Result<Value> {
let dir = crash_dir(s, p)?;
let limit = opt::<usize>(p, "limit")?.unwrap_or(crash::MAX_REPORTS);
let mut reports = crash::list(&dir);
reports.truncate(limit);
Ok(json!({"dir": dir.to_string_lossy(), "reports": reports}))
}
fn crash_report(s: &mut Session, p: &Value) -> Result<Value> {
let dir = crash_dir(s, p)?;
let id = req::<String>(p, "id")?;
crash::read(&dir, &id).map_err(EngineError::Params)
}
/// The draft input for `p` from the session: its commands and model (when shared), or a crash
/// report's replay and model.
pub fn bug_report_input(s: &Session, p: &Value) -> Result<bug_report::Input> {
let share = opt::<bool>(p, "share_session")?.unwrap_or(false);
let include_model = opt::<bool>(p, "include_model")?.unwrap_or(false);
let mut input = bug_report::Input {
title: opt::<String>(p, "title")?.unwrap_or_default(),
observed: opt::<String>(p, "observed")?.unwrap_or_default(),
share_session: share,
include_model,
..Default::default()
};
match opt::<String>(p, "crash")? {
Some(id) => {
let report = crash::read(&crash_dir(s, p)?, &id).map_err(EngineError::Params)?;
if share {
let read = |k: &str| {
report
.get(k)
.and_then(Value::as_str)
.and_then(|f| fieldcraft_io::fs::read_capped(std::path::Path::new(f), crash::MAX_MODEL_BYTES as u64).ok())
};
let replay: Value = read("replay_path").and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
input.commands = replay.get("commands").and_then(Value::as_array).cloned().unwrap_or_default();
input.model = if include_model { read("model_path") } else { None };
}
input.crash = Some(report);
}
None if share => {
input.commands = s.journal.iter().map(|(command, params)| json!({"command": command, "params": params})).collect();
input.model = if include_model { Some(s.file_bytes(None)?) } else { None };
}
None => {}
}
Ok(input)
}
fn bug_report_draft(s: &mut Session, p: &Value) -> Result<Value> {
let input = bug_report_input(s, p)?;
Ok(bug_report::draft(&input, &crash::Build::current().version_line()).to_json())
}
// The one deliberate panic and abort: they prove that crash reports work, and run only when
// asked for with FIELDCRAFT_CRASH_TEST=1.
#[allow(clippy::panic)]
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
let mode = req::<String>(p, "mode")?;
if std::env::var_os("FIELDCRAFT_CRASH_TEST").is_none_or(|v| v != "1") {
return Err(EngineError::Params("the crash test runs only with FIELDCRAFT_CRASH_TEST=1".into()));
}
match mode.as_str() {
"panic" => std::panic::panic_any(String::from("crash test: an internal error in a command")),
"recovered" => {
let caught = std::panic::catch_unwind(|| std::panic::panic_any(String::from("crash test: an internal error caught inside a command")));
Ok(json!({"caught": caught.is_err()}))
}
"abort" => std::process::abort(),
_ => Err(EngineError::invalid("mode", "must be panic, recovered or abort")),
}
}
+16 -4
View File
@@ -105,6 +105,7 @@ fn open_sample(s: &mut Session, p: &Value) -> Result<Value> {
opened.run_script(&script)?;
// A sample just opened has nothing to save: it opens again the same way.
opened.saved_revision = Some(opened.revision);
opened.saved_view = opened.view.clone();
s.replace_with(opened);
let mut reply = summary(s, &[]);
reply["sample"] = json!(sample.name);
@@ -190,7 +191,7 @@ impl Session {
design: self.sources.design.as_ref().map(stored).transpose()?,
meshes: self.sources.meshes.iter().map(stored).collect::<fieldcraft_io::Result<_>>()?,
};
Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal)?)
Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal, &self.view)?)
}
/// A session holding what a file holds, its geometry opened again from the paths it names
@@ -220,17 +221,28 @@ impl Session {
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source);
}
let session =
Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() };
let session = Session {
model: file.model,
design,
surfaces,
sources,
journal: file.journal,
revision: next_revision(),
saved_view: file.view.clone(),
view: file.view,
..Session::default()
};
(session, warnings)
}
/// Replace this session with `opened`, keeping its undo memory limit, its work caps and its
/// file access; the history starts empty.
/// file access (and where its commands come from); the history starts empty.
fn replace_with(&mut self, mut opened: Session) {
opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.file_access = std::mem::take(&mut self.file_access);
opened.crash_journal = self.crash_journal;
opened.origin = self.origin;
opened.clear_history();
opened.mark_opened();
*self = opened;
+128 -2
View File
@@ -1277,6 +1277,25 @@ pub const DATUM_PLANE: &[Field] = &[
xyz("normal", "Normal", D::None).help("The plane's normal direction.").req(),
],
),
variant(
"datum_points",
"Three datum points",
&[
reference("a", "Point A", C::DatumPoint).help("First datum point.").req(),
reference("b", "Point B", C::DatumPoint).help("Second datum point.").req(),
reference("c", "Point C", C::DatumPoint).help("Third datum point.").req(),
],
),
variant(
"normal_to_axis",
"Normal to an axis",
&[
reference("axis", "Axis", C::DatumAxis).help("The datum axis the plane is normal to.").req(),
reference("point", "Through", C::DatumPoint)
.help("A datum point on the plane; the axis's own point when empty.")
.hint("the axis's point"),
],
),
],
)
.help("How the plane is defined.")
@@ -1313,13 +1332,94 @@ pub const DATUM_AXIS: &[Field] = &[
xyz("direction", "Direction", D::None).help("The axis direction.").req(),
],
),
variant(
"datum_points",
"Two datum points",
&[
reference("a", "Point A", C::DatumPoint).help("First datum point on the axis.").req(),
reference("b", "Point B", C::DatumPoint).help("Second datum point on the axis.").req(),
],
),
],
)
.help("How the axis is defined.")
.req()
.def(r#"{"type": "principal"}"#),
];
pub const DATUM_DELETE: &[Field] = existing!("Datum", C::Datum);
pub const DATUM_POINT: &[Field] = &[
NAME,
variants(
"by",
"Definition",
&[
variant("coordinates", "Coordinates", &[xyz("point", "Point", POINT).help("The point's coordinates.").req()]),
variant(
"vertex",
"Mesh vertex",
&[
reference("body", "Body", C::Body).help("The body the vertex belongs to.").req(),
text("vertex", "Vertex").help("The vertex's name, as the mesh tags it.").req(),
],
),
variant(
"edge",
"On an edge",
&[
reference("body", "Body", C::Body).help("The body the edge belongs to.").req(),
text("edge", "Edge").help("The edge's persistent name.").req(),
range("fraction", "Fraction", D::None, 0.0, 1.0).help("How far along the edge from its start: 0.5 is its midpoint.").def("0.5"),
],
),
variant(
"face_centre",
"Face centre",
&[
reference("body", "Body", C::Body).help("The body the face belongs to.").req(),
text("face", "Face").help("The face's persistent name.").req(),
],
),
variant(
"axis_plane",
"Axis and plane",
&[
reference("axis", "Axis", C::DatumAxis).help("The datum axis.").req(),
reference("plane", "Plane", C::DatumPlane).help("The datum plane the axis crosses.").req(),
],
),
],
)
.help("How the point is defined.")
.req()
.def(r#"{"type": "coordinates"}"#),
];
pub const VIEW_ITEMS: &[Field] = &[rows(
"items",
"Items",
&[
choice(
"kind",
"Kind",
&[
("body", "Body"),
("surface", "Surface"),
("datum", "Datum"),
("boundary_condition", "Boundary condition"),
("load", "Load"),
("reference_point", "Reference point"),
("coupling", "Coupling"),
],
)
.help("What kind of item it is.")
.req(),
text("name", "Name").help("The item's name.").req(),
],
)
.help("The viewport items.")
.req()];
pub const DATUM_DELETE: &[Field] = &[
reference("name", "Datum", C::Datum).req().help("The Datum to act on, by name."),
flag("cascade", "Delete its partitions").help("Also delete the partitions that cut by it; without it, a datum in use is refused."),
];
pub const PARTITION: &[Field] = &[
NAME,
reference("datum", "Datum plane", C::DatumPlane).help("The plane that divides the bodies.").req(),
@@ -1333,6 +1433,23 @@ pub const FILE_SAVE: &[Field] = &[path("path", "File", true).help("Where to save
pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).help("The file to save to.").req()];
pub const RECOVERY_LIST: &[Field] =
&[path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder")];
pub const CRASH_REPORTS: &[Field] = &[
int("limit", "Limit", 1, 1000).help("At most this many reports, newest first.").hint("20"),
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
];
pub const CRASH_REPORT: &[Field] = &[
text("id", "Report").help("The crash report to show.").req(),
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
];
pub const BUG_REPORT: &[Field] = &[
text("title", "Title").help("The issue's title; one is made up when empty."),
text("observed", "What happened").help("What went wrong; the last error when empty."),
text("crash", "Crash report").help("A crash report to report (its id)."),
flag("share_session", "Share my session").help("Attach the commands that built the model (they can name your files).").def("false"),
flag("include_model", "Include the model").help("Attach the model file too (with Share my session).").def("false"),
];
pub const CRASH_TEST: &[Field] =
&[choice("mode", "Mode", &[("panic", "Panic"), ("recovered", "Recovered panic"), ("abort", "Abort")]).help("How to crash.").req()];
pub const RECOVER: &[Field] = &[
text("id", "Autosave").help("The autosave to recover.").req(),
path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder"),
@@ -1597,7 +1714,16 @@ mod tests {
}
}
const STATE: &[&str] = &["open a design first", "`boundary_conditions` or `loads`", "give one of", "is not an explicit", "has a result"];
const STATE: &[&str] = &[
"open a design first",
"update checks are off",
"`boundary_conditions` or `loads`",
"give one of",
"is not an explicit",
"has a result",
"no crash report",
"FIELDCRAFT_CRASH_TEST",
];
/// Fill top-level required names (a fresh one) and references (the first existing item).
fn fill_required(s: &Session, form: &[Field], p: &mut Value) {
+1 -1
View File
@@ -16,7 +16,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HEX_GENERATE: CommandSpec = CommandSpec {
id: "mesh.hex.generate",
label: "Hex Mesh",
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis, one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
params: r#"{"body": "Body1" (default: every body of the open design) or "surface": 0 (an imported surface), "size": 2.0 (element size, mm; default 1/25 of the bounding-box diagonal), "order": 1 (hex8) or 2 (hex20, mid-edge nodes on the geometry), "method": "auto" (default) | "swept" (any sweep) | "extrude" | "sweep" | "revolve" | "mapped", "tolerance": 0.05 (chordal tolerance of the tessellation, mm; default 1e-3 of the diagonal)} — replaces the meshes of the bodies it meshes (as mesh.tet.generate; other bodies keep theirs) with structured hexahedra: multi-level sweeps along a direction or around an axis (their cross-section cut into mappable blocks automatically: O-grids round holes, triangle primitives, four-sided blocks; nothing is added to the model), one-to-one sweeps through linking faces, or mapped blocks. Boundary element faces are tagged with their B-rep faces (named as in geometry.bodies; by index when a face has no name; `weld`, `weld_distance` and `feature_angle` as in mesh.tet.generate), so sets by geometry resolve. Edge seeds (mesh.seed.edge) set the counts and spacing of their edges: exact seeds are held, and seeds that cannot all hold (opposite sides of a mapped face, the layers between two levels) are an error naming them. Reports per body the method, counts and quality; a body no structured method fits is an error that says why"#,
form: Some(super::forms::HEX_GENERATE),
kind: CommandKind::Model,
requires: &[Requirement::Geometry],
+17
View File
@@ -6,6 +6,7 @@ mod contact;
pub(crate) mod datum;
pub(crate) mod deck;
pub(crate) mod define;
pub mod diagnostics;
mod edit;
pub(crate) mod file;
pub(crate) mod forms;
@@ -13,6 +14,7 @@ mod geometry;
mod hex;
pub(crate) mod mesh;
pub use datum::edit_params as datum_edit_params;
pub use mesh::analytic_surfaces;
mod mirror;
mod model;
@@ -23,6 +25,8 @@ pub(crate) mod seeds;
pub(crate) mod solve;
mod symmetry;
mod tags;
pub mod update;
pub(crate) mod view;
use crate::schema::ProblemKind;
use fieldcraft_model::MAX_LABEL;
@@ -41,7 +45,15 @@ pub static ALL: &[CommandSpec] = &[
geometry::IMPORT_MESH,
datum::DATUM_PLANE,
datum::DATUM_AXIS,
datum::DATUM_POINT,
datum::DATUM_EDIT,
datum::DATUM_AXIS_EDIT,
datum::DATUM_POINT_EDIT,
datum::DATUM_DELETE,
view::HIDE,
view::SHOW,
view::ISOLATE,
view::SHOW_ALL,
datum::PARTITION,
datum::PARTITION_DELETE,
mesh::NODE_ADD,
@@ -143,6 +155,11 @@ pub static ALL: &[CommandSpec] = &[
results::FRAME,
results::REACTIONS,
results::CLEAR,
diagnostics::CRASH_REPORTS,
diagnostics::CRASH_REPORT,
diagnostics::BUG_REPORT,
diagnostics::CRASH_TEST,
update::CHECK,
];
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
+59
View File
@@ -0,0 +1,59 @@
//! `app.update.check`: is a newer FieldCraft published? The same check as the desktop app's
//! Help › Check for Updates and `fieldcraft-cli update --check` (docs/updates.md). It only
//! reports: nothing is downloaded or installed, and no dialog opens.
//!
//! Checking reaches the network, so it is switched on only by the programs that may
//! ([`enable_checks`]: the desktop app and the CLI); everywhere else (tests, scratch sessions,
//! the browser) the command refuses without asking anything.
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::Value;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const CHECK: CommandSpec = CommandSpec {
id: "app.update.check",
label: "Check for Updates",
params: r#"{} — asks GitHub Releases for the latest published FieldCraft release and compares it with this version: current, latest, newer, url (its page and release notes), published, signed (whether it can be verified and installed from the app) and asset (the file this install would download; null when it cannot update itself). Reports only: nothing is downloaded or installed. Desktop app and fieldcraft-cli only"#,
form: Some(&[]),
kind: CommandKind::Session,
requires: &[],
run: check,
};
static ENABLED: AtomicBool = AtomicBool::new(false);
/// Let `app.update.check` reach the release server (the desktop app and the CLI do).
pub fn enable_checks(on: bool) {
ENABLED.store(on, Ordering::SeqCst);
}
#[cfg(target_arch = "wasm32")]
fn check(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Params("not in the browser: the web app offers Reload to update when a new version is deployed".into()))
}
#[cfg(not(target_arch = "wasm32"))]
fn check(_: &mut Session, _: &Value) -> Result<Value> {
if !ENABLED.load(Ordering::SeqCst) {
return Err(EngineError::Params("update checks are off in this session (the desktop app and fieldcraft-cli check for updates)".into()));
}
let check = fieldcraft_update::net::Client::from_env().check().map_err(|e| EngineError::Params(e.to_string()))?;
let asset = fieldcraft_update::install::here().asset_name(&check.latest.version, std::env::consts::ARCH);
Ok(check.to_json(asset.as_deref()))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
/// Off unless a program switches it on: tests and scratch sessions never reach the network.
#[test]
fn checks_are_off_unless_enabled() {
let e = Session::default().execute("app.update.check", &json!({})).unwrap_err();
assert!(e.to_string().contains("update checks are off"), "{e}");
}
}
+219
View File
@@ -0,0 +1,219 @@
//! What the viewport hides: `view.hide`, `view.show`, `view.isolate` and `view.show_all`.
//!
//! The hidden items (bodies, imported surfaces, datums, boundary conditions, loads, reference
//! points, couplings) are view state ([`fieldcraft_io::View`]): saved with the model and marking
//! it unsaved, but not a definition, so they are neither journalled nor undone and they leave
//! results current. A hidden item is not drawn, so it cannot be picked either. An item that goes
//! away (deleted, or a body cut into cells) is forgotten.
use std::collections::BTreeSet;
use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN};
use serde_json::Value;
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const HIDE: CommandSpec = CommandSpec {
id: "view.hide",
label: "Hide",
params: concat!(
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
" — hides the items in the viewport (and from picking); view state, saved with the model"
),
form: Some(super::forms::VIEW_ITEMS),
kind: CommandKind::Session,
requires: &[],
run: |s, p| change(s, p, Change::Hide),
};
pub const SHOW: CommandSpec = CommandSpec {
id: "view.show",
label: "Show",
params: concat!(
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
" — shows hidden items again"
),
form: Some(super::forms::VIEW_ITEMS),
kind: CommandKind::Session,
requires: &[],
run: |s, p| change(s, p, Change::Show),
};
pub const ISOLATE: CommandSpec = CommandSpec {
id: "view.isolate",
label: "Isolate",
params: concat!(
r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#,
" — shows only these items: every other one is hidden (view.show_all brings them back)"
),
form: Some(super::forms::VIEW_ITEMS),
kind: CommandKind::Session,
requires: &[],
run: |s, p| change(s, p, Change::Isolate),
};
pub const SHOW_ALL: CommandSpec = CommandSpec {
id: "view.show_all",
label: "Show All",
params: "{}",
form: Some(&[]),
kind: CommandKind::Session,
requires: &[],
run: |s, _| {
s.view.hidden.clear();
Ok(Value::Null)
},
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Change {
Hide,
Show,
Isolate,
}
/// Every item of `kind` the session has, by name.
pub(crate) fn names(s: &Session, kind: ItemKind) -> Vec<String> {
let m = &s.model;
match kind {
ItemKind::Body => s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(),
ItemKind::Surface => s.surfaces.iter().map(|x| x.name.clone()).collect(),
ItemKind::Datum => m.datums().keys().cloned().collect(),
ItemKind::BoundaryCondition => m.boundary_conditions().keys().cloned().collect(),
ItemKind::Load => m.loads().keys().cloned().collect(),
ItemKind::ReferencePoint => m.reference_points().keys().cloned().collect(),
ItemKind::Coupling => m.couplings().keys().cloned().collect(),
}
}
fn exists(s: &Session, item: &Item) -> bool {
let m = &s.model;
match item.kind {
ItemKind::Body => s.design.as_ref().is_some_and(|d| d.body(&item.name).is_some()),
ItemKind::Surface => s.surfaces.iter().any(|x| x.name == item.name),
ItemKind::Datum => m.datum(&item.name).is_some(),
ItemKind::BoundaryCondition => m.boundary_conditions().contains_key(&item.name),
ItemKind::Load => m.loads().contains_key(&item.name),
ItemKind::ReferencePoint => m.reference_points().contains_key(&item.name),
ItemKind::Coupling => m.couplings().contains_key(&item.name),
}
}
/// Forget hidden items the session no longer has.
pub(crate) fn forget_missing(s: &mut Session) {
if s.view.hidden.is_empty() {
return;
}
let gone: Vec<Item> = s.view.hidden.iter().filter(|i| !exists(s, i)).cloned().collect();
for i in gone {
s.view.hidden.remove(&i);
}
}
impl Session {
/// The analysis setup's symbols, without the definitions the view hides.
pub fn glyphs(&self) -> fieldcraft_render::glyphs::Glyphs {
use fieldcraft_render::glyphs::Definition;
fieldcraft_render::glyphs::Glyphs::from_model_hiding(&self.model, &|definition, name| {
let kind = match definition {
Definition::BoundaryCondition => ItemKind::BoundaryCondition,
Definition::Load => ItemKind::Load,
Definition::ReferencePoint => ItemKind::ReferencePoint,
Definition::Coupling => ItemKind::Coupling,
Definition::Contact | Definition::Tie | Definition::Step => return false,
};
self.view.is_hidden(kind, name)
})
}
/// Whether the view shows item `name` of `kind`.
pub fn shows(&self, kind: ItemKind, name: &str) -> bool {
!self.view.is_hidden(kind, name)
}
}
fn change(s: &mut Session, p: &Value, how: Change) -> Result<Value> {
let items: Vec<Item> = super::req(p, "items")?;
if items.len() > MAX_HIDDEN {
return Err(EngineError::Params(format!("at most {MAX_HIDDEN} items")));
}
if let Some(i) = items.iter().find(|i| !exists(s, i)) {
let kind = serde_json::to_value(i.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
return Err(EngineError::Params(format!("no {} `{}`", kind.replace('_', " "), i.name.chars().take(100).collect::<String>())));
}
match how {
Change::Hide => s.view.hidden.extend(items),
Change::Show => {
for i in &items {
s.view.hidden.remove(i);
}
}
Change::Isolate => {
let keep: BTreeSet<Item> = items.into_iter().collect();
let all = ItemKind::ALL.into_iter().flat_map(|kind| names(s, kind).into_iter().map(move |name| Item { kind, name }));
let rest: BTreeSet<Item> = all.filter(|i| !keep.contains(i)).take(MAX_HIDDEN).collect();
s.view.hidden = rest;
}
}
Ok(Value::Null)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::cmd::datum::tests::plate;
fn item(kind: ItemKind, name: &str) -> Value {
json!({"kind": kind, "name": name})
}
#[test]
fn hiding_is_saved_view_state_not_an_edit() {
let (mut s, dir) = plate();
s.execute("geometry.datum", &json!({"name": "Sym", "by": {"type": "offset", "plane": "zx"}})).unwrap();
s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "coordinates", "point": [1, 2, 3]}})).unwrap();
let file = dir.join("hide.fieldcraft");
s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap();
let (journal, revision) = (s.journal.len(), s.revision());
assert!(!s.is_dirty());
s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
assert_eq!(s.view.hidden.iter().collect::<Vec<_>>(), [&Item { kind: ItemKind::Datum, name: "Sym".into() }]);
// Unsaved, but no edit: nothing journalled, the revision (and so any result) unchanged.
assert!(s.is_dirty());
assert_eq!((s.journal.len(), s.revision()), (journal, revision));
// Showing it again is back to the saved state.
s.execute("view.show", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
assert!(!s.is_dirty());
// Hidden state survives save and open.
s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap();
s.execute("file.save", &json!({})).unwrap();
let mut t = Session::default();
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
assert!(t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P"));
assert!(!t.is_dirty());
// Isolate hides every other item; show_all brings them back.
let body = names(&t, ItemKind::Body).remove(0);
t.execute("view.isolate", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap();
assert!(t.view.is_hidden(ItemKind::Body, &body) && t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P"));
t.execute("view.show_all", &json!({})).unwrap();
assert!(t.view.is_empty());
// An item deleted is forgotten.
t.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap();
t.execute("geometry.datum.delete", &json!({"name": "P"})).unwrap();
assert!(t.view.is_empty());
// Unknown items and hostile parameters change nothing.
for p in [json!({}), json!({"items": "x"}), json!({"items": [item(ItemKind::Load, "Nope")]}), json!({"items": [{"kind": "x", "name": "A"}]})]
{
assert!(t.execute("view.hide", &p).is_err(), "{p}");
}
assert!(t.view.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}
File diff suppressed because it is too large. Load diff
+277
View File
@@ -0,0 +1,277 @@
use super::*;
use crate::Session;
fn fresh_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("fieldcraft-crash-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
d
}
fn build() -> Build {
Build { version: "9.9.9", commit: "abc1234" }
}
fn node(label: u32) -> Value {
json!({"label": label, "xyz": [f64::from(label), 0.0, 0.0]})
}
/// The child half of the process tests: does nothing unless a parent asks, then installs the
/// hook and crashes the way `FIELDCRAFT_CRASH_CHILD` says.
#[test]
fn child() {
let (Some(dir), Ok(mode)) = (std::env::var_os("FIELDCRAFT_CRASH_CHILD_DIR"), std::env::var("FIELDCRAFT_CRASH_CHILD")) else {
return;
};
let dir = PathBuf::from(dir);
if mode == "replay" {
// Replay the one report (in a process of its own: the replay replaces the panic hook).
let id = list(&dir)[0].id.clone();
let r = replay(&read(&dir, &id).unwrap(), &std::env::temp_dir()).unwrap();
let steps: Vec<_> = r.steps.iter().map(|(c, r)| json!({"command": c, "ok": r.is_ok()})).collect();
println!("REPLAY {}", json!({"reproduced": r.reproduced, "nodes": r.session.model.nodes().len(), "steps": steps}));
return;
}
let fresh = install(dir.clone(), build());
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
if mode == "idle" {
end_session();
return;
}
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("mesh.node.add", &node(1)).unwrap();
s.execute("mesh.node.add", &node(2)).unwrap();
// Commands that only read are not kept.
for _ in 0..50 {
s.execute("model.info", &json!({})).unwrap();
}
match mode.as_str() {
"command" => {
for _ in 0..3 {
let r = s.execute("diagnostics.crash_test", &json!({"mode": "panic"}));
assert!(matches!(r, Err(crate::EngineError::Internal { .. })), "{r:?}");
}
assert_eq!(s.model.nodes().len(), 2, "the model is unchanged");
}
"recovered" => {
assert_eq!(s.execute("diagnostics.crash_test", &json!({"mode": "recovered"})).unwrap()["caught"], true);
}
"thread" => {
let r = std::thread::Builder::new()
.name("worker".into())
.spawn(|| std::panic::panic_any(String::from("worker thread 42 failed")))
.unwrap()
.join();
assert!(r.is_err());
}
"abort" => {
activity("control ui.capture");
let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"}));
}
"autosave" => {
// The journal starts again from each autosave.
let mut a = crate::autosave::Autosaver::new(&dir.join("recovery")).unwrap();
assert!(a.save(&s).unwrap());
s.execute("mesh.node.add", &node(3)).unwrap();
activity("control ui.capture");
std::process::abort();
}
_ => {}
}
end_session();
}
/// Run [`child`] in a new process; returns its stdout and whether it succeeded.
fn run_child(dir: &Path, mode: &str) -> (String, bool) {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "crash::tests::child", "--test-threads=1", "--nocapture"])
.env("FIELDCRAFT_CRASH_CHILD_DIR", dir)
.env("FIELDCRAFT_CRASH_CHILD", mode)
.env("FIELDCRAFT_CRASH_TEST", "1")
.env("RUST_BACKTRACE", "0")
.output()
.unwrap();
(String::from_utf8_lossy(&out.stdout).into_owned(), out.status.success())
}
fn printed(out: &str, tag: &str) -> Value {
let line = out.lines().find_map(|l| l.split_once(tag).map(|(_, rest)| rest)).unwrap_or_else(|| panic!("no {tag} in {out}"));
serde_json::from_str(line.trim()).unwrap()
}
fn only_report(dir: &Path) -> (Summary, Value) {
let all = list(dir);
assert_eq!(all.len(), 1, "{all:?}");
let s = all.into_iter().next().unwrap();
let v = read(dir, &s.id).unwrap();
(s, v)
}
fn nodes_in(model: &str) -> usize {
let mut s = Session::default();
s.execute("file.open", &json!({"path": model})).unwrap();
s.model.nodes().len()
}
#[test]
fn a_command_panic_is_reported_once_with_the_model_and_the_command_and_replays() {
let d = fresh_dir("command");
let (_, ok) = run_child(&d, "command");
assert!(ok, "the panics were caught");
let (s, v) = only_report(&d);
assert_eq!(s.kind, "command_panic");
assert_eq!(s.count, 3, "repeats are counted, not written again");
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"));
assert!(s.replayable);
assert_eq!(v["format"], FORMAT);
assert_eq!(v["app"]["version"], "9.9.9");
assert_eq!(v["system"]["os"], std::env::consts::OS);
assert!(v["message"].as_str().unwrap().contains("an internal error in a command"), "{v}");
assert!(v["location"].as_str().unwrap().contains("diagnostics.rs"), "{v}");
let bt = v["backtrace"].as_array().unwrap();
assert!(bt.iter().any(|f| f.as_str().unwrap().contains("crash_test")), "{bt:?}");
assert!(!bt.iter().any(|f| f.as_str().unwrap().contains("crash::hook")), "the hook's own frames are dropped: {bt:?}");
assert_eq!(v["recent_commands"][0]["command"], "mesh.node.add");
assert_eq!(v["recent_commands"].as_array().unwrap().len(), 3, "read-only commands are not kept: {v}");
assert_eq!(v["recent_commands"][2]["ok"], false);
// The model from before the command, and the command: together they reproduce the crash.
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2);
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"], json!([{"command": "diagnostics.crash_test", "params": {"mode": "panic"}}]));
assert_eq!(v["reproduce"], format!("fieldcraft-cli crashes replay {}", s.id));
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "a clean exit leaves no journal");
// The replay reaches the same internal error, from the same model.
let (out, ok) = run_child(&d, "replay");
assert!(ok, "{out}");
let r = printed(&out, "REPLAY ");
assert_eq!(r["reproduced"], true, "{r}");
assert_eq!(r["nodes"], 2, "the replay ends in the state the crash left: {r}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_panic_caught_inside_a_command_is_reported_as_recovered() {
let d = fresh_dir("recovered");
let (_, ok) = run_child(&d, "recovered");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "recovered_panic");
assert!(!s.replayable, "the command did not fail");
assert!(v["message"].as_str().unwrap().contains("caught inside"), "{v}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_panic_outside_commands_is_reported_from_the_journal() {
let d = fresh_dir("thread");
let (_, ok) = run_child(&d, "thread");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "panic");
assert_eq!(v["thread"], "worker");
assert!(s.message.contains("worker thread 42 failed"));
// The journal's commands from a new model rebuild it.
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"].as_array().unwrap().len(), 2, "{replay}");
// A panic the hook reported is no unclean exit at the next start.
let (out, _) = run_child(&d, "abort");
let fresh = printed(&out, "FRESH ");
assert_eq!(fresh.as_array().unwrap().len(), 1, "the earlier report, seen once: {fresh}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_unclean_exit_is_found_at_the_next_start_with_its_commands() {
let d = fresh_dir("abort");
let (_, ok) = run_child(&d, "abort");
assert!(!ok, "the child aborted");
assert!(list(&d).is_empty(), "nothing wrote a report while it died");
assert_eq!(fs::read_dir(d.join("sessions")).unwrap().count(), 1, "its journal is left behind");
let (out, ok) = run_child(&d, "idle");
assert!(ok);
let fresh = printed(&out, "FRESH ");
assert_eq!(fresh[0]["kind"], "unclean_exit", "{fresh}");
let (s, v) = only_report(&d);
assert_eq!(s.command.as_deref(), Some("diagnostics.crash_test"), "the command in flight");
assert!(s.message.contains("ended without a clean exit while running `diagnostics.crash_test`"), "{}", s.message);
assert_eq!(v["last_activity"], "control ui.capture");
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
let ids: Vec<&str> = replay["commands"].as_array().unwrap().iter().map(|c| c["command"].as_str().unwrap()).collect();
assert_eq!(ids, ["mesh.node.add", "mesh.node.add", "diagnostics.crash_test"]);
assert!(fs::read_dir(d.join("sessions")).unwrap().next().is_none(), "the dead journal is gone");
// Told once: the next start offers nothing new.
let (out, _) = run_child(&d, "idle");
assert_eq!(printed(&out, "FRESH "), json!([]));
let _ = fs::remove_dir_all(&d);
}
#[test]
fn an_unclean_exit_after_an_autosave_starts_from_the_autosave() {
let d = fresh_dir("autosave");
let (_, ok) = run_child(&d, "autosave");
assert!(!ok);
run_child(&d, "idle");
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("between commands (last: control ui.capture)"), "{}", s.message);
assert_eq!(nodes_in(v["model_path"].as_str().unwrap()), 2, "the autosaved model");
let replay: Value = serde_json::from_slice(&fs::read(v["replay_path"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(replay["commands"], json!([{"command": "mesh.node.add", "params": node(3)}]), "only what came after the autosave");
let (out, ok) = run_child(&d, "replay");
assert!(ok, "{out}");
assert_eq!(printed(&out, "REPLAY ")["nodes"], 3, "the replay rebuilds the model the app had");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn journals_parse_and_hostile_ids_are_refused() {
let text = [
r#"{"start":"x","pid":1,"version":"1"}"#,
r#"{"base":"new"}"#,
r#"{"run":"a","params":{}}"#,
r#"{"ok":true}"#,
r#"{"activity":"control ui.capture"}"#,
r#"{"run":"b","params":{"x":1}}"#,
r#"{"ok":false,"error":"no"}"#,
"not json",
r#"{"run":"c","params":{}}"#,
]
.join("\n");
let j = parse_journal(&text);
assert_eq!(j.replay, vec![json!({"command": "a", "params": {}})]);
assert_eq!(j.recent.len(), 2);
assert_eq!(j.in_flight, Some(json!({"command": "c", "params": {}})));
assert_eq!(j.activity.as_deref(), Some("control ui.capture"));
assert!(j.complete && !j.reported);
let d = fresh_dir("hostile");
fs::create_dir_all(&d).unwrap();
for id in ["", "../x", "a/b", &"f".repeat(65)] {
assert!(read(&d, id).is_err(), "{id}");
assert_eq!(clear(&d, Some(id)), 0);
}
let _ = fs::remove_dir_all(&d);
}
#[test]
fn signatures_ignore_numbers() {
assert_eq!(signature("a.rs:1", "node 12 of 400"), signature("a.rs:1", "node 7 of 3"));
assert_ne!(signature("a.rs:1", "node 12"), signature("a.rs:2", "node 12"));
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(stamp(1_760_436_000), "20251014-100000");
}
#[test]
fn reports_are_pruned_to_the_limits() {
let d = fresh_dir("prune");
for i in 0..(MAX_REPORTS + 5) {
let id = format!("20261014-1000{:02}-{:016x}", i % 60, i + 1);
fs::create_dir_all(d.join(&id)).unwrap();
let kind = if i == MAX_REPORTS + 4 { "recovered_panic" } else { "panic" };
fs::write(d.join(&id).join("report.json"), json!({"kind": kind, "message": "m"}).to_string()).unwrap();
}
prune(&d, now());
let left = list(&d);
assert_eq!(left.len(), MAX_REPORTS);
assert!(left.iter().all(|r| r.kind == "panic"), "recovered ones go first");
assert_eq!(clear(&d, None), MAX_REPORTS);
let _ = fs::remove_dir_all(&d);
}
+42 -2
View File
@@ -13,17 +13,23 @@
#![forbid(unsafe_code)]
pub mod autosave;
pub mod bug_report;
pub mod catalogue;
pub mod cmd;
pub mod crash;
pub mod logfile;
pub use cmd::analytic_surfaces;
pub use cmd::replace::{MESHERS, Replacement, replacement};
pub use cmd::update::enable_checks as enable_update_checks;
pub use fieldcraft_io::{Item as ViewItem, ItemKind};
pub mod history;
pub mod progress;
pub mod requirement;
pub mod results;
pub mod sample;
pub mod schema;
pub mod usage;
pub mod view;
use std::collections::BTreeMap;
@@ -238,6 +244,18 @@ pub struct Session {
/// Identifies the open document: it changes when another model, sample or design is opened
/// in place of the current one, never with an edit (see [`Session::opened`]).
opened: u64,
/// What the viewport hides (`view.*`): saved with the model, but no edit of it, so it is
/// neither journalled nor undone, and it leaves results current.
pub view: fieldcraft_io::View,
/// The view as last saved (or opened): a change to it is unsaved too.
saved_view: fieldcraft_io::View,
/// This is the app's own model: its top-level commands go into the crash journal
/// ([`crash`]), so an unclean exit can be replayed. Off for scratch sessions and tests.
pub crash_journal: bool,
/// Where this session's commands come from, for the usage analytics ([`usage`]): the app
/// sets it on its own model (and the control channel and the command search while they
/// run); `None` (scratch sessions, the CLI, tests) counts nothing.
pub origin: Option<usage::Origin>,
}
impl Session {
@@ -246,10 +264,27 @@ impl Session {
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
// An id from hostile input is not echoed in full.
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?;
self.availability(spec).map_err(EngineError::Unavailable)?;
let timer = usage::Timer::start(self.origin);
let elements = self.model.elements().len();
let result = self.availability(spec).map_err(EngineError::Unavailable).and_then(|()| self.run_command(spec, params));
if timer.counting() {
let milestone = usage::milestone(spec.id, (elements, self.model.elements().len()));
let sample =
(spec.id == "file.sample").then(|| params.get("name").and_then(Value::as_str).and_then(sample::find)).flatten().map(|s| s.name);
timer.done(spec.id, spec.kind, result.as_ref().err(), milestone, sample);
}
result
}
/// [`Session::execute`] once the command is found and can run.
fn run_command(&mut self, spec: &'static CommandSpec, params: &Value) -> Result<Value> {
let id = spec.id;
// Session commands (open, save, undo, redo) change the session only in their last step,
// after everything that can fail, so they need no copy of the model to restore.
let before = (spec.kind == CommandKind::Model).then(|| (self.snapshot(), self.document()));
let scope = crash::Scope::enter();
let journaled = crash::command_begin(&scope, self, id, params);
let (revision, opened) = (self.revision, self.opened);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match outcome {
Ok(Err(EngineError::Param(p))) => Err(EngineError::Param(match spec.form {
@@ -275,8 +310,12 @@ impl Session {
self.journal.push((id.to_string(), params.clone()));
self.revision = next_revision();
self.record_undo(before);
// An item deleted (or a body cut into cells) is no longer hidden.
cmd::view::forget_missing(self);
}
}
drop(scope);
crash::command_done(self, journaled, self.revision != revision || self.opened != opened, id, params, &result);
result
}
@@ -315,7 +354,7 @@ impl Session {
/// Whether the session has changes its file does not hold. Undoing back to the saved state
/// makes it clean again.
pub fn is_dirty(&self) -> bool {
self.saved_revision.unwrap_or(0) != self.revision
self.saved_revision.unwrap_or(0) != self.revision || self.view != self.saved_view
}
/// The stored result of a step, if it was solved in the current state (an edit makes it stale;
@@ -356,6 +395,7 @@ impl Session {
pub(crate) fn mark_saved(&mut self, path: String) {
self.path = Some(path);
self.saved_revision = Some(self.revision);
self.saved_view = self.view.clone();
}
}
+180
View File
@@ -0,0 +1,180 @@
//! The log file: what the desktop app logs (through the `log` facade) goes to
//! `<data folder>/logs/fieldcraft.log`, one line per record:
//! `2026-10-14T10:03:22Z WARN [4242] fieldcraft_engine: message`. Warnings and errors also go to
//! the standard error stream.
//!
//! Bounded: past [`MAX_BYTES`] the file becomes `fieldcraft.log.1` (replacing the one before) and
//! a new one starts, so the logs never take more than twice that. A record is cut at
//! [`MAX_LINE`] bytes. The process id on every line lets a crash report take only the lines of
//! the run that crashed ([`tail`]), even when two apps share the file.
//!
//! Nothing is installed in tests, the CLI or the web build (which logs to the browser console).
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// The log file's name.
pub const FILE_NAME: &str = "fieldcraft.log";
/// The log file's size limit.
pub const MAX_BYTES: u64 = 2 << 20;
/// A record's size limit.
pub const MAX_LINE: usize = 4 << 10;
struct FileLogger {
path: PathBuf,
level: log::LevelFilter,
out: Mutex<Out>,
}
struct Out {
file: Option<File>,
bytes: u64,
}
static LOGGER: OnceLock<FileLogger> = OnceLock::new();
/// Send the `log` records at `level` and above to the log file in `dir` (created if needed).
/// Returns the file's path. Call once; later calls change nothing.
pub fn install(dir: &Path, level: log::LevelFilter) -> Result<PathBuf, String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(FILE_NAME);
let file = open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let bytes = file.metadata().map_or(0, |m| m.len());
let logger = FileLogger { path: path.clone(), level, out: Mutex::new(Out { file: Some(file), bytes }) };
if LOGGER.set(logger).is_err() {
return Err("a log file is already installed".into());
}
if let Some(l) = LOGGER.get() {
log::set_logger(l).map_err(|e| e.to_string())?;
log::set_max_level(level);
}
Ok(path)
}
fn open(path: &Path) -> std::io::Result<File> {
OpenOptions::new().create(true).append(true).open(path)
}
/// The log file, once [`install`]ed.
pub fn path() -> Option<&'static Path> {
LOGGER.get().map(|l| l.path.as_path())
}
/// The last `max` bytes of the log file's lines written by process `pid` (every line when
/// `None`), from the rotated file too when the current one is short. `None` without a log file.
pub fn tail(pid: Option<u32>, max: usize) -> Option<String> {
let path = path().map(Path::to_path_buf).or_else(default_path)?;
Some(tail_of(&path, pid, max))
}
/// [`tail`] of the log file at `path`.
fn tail_of(path: &Path, pid: Option<u32>, max: usize) -> String {
let mut text = String::new();
for p in [rotated(path), path.to_path_buf()] {
// The whole file (it is bounded): another process's lines may come between.
text.push_str(&read_end(&p, MAX_BYTES));
}
let mark = pid.map(|p| format!(" [{p}] "));
let mut lines: Vec<&str> = text.lines().filter(|l| mark.as_deref().is_none_or(|m| l.contains(m))).collect();
let mut total = 0;
let mut keep = lines.len();
for (i, l) in lines.iter().enumerate().rev() {
total += l.len() + 1;
if total > max {
break;
}
keep = i;
}
lines.drain(..keep);
lines.join("\n")
}
/// The log file of this computer's app (for a crash report written before [`install`]).
fn default_path() -> Option<PathBuf> {
fieldcraft_io::recovery::data_dir().map(|d| d.join("logs").join(FILE_NAME))
}
/// The file the log rotates into.
pub fn rotated(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".1");
PathBuf::from(s)
}
/// The last `max` bytes of a file, from the start of a line.
fn read_end(path: &Path, max: u64) -> String {
let Ok(mut f) = File::open(path) else { return String::new() };
let len = f.metadata().map_or(0, |m| m.len());
let from = len.saturating_sub(max);
let mut buf = Vec::new();
if f.seek(SeekFrom::Start(from)).is_err() || f.take(max).read_to_end(&mut buf).is_err() {
return String::new();
}
let text = String::from_utf8_lossy(&buf).into_owned();
match (from > 0, text.find('\n')) {
(true, Some(i)) => text.get(i + 1..).unwrap_or_default().to_string(),
_ => text,
}
}
impl log::Log for FileLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let message = crate::crash::limited(&record.args().to_string(), MAX_LINE).replace('\n', "\n ");
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let line = format!("{} {:<5} [{}] {}: {message}\n", crate::crash::utc(now), record.level(), std::process::id(), record.target());
if record.level() <= log::Level::Warn {
eprint!("{line}");
}
let Ok(mut out) = self.out.lock() else { return };
if out.bytes + line.len() as u64 > MAX_BYTES {
out.file = None;
let _ = fs::rename(&self.path, rotated(&self.path));
out.file = open(&self.path).ok();
out.bytes = 0;
}
if let Some(f) = &mut out.file
&& f.write_all(line.as_bytes()).is_err()
{
out.file = None;
}
out.bytes += line.len() as u64;
}
fn flush(&self) {
if let Ok(mut out) = self.out.lock()
&& let Some(f) = &mut out.file
{
let _ = f.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_keeps_one_process_and_the_end() {
let d = std::env::temp_dir().join(format!("fieldcraft-logtail-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
let p = d.join(FILE_NAME);
fs::write(rotated(&p), "t INFO [7] a: old seven\nt INFO [8] a: old eight\n").unwrap();
fs::write(&p, "t INFO [7] a: one\nt WARN [8] a: other\nt INFO [7] a: two\n").unwrap();
assert_eq!(tail_of(&p, Some(7), 1000), "t INFO [7] a: old seven\nt INFO [7] a: one\nt INFO [7] a: two");
assert_eq!(tail_of(&p, Some(7), 40), "t INFO [7] a: one\nt INFO [7] a: two");
assert_eq!(tail_of(&p, None, 1000).lines().count(), 5);
// From the middle of a line, the partial line is dropped.
assert_eq!(read_end(&p, 20), "t INFO [7] a: two\n");
let _ = fs::remove_dir_all(&d);
}
}
+14 -2
View File
@@ -24,10 +24,12 @@ pub enum Requirement {
Design,
/// Geometry to mesh: an open design or an imported surface.
Geometry,
/// A datum plane or axis.
/// A datum plane, axis or point.
Datum,
/// A datum plane (what a partition cuts by).
DatumPlane,
DatumAxis,
DatumPoint,
/// A partition of the design.
Partition,
/// An edge seed.
@@ -74,6 +76,8 @@ impl Requirement {
Requirement::Geometry => s.design.is_some() || !s.surfaces.is_empty(),
Requirement::Datum => !m.datums().is_empty(),
Requirement::DatumPlane => m.datums().values().any(|d| d.plane().is_some()),
Requirement::DatumAxis => m.datums().values().any(|d| d.axis().is_some()),
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
Requirement::Partition => !m.partitions().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
@@ -110,8 +114,10 @@ impl Requirement {
match self {
Requirement::Design => "an open design",
Requirement::Geometry => "geometry (an open design or an imported surface)",
Requirement::Datum => "a datum plane or axis",
Requirement::Datum => "a datum",
Requirement::DatumPlane => "a datum plane",
Requirement::DatumAxis => "a datum axis",
Requirement::DatumPoint => "a datum point",
Requirement::Partition => "a partition",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes",
@@ -140,6 +146,8 @@ impl Requirement {
match self {
Requirement::Design | Requirement::Geometry => "geometry.open",
Requirement::Datum | Requirement::DatumPlane => "geometry.datum",
Requirement::DatumAxis => "geometry.datum.axis",
Requirement::DatumPoint => "geometry.datum.point",
Requirement::Partition => "geometry.partition",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add",
@@ -195,6 +203,8 @@ impl crate::schema::Collection {
C::Body => Requirement::Design,
C::Datum => Requirement::Datum,
C::DatumPlane => Requirement::DatumPlane,
C::DatumAxis => Requirement::DatumAxis,
C::DatumPoint => Requirement::DatumPoint,
C::Partition => Requirement::Partition,
}
}
@@ -333,6 +343,8 @@ mod tests {
C::Body,
C::Datum,
C::DatumPlane,
C::DatumAxis,
C::DatumPoint,
C::Partition,
];
let empty = Session::default();
+42 -94
View File
@@ -497,101 +497,36 @@ pub const CRANK_ARM: &str = include_str!("../samples/crank-arm.json");
/// The crank arm's pedal load (N): a standing rider's weight on one pedal.
pub const PEDAL_LOAD: f64 = 1500.0;
/// The cells of the half `CrankArm/a` (y < 0) after [`crank_prepare`], from the spindle to the
/// pedal: the spindle boss and the arm's root (`X30`), the pocket's spindle end below the
/// diagonal `D1`, the arm between `D1` and `X60`, the middle of the arm (`X60` to `X110`), the arm
/// between `X110` and `D2`, the pocket's pedal end beyond `D2`, and the pedal boss (`X148`).
pub const CRANK_CELLS: [&str; 7] = [
"CrankArm/a/a",
"CrankArm/a/b/a",
"CrankArm/a/b/b/a",
"CrankArm/a/b/b/b/a",
"CrankArm/a/b/b/b/b/a",
"CrankArm/a/b/b/b/b/b/a",
"CrankArm/a/b/b/b/b/b/b",
];
/// The half of the crank [`crank_prepare`] cuts off on the symmetry plane (y < 0), the one
/// meshed; its mirror image is `CrankArm/b`.
pub const CRANK_HALF: &str = "CrankArm/a";
/// The diagonals' angles from the arm's axis (degrees): `D1` through the centre of the pocket's
/// spindle-end corner fillet (53, −2), `D2` through the pedal-end one (125, −2), each radial to
/// its fillet so it meets the pocket wall square. Steeper than 45° from the pocket's floor edge
/// (y = 0) on its inner side and shallower than 45° from the arm's edge on its outer side, so the
/// pocket's middle keeps its corners there and the cells beyond it do not gain one.
const CRANK_DIAGONALS: [f64; 2] = [40.0, 32.0];
/// [`CRANK_ARM`] meshed structured all-hex (hex20 of 3 mm) from a partitioned half and mirrored,
/// in 6061-T6 aluminium: the square bore's faces and corner fillets (set `spindle`) fixed,
/// [`CRANK_ARM`] meshed structured all-hex (hex20 of 3 mm) from its half and mirrored, in
/// 6061-T6 aluminium: the square bore's faces and corner fillets (set `spindle`) fixed,
/// [`PEDAL_LOAD`] on the pedal hole (set `pedal`), in the crank's plane and across the arm (−y).
/// Step `Pedal` (linear static). The faces are picked by their surfaces, not their names: the
/// square bore's are the vertical planes 6.35 mm from the spindle axis and the R1.5 cylinders
/// beside it, the pedal hole is the Ø14 cylinder on the pedal axis.
///
/// The mesh: [`crank_prepare`] cuts the crank on its symmetry plane and the half `CrankArm/a`
/// into the seven [`CRANK_CELLS`], each a stack of extrusions whose cross-section splits into
/// regions that map: four-sided regions (transfinite), the half ring round each bore, and the
/// pocket's four corners cut off by the partitions, three-sided (the triangle primitive). Exact
/// edge seeds give each cut the same divisions in the two cells it divides, so `mesh.cells`
/// sweeps every cell (`extrude`) and merges them node for node; `mesh.mirror` reflects the half
/// onto `CrankArm/b`, joined on the plane.
/// The mesh: [`crank_prepare`] cuts the crank on its symmetry plane only; `mesh.hex.generate`
/// extrudes the half [`CRANK_HALF`], decomposing its cross-section into blocks itself (half
/// rings round both bores, the pocket's corners as triangle primitives, four-sided blocks along
/// the arm), with no seeds; `mesh.mirror` reflects it onto `CrankArm/b`, joined on the plane.
pub fn crank_arm(bodies: &[Shape]) -> Value {
let cells = CRANK_CELLS;
let mut mesh = crank_seeds();
mesh.push(json!({"command": "mesh.cells", "params": {"bodies": cells, "default": "extrude", "size": 3, "order": 2}}));
// Each cell's image is part of the uncut half.
let images: serde_json::Map<String, Value> = cells.iter().map(|c| (c.to_string(), json!("CrankArm/b"))).collect();
mesh.push(json!({"command": "mesh.mirror", "params": {"plane": "Sym", "bodies": cells, "as": images}}));
let mesh = vec![
json!({"command": "mesh.hex.generate", "params": {"body": CRANK_HALF, "method": "extrude", "size": 3, "order": 2}}),
json!({"command": "mesh.mirror", "params": {"plane": "Sym", "bodies": [CRANK_HALF], "as": {CRANK_HALF: "CrankArm/b"}}}),
];
crank_model(bodies, mesh)
}
/// The edge seeds that make every cut's divisions alike on its two sides: each cut's line in
/// the arm's cross-section (its bottom edge, z = 0) and, for the diagonals and the cuts across the
/// pocket, its line on the pocket's floor (z = 8); and the 16 mm walls of the end cells on their
/// cuts, in six layers as the pocket's floor level splits the cells between them (3 + 3). The
/// counts are those the cells' mapped regions need: the cut `X30` faces the spindle boss's arc
/// across the root (7), the arm between the diagonals is one block of five rows.
fn crank_seeds() -> Vec<Value> {
let [c1, c2, c3, c4, c5, c6, c7] = CRANK_CELLS;
let cut = |name: &str, a: &str, b: &str, face: &str, count: u32| {
json!({"command": "mesh.seed.edge", "params": {"edges": [
{"body": a, "edge": format!("{face}|partition:{name}:a")},
{"body": b, "edge": format!("{face}|partition:{name}:b")},
], "count": count}})
};
let mut seeds = vec![cut("X30", c1, c2, "F2:start", 7), cut("X148", c6, c7, "F2:start", 5)];
for (name, a, b) in [("D1", c2, c3), ("X60", c3, c4), ("X110", c4, c5), ("D2", c5, c6)] {
seeds.push(cut(name, a, b, "F2:start", 5));
seeds.push(cut(name, a, b, "F6:top", 3));
}
seeds.push(json!({"command": "mesh.seed.edge", "params": {"edges": [
{"body": c1, "edge": "F2:side:lo|partition:X30:a"},
{"body": c7, "edge": "F2:side:lo|partition:X148:b"},
], "count": 6}}));
seeds
}
/// The crank arm's symmetry plane (`Sym`, y = 0) and the partition by it (`Half`), then the
/// half `CrankArm/a` cut into [`CRANK_CELLS`]: across the arm at x = 30 (the root, beyond the
/// spindle boss), 60 and 110 (the pocket's straight middle) and 148 (before the pedal boss), and
/// on the diagonals of [`CRANK_DIAGONALS`]. Each cut names the one cell it divides.
/// The crank arm's symmetry plane (`Sym`, y = 0) and the partition by it (`Half`): the one datum
/// the structured mesh needs.
fn crank_prepare() -> Value {
let [d1, d2] = CRANK_DIAGONALS.map(f64::to_radians);
let across = |x: f64| json!({"type": "offset", "plane": "yz", "offset": x});
let cuts = [
("X30", across(30.0), "CrankArm/a"),
("D1", json!({"type": "point_normal", "point": [53, -2, 0], "normal": [d1.sin(), -d1.cos(), 0]}), "CrankArm/a/b"),
("X60", across(60.0), "CrankArm/a/b/b"),
("X110", across(110.0), "CrankArm/a/b/b/b"),
("D2", json!({"type": "point_normal", "point": [125, -2, 0], "normal": [d2.sin(), d2.cos(), 0]}), "CrankArm/a/b/b/b/b"),
("X148", across(148.0), "CrankArm/a/b/b/b/b/b"),
];
let mut script = vec![
json!({"command": "geometry.datum", "params": {"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}}}),
json!({"command": "geometry.partition", "params": {"name": "Half", "datum": "Sym"}}),
];
for (name, by, body) in cuts {
script.push(json!({"command": "geometry.datum", "params": {"name": name, "by": by}}));
script.push(json!({"command": "geometry.partition", "params": {"name": name, "datum": name, "bodies": [body]}}));
}
Value::Array(script)
json!([
{"command": "geometry.datum", "params": {"name": "Sym", "by": {"type": "offset", "plane": "zx", "offset": 0}}},
{"command": "geometry.partition", "params": {"name": "Half", "datum": "Sym"}},
])
}
/// The crank's model after the `mesh` commands: material, the spindle fixed, the rider on the
@@ -806,6 +741,18 @@ mod tests {
assert!(r["mass"]["relative_change"].as_f64().unwrap().abs() < 0.01, "{r}");
}
/// The heated bracket's 4 mm tet mesh stays near its 23 000 elements. #303: refinement piled
/// face vertices against a feature curve, and the improvement then grew the mesh to 29 257
/// elements in about two minutes. The element count is the check: wall time depends on the
/// machine and the build profile (about 5 s in a release build, far longer on CI).
#[test]
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
fn the_heated_bracket_opens_with_its_usual_mesh() {
let s = open("heated-bracket");
let elements = s.model.elements().len();
assert!((20_000..=26_000).contains(&elements), "{elements} elements");
}
/// The heated bracket's temperatures lie between the air's and the heater's, falling along
/// the bracket away from the heater. Its thermal expansion: with the air at the heater's
/// temperature the bracket warms evenly by ΔT, and held at the foot's hole alone (at x = 40)
@@ -859,11 +806,11 @@ mod tests {
assert!(r["max_von_mises"]["value"].as_f64().unwrap() > 200.0, "{r}");
}
/// The crank opens as one conforming structured hex20 mesh: the
/// half's seven cells, every cut merged node for node (no ties), the half and its mirror
/// The crank opens as one conforming structured hex20 mesh: the half meshed in blocks
/// without partitions or seeds, no ties, the half and its mirror
/// image alike and joined on the symmetry plane, the spindle and pedal faces found on both
/// halves (the image's faces carry the names of the half they are the image of), and the
/// quality floor: scaled Jacobian at least 0.3, edge ratio at most 10.
/// quality floor: scaled Jacobian at least 0.37, edge ratio at most 10.
#[test]
fn the_crank_opens_as_one_structured_mesh_of_two_alike_halves() {
let (arm, mirror) = (find("crank-arm").unwrap(), find("crank-mirror").unwrap());
@@ -876,10 +823,11 @@ mod tests {
let name = "crank-arm";
let mut s = open(name);
let bodies: Vec<String> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
let mut cells: Vec<String> = CRANK_CELLS.iter().map(|c| c.to_string()).collect();
cells.push("CrankArm/b".into());
assert_eq!(bodies, cells);
assert!(s.model.ties().is_empty(), "every cut merged");
assert_eq!(bodies, [CRANK_HALF, "CrankArm/b"]);
// One datum, the symmetry plane, hidden.
assert!(s.model.datums().len() == 1 && s.model.datums().keys().all(|d| !s.shows(crate::ItemKind::Datum, d)), "datums shown");
assert!(!s.is_dirty(), "a sample just opened has nothing to save");
assert!(s.model.ties().is_empty(), "no ties");
let elements = s.model.elements().values().filter(|e| e.kind == fieldcraft_model::ElementKind::Hex20).count();
assert_eq!(elements, s.model.elements().len());
let mut set = |name: &str, bodies: &[&str]| {
@@ -887,7 +835,7 @@ mod tests {
.unwrap();
s.model.resolve_set(name).unwrap()
};
let (a, b) = (set("half_a", &CRANK_CELLS), set("half_b", &["CrankArm/b"]));
let (a, b) = (set("half_a", &[CRANK_HALF]), set("half_b", &["CrankArm/b"]));
assert_eq!(a.elements.len(), b.elements.len());
assert_eq!(a.elements.len() + b.elements.len(), elements);
assert_eq!(a.faces.len(), b.faces.len(), "the halves' skins are alike");
@@ -897,12 +845,12 @@ mod tests {
let q = s.execute("mesh.quality", &json!({})).unwrap();
assert_eq!(q["valid"].as_u64().unwrap() as usize, elements, "{q}");
let metric = |m: &str, k: &str| q["metrics"][m][k].as_f64().unwrap();
assert!(metric("scaled_jacobian", "min") >= 0.3, "{q}");
assert!(metric("scaled_jacobian", "min") >= 0.37, "{q}");
assert!(metric("edge_ratio", "max") <= 10.0, "{q}");
let members = |set: &str| s.model.sets().get(set).map_or(Vec::new(), |x| x.geometry.iter().map(|g| g.body.clone()).collect::<Vec<_>>());
for set in ["spindle", "pedal"] {
let bodies = members(set);
assert!(bodies.iter().any(|b| b.starts_with("CrankArm/a/")) && bodies.iter().any(|b| b == "CrankArm/b"), "{set}: {bodies:?}");
assert!(bodies.iter().any(|b| b == CRANK_HALF) && bodies.iter().any(|b| b == "CrankArm/b"), "{set}: {bodies:?}");
assert!(!s.model.resolve_set(set).unwrap().faces.is_empty(), "{set}");
}
let faces = |set: &str| s.model.sets().get(set).map_or(0, |x| x.geometry.len());
+9 -1
View File
@@ -124,10 +124,14 @@ pub enum Collection {
SolvedStep,
/// A body of the open design.
Body,
/// A datum plane or axis.
/// A datum plane, axis or point.
Datum,
/// A datum plane.
DatumPlane,
/// A datum axis.
DatumAxis,
/// A datum point.
DatumPoint,
Partition,
}
@@ -151,6 +155,8 @@ impl Collection {
Collection::Body => "body",
Collection::Datum => "datum",
Collection::DatumPlane => "datum plane",
Collection::DatumAxis => "datum axis",
Collection::DatumPoint => "datum point",
Collection::Partition => "partition",
}
}
@@ -178,6 +184,8 @@ impl Session {
Collection::Body => Box::new(self.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.as_str()))),
Collection::Datum => Box::new(m.datums().keys().map(String::as_str)),
Collection::DatumPlane => Box::new(m.datums().iter().filter(|(_, d)| d.plane().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumAxis => Box::new(m.datums().iter().filter(|(_, d)| d.axis().is_some()).map(|(n, _)| n.as_str())),
Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())),
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
+593
View File
@@ -0,0 +1,593 @@
//! Anonymous usage analytics, off until the user agrees (docs/privacy.md).
//!
//! The app records [`Event`]s here; a host (the desktop app's sender thread, the web page's
//! `fetch`) takes them in batches ([`take_batch`]) and posts [`Batch::body`] to PostHog's batch
//! API. Nothing here touches the network.
//!
//! What an event can hold is fixed by its type: command ids, sample names and requirements come
//! from the registries (`&'static str`), never from parameters, paths, file names or messages.
//! Recording does nothing until [`enable`] (the user agreed, and the host may send); [`disable`]
//! drops what is queued. The queue is bounded ([`MAX_QUEUED`]): when full, new events are dropped.
//!
//! [`Session::execute`](crate::Session::execute) counts every top-level command of a session
//! whose [`Session::origin`](crate::Session::origin) is set (the app's own model): its id, kind,
//! duration, whether it failed and how, and where it came from ([`Origin`]); the funnel's
//! [`Milestone`]s and [`Event::SampleOpened`] follow from the same commands.
use std::cell::Cell;
use std::collections::VecDeque;
use std::sync::Mutex;
use serde_json::{Map, Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, EngineError};
/// PostHog Cloud's EU ingestion host.
pub const DEFAULT_HOST: &str = "https://eu.i.posthog.com";
/// Events kept waiting to be sent; more are dropped.
pub const MAX_QUEUED: usize = 1000;
/// Events in one request.
pub const MAX_BATCH: usize = 100;
/// Where a command came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Origin {
/// A click: the ribbon, a menu, a form, the browser.
Ui,
/// The command search or the command line.
Palette,
/// A script or the control channel.
Script,
/// An AI agent through the MCP server.
Mcp,
}
impl Origin {
pub fn as_str(self) -> &'static str {
match self {
Origin::Ui => "ui",
Origin::Palette => "palette",
Origin::Script => "script",
Origin::Mcp => "mcp",
}
}
/// Scripts and agents, which the user can leave out on their own.
pub fn automated(self) -> bool {
matches!(self, Origin::Script | Origin::Mcp)
}
}
/// A step of the way from a design to results, counted once per run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Milestone {
DesignOpened,
Meshed,
Solved,
ResultsViewed,
}
impl Milestone {
pub fn as_str(self) -> &'static str {
match self {
Milestone::DesignOpened => "design_opened",
Milestone::Meshed => "meshed",
Milestone::Solved => "solved",
Milestone::ResultsViewed => "results_viewed",
}
}
fn bit(self) -> u8 {
1 << self as u8
}
}
/// Something that happened, with only what docs/privacy.md lists.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
AppStarted,
/// The window closed after `seconds`.
AppClosed {
seconds: u64,
},
CommandRun {
command: &'static str,
kind: CommandKind,
duration_ms: u64,
error: Option<&'static str>,
from: Origin,
},
Milestone(Milestone),
SampleOpened {
name: &'static str,
},
/// A command's form closed without running it.
FormCancelled {
command: &'static str,
},
/// A command clicked before the model had what it needs.
UnavailableClicked {
command: &'static str,
missing: Vec<Requirement>,
},
}
impl Event {
pub fn name(&self) -> &'static str {
match self {
Event::AppStarted => "app_started",
Event::AppClosed { .. } => "app_closed",
Event::CommandRun { .. } => "command_run",
Event::Milestone(_) => "milestone",
Event::SampleOpened { .. } => "sample_opened",
Event::FormCancelled { .. } => "form_cancelled",
Event::UnavailableClicked { .. } => "unavailable_clicked",
}
}
/// The event's own properties.
pub fn properties(&self) -> Map<String, Value> {
let v = match self {
Event::AppStarted => json!({}),
Event::AppClosed { seconds } => json!({"duration_s": seconds}),
Event::CommandRun { command, kind, duration_ms, error, from } => {
let kind = match kind {
CommandKind::Model => "model",
CommandKind::Session => "session",
};
let mut v = json!({"command": command, "kind": kind, "duration_ms": duration_ms, "ok": error.is_none(), "from": from.as_str()});
if let Some(e) = error {
v["error"] = json!(e);
}
v
}
Event::Milestone(m) => json!({"name": m.as_str()}),
Event::SampleOpened { name } => json!({"name": name}),
Event::FormCancelled { command } => json!({"command": command}),
Event::UnavailableClicked { command, missing } => json!({"command": command, "missing": missing}),
};
match v {
Value::Object(m) => m,
_ => Map::new(),
}
}
}
/// The class of an error, without its message (which can name the user's files and values).
pub fn error_class(e: &EngineError) -> &'static str {
match e {
EngineError::UnknownCommand(_) => "unknown_command",
EngineError::Params(_) | EngineError::Param(_) => "params",
EngineError::Model(_) => "model",
EngineError::Geometry(_) => "geometry",
EngineError::Mesh(_) => "mesh",
EngineError::File(_) => "file",
EngineError::Deck(_) => "deck",
EngineError::Solver(_) => "solver",
EngineError::Explicit(_) => "explicit",
EngineError::Fluid(_) => "fluid",
EngineError::Unavailable(_) => "unavailable",
EngineError::Internal { .. } => "internal",
}
}
/// Who is sending: the anonymous install and the build. The same for every event of a run.
#[derive(Clone, Debug, PartialEq)]
pub struct Identity {
/// A random id made for this install (not derived from the machine or the user).
pub install_id: String,
/// `desktop` or `web`.
pub platform: &'static str,
/// The operating system (`macos`, `linux`, `windows`; `web` in a browser).
pub os: &'static str,
pub arch: &'static str,
pub app_version: &'static str,
}
/// What [`take_batch`] hands the sender.
#[derive(Clone, Debug, PartialEq)]
pub struct Batch {
pub identity: Identity,
pub events: Vec<Event>,
}
impl Batch {
/// The body of a POST to PostHog's `/batch/` endpoint with the project's API key: each event
/// with the anonymous install id, the build, no person profile and no IP-based location.
pub fn body(&self, api_key: &str) -> String {
let id = &self.identity;
let batch: Vec<Value> = self
.events
.iter()
.map(|e| {
let mut p = e.properties();
p.insert("app_version".into(), json!(id.app_version));
p.insert("platform".into(), json!(id.platform));
p.insert("os".into(), json!(id.os));
p.insert("arch".into(), json!(id.arch));
p.insert("$process_person_profile".into(), json!(false));
p.insert("$geoip_disable".into(), json!(true));
json!({"event": e.name(), "distinct_id": id.install_id, "properties": p})
})
.collect();
json!({"api_key": api_key, "batch": batch}).to_string()
}
}
/// Whether this run may record at all, before asking the user: the build has a project key,
/// `FIELDCRAFT_TELEMETRY` (`telemetry`) is not `0`, and a run driven by automation (`--control`)
/// or CI records only when `FIELDCRAFT_TELEMETRY=1` says so.
pub fn allowed(has_key: bool, telemetry: Option<&str>, automation: bool, ci: bool) -> bool {
let set = telemetry.map(|v| v.trim().to_ascii_lowercase());
match set.as_deref() {
Some("0" | "false" | "off" | "no") => false,
Some("1" | "true" | "on" | "yes") => has_key,
_ => has_key && !automation && !ci,
}
}
struct State {
identity: Identity,
/// Count scripts and MCP runs too.
automation: bool,
queue: VecDeque<Event>,
/// Milestones already sent this run.
reached: u8,
/// When recording started ([`now_ms`]), for the session's length.
started: Option<f64>,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
fn with_state<R>(f: impl FnOnce(&mut Option<State>) -> R) -> R {
// A poisoned lock still holds a usable queue.
let mut g = STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut g)
}
/// Start recording, as `identity`, with [`Event::AppStarted`]; `automation` counts scripts and
/// MCP runs too. Already recording, only the settings change.
pub fn enable(identity: Identity, automation: bool) {
let now = now_ms();
with_state(|s| match s {
Some(s) => {
s.identity = identity;
s.automation = automation;
}
None => *s = Some(State { identity, automation, queue: VecDeque::from([Event::AppStarted]), reached: 0, started: now }),
});
}
/// The app is closing: [`Event::AppClosed`] with how long it recorded.
pub fn closing() {
let now = now_ms();
let started = with_state(|s| s.as_ref().map(|s| s.started));
if let Some(started) = started {
let seconds = match (started, now) {
(Some(a), Some(b)) if b >= a => ((b - a) / 1000.0).round() as u64,
_ => 0,
};
record(Event::AppClosed { seconds });
}
}
/// Stop recording and drop everything queued.
pub fn disable() {
with_state(|s| *s = None);
}
pub fn enabled() -> bool {
with_state(|s| s.is_some())
}
/// Queue an event, when recording (and when it is no script or MCP run the user left out).
pub fn record(event: Event) {
with_state(|s| {
let Some(s) = s else { return };
match &event {
Event::CommandRun { from, .. } if from.automated() && !s.automation => return,
Event::Milestone(m) if s.reached & m.bit() != 0 => return,
Event::Milestone(m) => s.reached |= m.bit(),
_ => {}
}
if s.queue.len() < MAX_QUEUED {
s.queue.push_back(event);
}
});
}
/// Up to [`MAX_BATCH`] queued events to send, oldest first; `None` when there are none.
pub fn take_batch() -> Option<Batch> {
with_state(|s| {
let s = s.as_mut()?;
if s.queue.is_empty() {
return None;
}
let n = s.queue.len().min(MAX_BATCH);
Some(Batch { identity: s.identity.clone(), events: s.queue.drain(..n).collect() })
})
}
/// Events waiting to be sent.
pub fn queued() -> usize {
with_state(|s| s.as_ref().map_or(0, |s| s.queue.len()))
}
/// A clock in milliseconds for command durations. The plain wasm target has none: the web page
/// sets one ([`set_clock`]).
static CLOCK: Mutex<Option<fn() -> f64>> = Mutex::new(None);
/// Use `clock` (milliseconds, any origin) for command durations.
pub fn set_clock(clock: fn() -> f64) {
*CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(clock);
}
fn now_ms() -> Option<f64> {
if let Some(c) = *CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
return Some(c());
}
#[cfg(not(target_arch = "wasm32"))]
{
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
Some(START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0)
}
#[cfg(target_arch = "wasm32")]
None
}
thread_local! {
/// Commands running on this thread: only the outermost is counted.
static DEPTH: Cell<u32> = const { Cell::new(0) };
}
/// A command being counted, from [`Session::execute`](crate::Session::execute): `None` when it
/// is not (not recording, no origin, or a command inside another).
pub(crate) struct Timer {
started: Option<f64>,
from: Option<Origin>,
}
impl Timer {
pub(crate) fn start(from: Option<Origin>) -> Timer {
let outer = DEPTH.with(|d| {
d.set(d.get() + 1);
d.get() == 1
});
let from = from.filter(|_| outer && enabled());
Timer { started: from.and_then(|_| now_ms()), from }
}
/// Whether this command is counted.
pub(crate) fn counting(&self) -> bool {
self.from.is_some()
}
/// The command ended: count it, and the milestone it reached.
pub(crate) fn done(
&self,
command: &'static str,
kind: CommandKind,
error: Option<&EngineError>,
milestone: Option<Milestone>,
sample: Option<&'static str>,
) {
let Some(from) = self.from else { return };
let duration_ms = match (self.started, now_ms()) {
(Some(a), Some(b)) if b >= a => (b - a).round() as u64,
_ => 0,
};
record(Event::CommandRun { command, kind, duration_ms, error: error.map(error_class), from });
if error.is_none() {
if let Some(m) = milestone {
record(Event::Milestone(m));
}
if let Some(name) = sample {
record(Event::SampleOpened { name });
}
}
}
}
impl Drop for Timer {
fn drop(&mut self) {
DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
/// The milestone a successful `command` reaches; `elements` is the element count before and
/// after it.
pub(crate) fn milestone(command: &str, elements: (usize, usize)) -> Option<Milestone> {
match command {
"geometry.open" | "geometry.import.mesh" | "file.open" => Some(Milestone::DesignOpened),
c if c.starts_with("mesh.") && elements.1 > elements.0 => Some(Milestone::Meshed),
// Solvers, not the queries on a solved step.
c if c.starts_with("solve.") && !matches!(c, "solve.probe" | "solve.history") => Some(Milestone::Solved),
c if c.starts_with("results.") && !matches!(c, "results.reactions" | "results.clear") => Some(Milestone::ResultsViewed),
_ => None,
}
}
/// Serialises the tests (and other code) that turn the process-wide recording on.
#[doc(hidden)]
pub static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
fn identity() -> Identity {
Identity { install_id: "00000000-0000-4000-8000-000000000000".into(), platform: "desktop", os: "test", arch: "test", app_version: "0.0.0" }
}
fn take_all() -> Vec<Event> {
std::iter::from_fn(take_batch).flat_map(|b| b.events).collect()
}
/// Every string in a payload is a key, a registry name or the identity, and every number a
/// duration: no path, file name, parameter value or message can be in it.
pub(crate) fn assert_clean(body: &str) {
let v: Value = serde_json::from_str(body).unwrap();
let mut allowed: Vec<String> = crate::commands().iter().map(|c| c.id.to_string()).collect();
allowed.extend(crate::sample::SAMPLES.iter().map(|s| s.name.to_string()));
for e in [
"app_started",
"app_closed",
"command_run",
"milestone",
"sample_opened",
"form_cancelled",
"unavailable_clicked",
"model",
"session",
"ui",
"palette",
"script",
"mcp",
"design_opened",
"meshed",
"solved",
"results_viewed",
"unknown_command",
"params",
"geometry",
"mesh",
"file",
"deck",
"solver",
"explicit",
"fluid",
"unavailable",
"internal",
"KEY",
] {
allowed.push(e.into());
}
let id = identity();
allowed.extend([id.install_id, id.platform.into(), id.os.into(), id.arch.into(), id.app_version.into()]);
fn walk(key: &str, v: &Value, allowed: &[String]) {
match v {
Value::String(s) => {
// A requirement's snake_case name.
let requirement = key == "missing" && s.chars().all(|c| c.is_ascii_lowercase() || c == '_');
assert!(allowed.contains(s) || requirement, "`{key}`: `{s}` is not a name FieldCraft defines");
}
Value::Number(_) => assert!(["duration_ms", "duration_s"].contains(&key), "`{key}`: a number that is not a duration"),
Value::Array(a) => a.iter().for_each(|x| walk(key, x, allowed)),
Value::Object(m) => m.iter().for_each(|(k, x)| walk(k, x, allowed)),
Value::Bool(_) | Value::Null => {}
}
}
walk("", &v, &allowed);
}
#[test]
fn nothing_is_queued_without_consent() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
let mut s = Session { origin: Some(Origin::Ui), ..Default::default() };
s.run_script(&crate::sample::cantilever()).unwrap();
record(Event::AppStarted);
assert_eq!(queued(), 0);
assert!(take_batch().is_none());
}
#[test]
fn a_scripted_session_sends_only_names_and_durations() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
enable(identity(), true);
let dir = std::env::temp_dir().join(format!("fieldcraft-usage-secret-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("my-secret-bracket.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut s = Session { origin: Some(Origin::Script), ..Default::default() };
s.execute("geometry.open", &json!({"path": design.to_string_lossy()})).unwrap();
s.run_script(&crate::sample::cantilever()).unwrap();
s.execute("model.material.create", &json!({"name": "Secret alloy 7", "elastic": {"young": 123456.75, "poisson": 0.3125}})).ok();
s.execute("solve.run", &json!({})).unwrap();
s.execute("results.contour", &json!({"field": "von_mises"})).unwrap();
// A failure whose message names a path, and a sample.
assert!(s.execute("file.open", &json!({"path": dir.join("missing-secret.fieldcraft").to_string_lossy()})).is_err());
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
// An unknown id is user text: not counted.
assert!(s.execute("secret.command", &json!({})).is_err());
let events = take_all();
let body = Batch { identity: identity(), events: events.clone() }.body("KEY");
assert_clean(&body);
for secret in ["secret", "Secret", "123456", "0.3125", "1000", dir.to_string_lossy().as_ref()] {
assert!(!body.contains(secret), "{secret} in {body}");
}
let names: Vec<&str> = events.iter().map(Event::name).collect();
assert!(names.contains(&"command_run") && names.contains(&"sample_opened"), "{names:?}");
// Inner commands (the script's steps run by execute, a sample's own script) count once.
let runs = events.iter().filter(|e| matches!(e, Event::CommandRun { command: "file.sample", .. })).count();
assert_eq!(runs, 1);
let reached: Vec<_> = events.iter().filter_map(|e| if let Event::Milestone(m) = e { Some(*m) } else { None }).collect();
for m in [Milestone::DesignOpened, Milestone::Meshed, Milestone::Solved, Milestone::ResultsViewed] {
assert_eq!(reached.iter().filter(|r| **r == m).count(), 1, "{m:?} once: {reached:?}");
}
assert!(
events.iter().any(|e| matches!(e, Event::CommandRun { command: "file.open", error: Some("file"), from: Origin::Script, .. })),
"{events:?}"
);
disable();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scripts_and_agents_can_be_left_out_and_the_queue_is_bounded() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
enable(identity(), false);
let run = |from| Event::CommandRun { command: "model.info", kind: CommandKind::Model, duration_ms: 1, error: None, from };
record(run(Origin::Mcp));
record(run(Origin::Script));
record(run(Origin::Palette));
assert_eq!(take_all(), [Event::AppStarted, run(Origin::Palette)]);
for _ in 0..MAX_QUEUED + 50 {
record(Event::AppStarted);
}
assert_eq!(queued(), MAX_QUEUED);
let first = take_batch().unwrap();
assert_eq!(first.events.len(), MAX_BATCH);
assert_eq!(queued(), MAX_QUEUED - MAX_BATCH);
// Turning it off drops what waits.
disable();
assert_eq!(queued(), 0);
enable(identity(), true);
closing();
assert!(matches!(take_all()[..], [Event::AppStarted, Event::AppClosed { .. }]));
disable();
}
#[test]
fn the_body_is_a_posthog_batch() {
let b = Batch {
identity: identity(),
events: vec![Event::UnavailableClicked { command: "solve.run", missing: vec![Requirement::Step, Requirement::Material] }],
};
let v: Value = serde_json::from_str(&b.body("phc_test")).unwrap();
assert_eq!(v["api_key"], "phc_test");
let e = &v["batch"][0];
assert_eq!((e["event"].as_str(), e["distinct_id"].as_str()), (Some("unavailable_clicked"), Some(identity().install_id.as_str())));
assert_eq!(e["properties"]["missing"], json!(["step", "material"]));
assert_eq!(e["properties"]["$process_person_profile"], false);
assert_eq!(e["properties"]["$geoip_disable"], true);
assert_clean(&b.body("KEY"));
}
#[test]
fn telemetry_is_off_without_a_key_in_automation_and_ci_and_when_turned_off() {
assert!(allowed(true, None, false, false));
assert!(!allowed(false, None, false, false));
assert!(!allowed(false, Some("1"), false, false));
assert!(!allowed(true, Some("0"), false, false));
assert!(!allowed(true, Some(" off "), false, false));
assert!(!allowed(true, None, true, false));
assert!(!allowed(true, None, false, true));
assert!(!allowed(true, Some(""), true, false));
assert!(allowed(true, Some("1"), true, true));
}
}
+13 -8
View File
@@ -121,14 +121,14 @@ pub struct Shown {
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
let mut d = DisplayMesh::from_model(&s.model);
let mut failed = Vec::new();
for body in s.design.iter().flat_map(|d| &d.bodies) {
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh {
Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face),
Err(e) => failed.push(format!("{}: {e}", body.name)),
}
}
for surface in &s.surfaces {
for surface in s.surfaces.iter().filter(|x| s.shows(fieldcraft_io::ItemKind::Surface, &x.name)) {
d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face);
}
(d, failed)
@@ -164,6 +164,8 @@ struct Picture {
shown: Shown,
glyphs: Glyphs,
colors: GlyphColors,
/// The box around the datums drawn: inside the clipping radius, never framed (#295).
datums: Aabb3,
}
impl Picture {
@@ -180,14 +182,15 @@ impl Picture {
fn picture(s: &Session, p: &Palette) -> Picture {
let (base, failed) = display(s);
let glyphs = Glyphs::from_model(&s.model);
let glyphs = s.glyphs();
let mut shown = Shown { failed, ..Shown::default() };
let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p);
let mut bounds = base.bounds;
bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &p.glyphs, &mut scene));
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs };
let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene);
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums };
};
shown.step = Some(view.step.clone());
let pick = view.pick();
@@ -231,7 +234,7 @@ fn picture(s: &Session, p: &Palette) -> Picture {
}
let mut bounds = base.bounds;
bounds.union(drawn.bounds);
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1) }
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY }
}
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
@@ -263,7 +266,8 @@ pub fn orientation(p: &Value) -> Result<Orientation> {
Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0"))
}
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn, or from
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn but the
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
/// `at`'s distance when it has one. The symbols are drawn at their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
@@ -283,6 +287,7 @@ pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette
symbols = Scene::default();
let mut bounds = pic.bounds;
bounds.union(pic.symbols(unit(&cam), &mut symbols));
bounds.union(pic.datums);
pic.scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
let png = fieldcraft_render::render_layers_png(&pic.scene, &[&symbols], &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, pic.shown))
+31
View File
@@ -33,6 +33,24 @@ impl Edge {
pub fn length(&self) -> f64 {
self.points.windows(2).map(|w| distance(w[0], w[1])).sum()
}
/// The point `fraction` of its length along it from its start (0: the start, 1: the end);
/// `None` for a fraction outside 0..=1 or an edge without points.
pub fn point_at(&self, fraction: f64) -> Option<[f64; 3]> {
if !(0.0..=1.0).contains(&fraction) {
return None;
}
let mut left = fraction * self.length();
for w in self.points.windows(2) {
let l = distance(w[0], w[1]);
if left <= l && l > 0.0 {
let t = left / l;
return Some([0, 1, 2].map(|k| w[0][k] + t * (w[1][k] - w[0][k])));
}
left -= l;
}
self.points.last().copied()
}
}
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
@@ -186,6 +204,19 @@ mod tests {
assert_eq!(n, names(&ea));
}
#[test]
fn points_along_an_edge() {
let e = super::Edge { name: "e".into(), faces: vec![], points: vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 2.0, 0.0]] };
assert_eq!(e.point_at(0.0), Some([0.0, 0.0, 0.0]));
assert_eq!(e.point_at(0.5), Some([2.0, 0.0, 0.0]));
assert_eq!(e.point_at(0.75), Some([2.0, 1.0, 0.0]));
assert_eq!(e.point_at(1.0), Some([2.0, 2.0, 0.0]));
for bad in [-0.1, 1.5, f64::NAN] {
assert_eq!(e.point_at(bad), None);
}
assert_eq!(super::Edge { points: vec![], ..e }.point_at(0.5), None);
}
#[test]
fn partition_cut_edges_are_named_after_the_cut_faces() {
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
+40
View File
@@ -160,6 +160,30 @@ impl Shape {
self.faces.iter().position(|f| f.name == name).and_then(|i| u32::try_from(i).ok())
}
/// The area centroid of the face with this persistent name, from a tessellation fine enough
/// to place it (on a curved face the centroid lies off the surface, inside its curvature).
pub fn face_centre(&self, name: &str) -> Result<[f64; 3]> {
let face = self.face_index(name).ok_or_else(|| Error::Invalid(format!("body `{}` has no face `{name}`", self.name)))?;
let mesh = self.tessellate((self.size()? * edges::EDGE_TOLERANCE).max(1e-4))?;
let (mut sum, mut area) = ([0.0; 3], 0.0);
for (t, _) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, f)| **f == face) {
let corner = |i: u32| mesh.positions.get(i as usize).copied();
let (Some(a), Some(b), Some(c)) = (corner(t[0]), corner(t[1]), corner(t[2])) else { continue };
let (u, v) = ([0, 1, 2].map(|k| b[k] - a[k]), [0, 1, 2].map(|k| c[k] - a[k]));
let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
let w = 0.5 * (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
for (k, s) in sum.iter_mut().enumerate() {
*s += w * (a[k] + b[k] + c[k]) / 3.0;
}
area += w;
}
if area > 0.0 && area.is_finite() {
Ok(sum.map(|s| s / area))
} else {
Err(Error::Failed(format!("face `{name}` of body `{}` has no area", self.name)))
}
}
/// The analytic surface of each B-rep face, by face index (the [`SurfaceMesh::tri_face`]
/// tags): `None` for a free-form face. Meshers project nodes onto these surfaces.
pub fn face_surfaces(&self) -> Result<Vec<Option<FaceSurface>>> {
@@ -241,6 +265,22 @@ impl SurfaceMesh {
mod tests {
use super::*;
/// The plate's side face at x = 0 (40 × 30 × 10, the hole well inside) has its centre in its
/// middle; an unknown face is an error.
#[test]
fn a_face_centre_is_its_area_centroid() {
let d = open_design(&sample::plate_design(&sample::Plate::default()).unwrap()).unwrap();
let body = &d.bodies[0];
let surfaces = body.face_surfaces().unwrap();
let side = surfaces
.iter()
.position(|s| matches!(s, Some(FaceSurface::Plane { normal, point }) if normal[0] < -0.5 && point[0].abs() < 1e-9))
.unwrap();
let c = body.face_centre(&body.faces()[side].name).unwrap();
assert!((c[0]).abs() < 1e-9 && (c[1] - 15.0).abs() < 1e-6 && (c[2] - 5.0).abs() < 1e-6, "{c:?}");
assert!(body.face_centre("nope").is_err());
}
#[test]
fn block_measures() {
let m = block([0., 0., 0.], [10., 20., 30.]).unwrap().measure().unwrap();
+81 -6
View File
@@ -5,8 +5,8 @@
//!
//! - `fieldcraft.json`: the manifest. The format tag (`fieldcraft/1`), the program that wrote
//! it, the geometry the model is built on (the SolidCraft design and the imported 3MF meshes,
//! each a path and the SHA-256 of its content, opened again when the model is), and the size
//! and SHA-256 of every other part.
//! each a path and the SHA-256 of its content, opened again when the model is), the view state
//! ([`View`]: what the viewport hides), and the size and SHA-256 of every other part.
//! - `model.json`: the model definition (unit system, sets, materials, sections, reference
//! points, couplings, boundary conditions, loads, steps) as pretty-printed JSON with a stable
//! key order, so two versions of a model diff line by line.
@@ -28,7 +28,7 @@ pub mod mesh;
pub mod recovery;
pub mod roots;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use fieldcraft_model::Model;
@@ -176,6 +176,75 @@ pub struct Geometry {
pub meshes: Vec<SourceFile>,
}
/// Most items a view hides.
pub const MAX_HIDDEN: usize = 100_000;
/// Longest name of a hidden item.
const MAX_HIDDEN_NAME: usize = 1024;
/// The kinds of items the viewport draws one by one, and so can hide.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
/// A body of the open design (or a cell of a partitioned one).
Body,
/// An imported surface.
Surface,
Datum,
BoundaryCondition,
Load,
ReferencePoint,
Coupling,
}
impl ItemKind {
pub const ALL: [ItemKind; 7] = [
ItemKind::Body,
ItemKind::Surface,
ItemKind::Datum,
ItemKind::BoundaryCondition,
ItemKind::Load,
ItemKind::ReferencePoint,
ItemKind::Coupling,
];
}
/// An item of the viewport, by kind and name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
pub kind: ItemKind,
pub name: String,
}
/// View state saved with a model (not part of its definition): the items the viewport hides.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct View {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub hidden: BTreeSet<Item>,
}
impl View {
pub fn is_empty(&self) -> bool {
self.hidden.is_empty()
}
pub fn is_hidden(&self, kind: ItemKind, name: &str) -> bool {
self.hidden.contains(&Item { kind, name: name.to_string() })
}
fn check(&self) -> Result<()> {
if self.hidden.len() > MAX_HIDDEN {
return Err(Error::Limit(format!("a view hides at most {MAX_HIDDEN} items")));
}
if self.hidden.iter().any(|i| i.name.is_empty() || i.name.len() > MAX_HIDDEN_NAME) {
return invalid(format!("{MANIFEST}: a hidden item's name is 1 to {MAX_HIDDEN_NAME} bytes"));
}
Ok(())
}
}
/// One journal entry: a command id and its parameters.
pub type JournalEntry = (String, Value);
@@ -185,6 +254,7 @@ pub struct ModelFile {
pub model: Model,
pub geometry: Geometry,
pub journal: Vec<JournalEntry>,
pub view: View,
}
#[derive(Serialize, Deserialize)]
@@ -194,6 +264,8 @@ struct Manifest {
generator: String,
#[serde(default)]
geometry: Geometry,
#[serde(default, skip_serializing_if = "View::is_empty")]
view: View,
parts: BTreeMap<String, Part>,
}
@@ -235,11 +307,12 @@ fn journal_text(journal: &[JournalEntry]) -> Result<Vec<u8>> {
}
/// The bytes of a `.fieldcraft` file holding `model`, built on `geometry` by the commands in
/// `journal`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Result<Vec<u8>> {
/// `journal`, shown with `view`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry], view: &View) -> Result<Vec<u8>> {
if geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes")));
}
view.check()?;
let mut model_json = serde_json::to_vec_pretty(&model.without_mesh()).map_err(|e| json_error(MODEL, e))?;
model_json.push(b'\n');
let mesh = mesh::encode(model)?;
@@ -249,6 +322,7 @@ pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Re
format: FORMAT.into(),
generator: format!("FieldCraft {}", env!("CARGO_PKG_VERSION")),
geometry: geometry.clone(),
view: view.clone(),
parts: parts.iter().map(|(name, bytes)| (name.to_string(), Part { bytes: bytes.len() as u64, sha256: sha256_hex(bytes) })).collect(),
};
let mut manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|e| json_error(MANIFEST, e))?;
@@ -323,6 +397,7 @@ pub fn read(bytes: &[u8]) -> Result<ModelFile> {
if manifest.geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes")));
}
manifest.view.check()?;
if manifest.parts.len() != 3 {
return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}"));
}
@@ -347,7 +422,7 @@ pub fn read(bytes: &[u8]) -> Result<ModelFile> {
mesh::decode_into(&mut model, &mesh_bin)?;
drop(mesh_bin);
let journal = read_journal(&journal_json)?;
Ok(ModelFile { model, geometry: manifest.geometry, journal })
Ok(ModelFile { model, geometry: manifest.geometry, journal, view: manifest.view })
}
fn read_journal(bytes: &[u8]) -> Result<Vec<JournalEntry>> {
+26 -10
View File
@@ -32,26 +32,36 @@ pub fn automation_dir() -> PathBuf {
/// Set by [`use_automation_dir`].
static AUTOMATED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// This process is driven by automation (a control channel): unless `FIELDCRAFT_RECOVERY_DIR`
/// names a folder, [`default_dir`] is [`automation_dir`] from now on.
/// This process is driven by automation (a control channel): [`data_dir`] is the temporary
/// `fieldcraft-automation` folder from now on, so unless `FIELDCRAFT_RECOVERY_DIR` names a
/// folder, [`default_dir`] is [`automation_dir`].
pub fn use_automation_dir() {
let _ = AUTOMATED.set(());
}
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else [`automation_dir`] in automated runs
/// ([`use_automation_dir`], or the `CI` environment variable set), else `recovery` in
/// the platform's per-user data folder (`FieldCraft/recovery`, `fieldcraft/recovery` on Linux);
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `recovery` in [`data_dir`];
/// [`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);
if let Some(d) = std::env::var_os("FIELDCRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(d));
}
data_dir().map(|d| d.join("recovery"))
}
/// The app's per-user data folder, which holds the recovery, crash and log folders: the
/// platform's (`FieldCraft`, `fieldcraft` on Linux), or `fieldcraft-automation` in the temporary
/// folder in automated runs ([`use_automation_dir`], or the `CI` environment variable set).
/// `None` in the browser and where the platform names no such folder.
pub fn data_dir() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return None;
}
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
if AUTOMATED.get().is_some() || env("CI").is_some() {
return Some(automation_dir());
return automation_dir().parent().map(Path::to_path_buf);
}
let (root, name) = if cfg!(windows) {
(env("LOCALAPPDATA")?, "FieldCraft")
@@ -60,7 +70,7 @@ pub fn default_dir() -> Option<PathBuf> {
} else {
(env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?, "fieldcraft")
};
Some(root.join(name).join("recovery"))
Some(root.join(name))
}
/// Entries older than this many days are pruned ([`prune`]).
@@ -338,6 +348,11 @@ impl Slot {
Ok(())
}
/// The autosaved model's file (there once [`Slot::write`] has written it).
pub fn file(&self) -> PathBuf {
file(&self.dir, &self.id, EXTENSION)
}
/// Drop the autosave (nothing is unsaved any more).
pub fn clear(&mut self) {
if self.has_entry {
@@ -458,6 +473,7 @@ mod tests {
use_automation_dir();
assert_eq!(default_dir(), Some(automation_dir()));
assert!(automation_dir().starts_with(std::env::temp_dir()));
assert_eq!(data_dir().map(|d| d.join("recovery")), Some(automation_dir()));
}
#[test]
+56 -18
View File
@@ -12,7 +12,9 @@
//! - The result is compared with the root by whole folder names: `/work2/x` is not inside
//! `/work`.
//! - Read and write access are separate; a root left out grants none of its access. Without
//! either root ([`Roots::default`]) nothing is confined.
//! either root ([`Roots::default`]) nothing is confined. [`Roots::writes_within`] confines
//! writes alone (a crash replay may read the model's geometry wherever it is, and writes
//! nothing outside a scratch folder).
//!
//! A path is checked, then opened: another program that swaps a link in between can still win
//! that race, so the roots keep an agent to the files it was given rather than guarding against
@@ -41,9 +43,28 @@ impl Access {
/// The folders file access is confined to; the default confines nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Roots {
/// `None` while unconfined; otherwise the resolved read and write roots (each `None` when
/// that access is not granted).
confined: Option<[Option<PathBuf>; 2]>,
/// `None` while unconfined; otherwise what each of read and write access is granted.
confined: Option<[Grant; 2]>,
}
/// What one kind of access is granted while confined.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Grant {
/// Not granted.
None,
/// Granted anywhere.
Anywhere,
/// Granted inside this resolved folder.
Within(PathBuf),
}
fn resolve_root(p: &Path, access: Access) -> Result<PathBuf> {
let shown = p.display();
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
if !dir.is_dir() {
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
}
Ok(dir)
}
impl Roots {
@@ -55,16 +76,15 @@ impl Roots {
/// Confine reads to `read` and writes to `write`. A root left out grants none of its access.
/// Each root must be an existing folder; it is resolved (links followed) once, here.
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Roots> {
let resolve = |p: Option<&Path>, access: Access| -> Result<Option<PathBuf>> {
let Some(p) = p else { return Ok(None) };
let shown = p.display();
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
if !dir.is_dir() {
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
}
Ok(Some(dir))
let grant = |p: Option<&Path>, access: Access| -> Result<Grant> {
Ok(p.map(|p| resolve_root(p, access)).transpose()?.map_or(Grant::None, Grant::Within))
};
Ok(Roots { confined: Some([resolve(read, Access::Read)?, resolve(write, Access::Write)?]) })
Ok(Roots { confined: Some([grant(read, Access::Read)?, grant(write, Access::Write)?]) })
}
/// Reads anywhere, writes only inside `write` (an existing folder, resolved here).
pub fn writes_within(write: &Path) -> Result<Roots> {
Ok(Roots { confined: Some([Grant::Anywhere, Grant::Within(resolve_root(write, Access::Write)?)]) })
}
/// Whether any access is confined.
@@ -72,10 +92,12 @@ impl Roots {
self.confined.is_some()
}
/// The root of `access`, when confined and granted.
/// The root of `access`, when confined to one.
pub fn root(&self, access: Access) -> Option<&Path> {
let roots = self.confined.as_ref()?;
roots.get(access as usize).and_then(|r| r.as_deref())
match self.confined.as_ref()?.get(access as usize)? {
Grant::Within(root) => Some(root),
Grant::None | Grant::Anywhere => None,
}
}
/// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path
@@ -84,8 +106,12 @@ impl Roots {
pub fn check(&self, path: &Path, access: Access) -> Result<PathBuf> {
let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) };
let shown = path.display();
let Some(root) = roots.get(access as usize).and_then(|r| r.as_deref()) else {
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name())));
let root = match roots.get(access as usize) {
Some(Grant::Anywhere) => return Ok(path.to_path_buf()),
Some(Grant::Within(root)) => root,
Some(Grant::None) | None => {
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name())));
}
};
let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?;
if !resolved.starts_with(root) {
@@ -209,6 +235,18 @@ mod tests {
assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder");
}
#[test]
fn writes_within_reads_anywhere() {
let d = Dir::new("writes");
let r = Roots::writes_within(&d.0).unwrap();
assert!(r.is_confined());
assert_eq!(r.check(Path::new("/etc/passwd"), Access::Read).unwrap(), Path::new("/etc/passwd"));
assert!(r.check(Path::new("/etc/passwd"), Access::Write).is_err());
assert_eq!(r.check(&d.0.join("x.inp"), Access::Write).unwrap(), d.0.join("x.inp"));
assert_eq!(r.root(Access::Write), Some(d.0.as_path()));
assert_eq!(r.root(Access::Read), None);
}
#[cfg(unix)]
#[test]
fn links_are_followed() {
+25 -3
View File
@@ -2,7 +2,7 @@
use std::io::{Cursor, Read, Write};
use fieldcraft_io::{Error, Geometry, ModelFile, SourceFile, mesh, read, write};
use fieldcraft_io::{Error, Geometry, Item, ItemKind, ModelFile, SourceFile, View, mesh, read, write};
use fieldcraft_model::{ElementKind, Entity, GeometryRef, Model, UnitSystem};
use serde_json::{Value, json};
@@ -36,11 +36,12 @@ fn sample() -> ModelFile {
};
let journal =
vec![("mesh.node.add".to_string(), json!({"label": 1, "xyz": [0, 0, 0]})), ("model.units".to_string(), json!({"system": "mm-t-s-N-MPa"}))];
ModelFile { model: m, geometry, journal }
let view = View { hidden: [Item { kind: ItemKind::Datum, name: "Sym".into() }, Item { kind: ItemKind::Body, name: "Body1".into() }].into() };
ModelFile { model: m, geometry, journal, view }
}
fn bytes_of(f: &ModelFile) -> Vec<u8> {
write(&f.model, &f.geometry, &f.journal).unwrap()
write(&f.model, &f.geometry, &f.journal, &f.view).unwrap()
}
/// The package's parts, by name.
@@ -248,3 +249,24 @@ fn mesh_part_rejects_what_the_model_rejects() {
mesh::decode_into(&mut back, &good).unwrap();
assert_eq!(back, f.model);
}
/// The view's hidden items come back as written; a hostile hidden list is refused.
#[test]
fn the_view_state_round_trips_and_is_checked() {
let f = sample();
let back = read(&bytes_of(&f)).unwrap();
assert_eq!(back.view, f.view);
assert!(back.view.is_hidden(ItemKind::Datum, "Sym") && !back.view.is_hidden(ItemKind::Load, "Sym"));
let bytes = bytes_of(&f);
for bad in [json!([{"kind": "body", "name": ""}]), json!([{"kind": "nope", "name": "A"}]), json!("x")] {
let mut ps = parts(&bytes);
let mut manifest: Value = serde_json::from_slice(&ps[0].1).unwrap();
manifest["view"]["hidden"] = bad.clone();
ps[0].1 = serde_json::to_vec(&manifest).unwrap();
assert!(read(&package(&ps)).is_err(), "{bad}");
}
let empty = View::default();
let plain = write(&f.model, &f.geometry, &f.journal, &empty).unwrap();
let manifest: Value = serde_json::from_slice(&parts(&plain)[0].1).unwrap();
assert!(manifest.get("view").is_none(), "an empty view writes nothing: {manifest}");
}
+2 -1
View File
@@ -230,7 +230,8 @@ fn temp_png() -> PathBuf {
impl Backend for Remote {
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
self.call("engine.execute", json!({"command": command, "params": params}))
// `client` lets the app count an agent's commands apart from a script's (docs/privacy.md).
self.call("engine.execute", json!({"command": command, "params": params, "client": "mcp"}))
}
fn commands(&mut self) -> Result<Value, String> {
File diff suppressed because it is too large. Load diff
+45 -202
View File
@@ -17,9 +17,14 @@
//! dimensional geometries*, Proc. 7th International Meshing Roundtable, 1998). Which intervals a
//! region fills is decided by the inside test at its centre ([`super::geom::Inside`]).
//!
//! **Blocks.** Regions no structured scheme fits are cut into blocks that fit
//! ([`super::decompose`]: holes apart, O-grids, cuts from concave corners and fillets), and the
//! arrangement is laid out again with the cuts, so they are curves like the others.
//!
//! **Meshing.** Interval assignment ([`super::interval`]) gives every curve its count so that
//! four-sided regions are mapped (opposite sides equal) and the others have even loops for the
//! free all-quad mesher; each level interval gets its own layer count. Each region's quads are
//! every region's scheme fits ([`super::section`]: four-sided regions mapped with opposite sides
//! equal, three-sided ones the triangle primitive, rings and disks O-grids, the rest even loops
//! for the free all-quad mesher); each level interval gets its own layer count. Each region's quads are
//! stacked through its intervals; shared curves and levels share nodes, so the hexahedra
//! conform. Nodes of curves on analytic walls are projected onto them; in a revolution, the
//! rotated nodes then lie exactly on every surface of revolution.
@@ -29,9 +34,11 @@ use std::f64::consts::{FRAC_PI_4, PI, TAU};
use super::arrangement::{self, Arrangement, Q};
use super::brep::{Brep, Face};
use super::decompose;
use super::geom::{Inside, Surface, add, dist, scale, unit};
use super::interval::Intervals;
use super::region::{self, Scheme};
use super::region::Scheme;
use super::section;
use super::{Ctx, Raw};
use crate::predicates::Sign;
use crate::seeds::EdgeSeed;
@@ -349,7 +356,7 @@ impl SizeField for PlaneField<'_> {
}
}
/// The cross-section of a frame the body sweeps in: the arrangement (cut at reflex corners),
/// The cross-section of a frame the body sweeps in: the arrangement (cut into blocks),
/// the level intervals and which of them each region fills. It does not depend on the element
/// size, so refinement retries reuse it.
pub(crate) struct Prepared {
@@ -401,17 +408,20 @@ pub(crate) fn prepare(ctx: &Ctx, setup: Setup) -> Result<Prepared> {
let ends: Vec<f64> = if frame.periodic() { rest.iter().copied().chain(std::iter::once(first_level + TAU)).collect() } else { rest.to_vec() };
let intervals: Vec<(f64, f64)> = levels.iter().copied().zip(ends).collect();
let mut inside = Inside::new(brep.surface);
let first = arrangement::build(&vertices, &polylines, tol, coincide)?;
let (_, used_first) = occupancy(&first, frame, &intervals, &mut inside)?;
// Cut the filled regions at their reflex corners, then lay the arrangement out again.
let cut_lines = cuts(&first, &used_first, tol);
let arr = if cut_lines.is_empty() {
first
} else {
polylines.extend(cut_lines);
arrangement::build(&vertices, &polylines, tol, coincide)?
};
let (filled, used) = occupancy(&arr, frame, &intervals, &mut inside)?;
let mut arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
let (mut filled, mut used) = occupancy(&arr, frame, &intervals, &mut inside)?;
// Cut the filled regions into blocks the structured schemes fit, and lay the arrangement out
// again with the cuts.
let h_ref = levels.first().copied().unwrap_or(0.0);
let plane = PlaneField { field: ctx.field, frame: *frame, h: h_ref };
let settings = decompose::Settings { field: &plane, tol, opts: &ctx.opts.quad };
let locked = seeded_curves(ctx, frame, h_ref, &intervals, setup.h_tol, &arr);
let cut_lines = decompose::cuts(&arr, &used, &locked, &settings)?;
if !cut_lines.is_empty() {
polylines.extend(cut_lines.into_iter().map(|c| (c, decompose::CUT)));
arr = arrangement::build(&vertices, &polylines, tol, coincide)?;
(filled, used) = occupancy(&arr, frame, &intervals, &mut inside)?;
}
Ok(Prepared { setup, arr, intervals, filled, used })
}
@@ -435,27 +445,7 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
let goal = ideal_divisions(ctx.field, &curve3d(c))?;
ia.var(goal * fine, 1);
}
let oriented = |c: usize, fwd: bool| -> Vec<Q> {
let mut v: Vec<Q> =
arr.curves.get(c).map(|x| x.points.iter().map(|&p| arr.points.get(p).copied().unwrap_or([0.0; 2])).collect()).unwrap_or_default();
if !fwd {
v.reverse();
}
v
};
let mut schemes: Vec<Scheme> = vec![Scheme::Free; arr.regions.len()];
for (r, region) in arr.regions.iter().enumerate() {
if !used.get(r).copied().unwrap_or(false) {
continue;
}
let polylines: Vec<Vec<Vec<Q>>> = region.loops.iter().map(|lp| lp.iter().map(|&(c, f)| oriented(c, f)).collect()).collect();
let scheme = region::classify(&polylines);
let vars: Vec<Vec<usize>> = region.loops.iter().map(|lp| lp.iter().map(|x| x.0).collect()).collect();
region::constrain(&mut ia, &vars, scheme);
if let Some(slot) = schemes.get_mut(r) {
*slot = scheme;
}
}
let schemes = section::schemes(arr, used, &mut ia);
let seeds = Seeds::of(ctx, frame, h_ref, intervals, setup.h_tol, &(0..arr.curves.len()).map(curve3d).collect::<Vec<_>>())?;
for (c, seed) in seeds.curves.iter().enumerate() {
if let Some((seed, _)) = seed {
@@ -483,85 +473,21 @@ pub(crate) fn mesh(ctx: &Ctx, prepared: &Prepared, refine: u32) -> Result<Raw> {
}
layers.push(n);
}
// 2D nodes: curve ends, then curve interiors (projected onto analytic walls).
let mut nodes2: Vec<Q> = Vec::new();
let mut point_node: HashMap<usize, u32> = HashMap::new();
let mut curve_nodes: Vec<Vec<u32>> = Vec::with_capacity(arr.curves.len());
for (c, curve) in arr.curves.iter().enumerate() {
let n = counts.get(c).copied().unwrap_or(1);
// The cross-section's nodes (curve interiors projected onto analytic walls) and quadrilaterals.
let place = |c: usize, n: usize| -> Result<Vec<Q>> {
let pts3 = match seeds.curves.get(c).copied().flatten() {
Some((seed, rev)) => crate::seeds::place(&curve3d(c), &seed.fractions(n, rev))?,
None => divide_polyline(ctx.field, &curve3d(c), n)?,
};
let wall = curve.tags.iter().find_map(|&f| brep.faces.get(f).and_then(|x| x.surface)).filter(|s| !matches!(s, Surface::Plane { .. }));
let mut ids = Vec::with_capacity(pts3.len());
let last = pts3.len().saturating_sub(1);
for (k, p) in pts3.iter().enumerate() {
let end = if k == 0 {
curve.points.first()
} else if k == last {
curve.points.last()
} else {
None
};
let id = match end {
Some(&pid) => *point_node.entry(pid).or_insert_with(|| {
nodes2.push(arr.points.get(pid).copied().unwrap_or([0.0; 2]));
nodes2.len() as u32 - 1
}),
None => {
let p = wall.and_then(|s| s.project(*p)).unwrap_or(*p);
nodes2.push(frame.coords(p).0);
nodes2.len() as u32 - 1
}
};
ids.push(id);
}
curve_nodes.push(ids);
}
// Quad meshes of the filled regions.
let mut region_quads: Vec<Vec<[u32; 4]>> = vec![Vec::new(); arr.regions.len()];
let quad_opts = &ctx.opts.quad;
for (r, region) in arr.regions.iter().enumerate() {
if !used.get(r).copied().unwrap_or(false) {
continue;
}
let mut loops_ids: Vec<Vec<u32>> = Vec::with_capacity(region.loops.len());
let mut starts: Vec<Vec<usize>> = Vec::new();
for lp in &region.loops {
let mut ids = Vec::new();
let mut st = Vec::new();
for &(c, fwd) in lp {
st.push(ids.len());
let cn = curve_nodes.get(c).map(Vec::as_slice).unwrap_or(&[]);
let mut seq: Vec<u32> = cn.to_vec();
if !fwd {
seq.reverse();
}
seq.pop();
ids.extend(seq);
}
loops_ids.push(ids);
starts.push(st);
}
let at = |i: u32| nodes2.get(i as usize).copied().unwrap_or([0.0; 2]);
let loops: Vec<Vec<Q>> = loops_ids.iter().map(|l| l.iter().map(|&i| at(i)).collect()).collect();
let scheme = schemes.get(r).copied().unwrap_or(Scheme::Free);
let (mesh, used_scheme) = region::mesh(&loops, &starts, scheme, &plane_field, quad_opts).map_err(|e| region_error(r, e))?;
if let Some(slot) = schemes.get_mut(r) {
*slot = used_scheme;
}
let boundary: Vec<u32> = loops_ids.concat();
let mut local: Vec<u32> = boundary.clone();
for p in mesh.nodes.iter().skip(mesh.boundary_nodes) {
nodes2.push(*p);
local.push(nodes2.len() as u32 - 1);
}
let quads = region_quads.get_mut(r).ok_or_else(|| Error::Failed("region index".into()))?;
for q in &mesh.quads {
quads.push(q.map(|i| local.get(i as usize).copied().unwrap_or(0)));
}
}
let wall = arr
.curves
.get(c)
.and_then(|curve| curve.tags.iter().find_map(|&f| brep.faces.get(f).and_then(|x| x.surface)))
.filter(|s| !matches!(s, Surface::Plane { .. }));
Ok(pts3.iter().map(|p| frame.coords(wall.and_then(|s| s.project(*p)).unwrap_or(*p)).0).collect())
};
let section::Section { nodes: nodes2, quads: region_quads, schemes } =
section::mesh(arr, used, schemes, &counts, &place, &plane_field, &ctx.opts.quad)?;
// Layer coordinates.
let mut heights: Vec<f64> = Vec::new();
let mut first_layer: Vec<usize> = Vec::with_capacity(intervals.len());
@@ -799,102 +725,19 @@ fn occupancy(arr: &Arrangement, frame: &Frame, intervals: &[(f64, f64)], inside:
Ok((filled, used))
}
/// The tag of cut lines (no wall behind them).
const CUT: usize = usize::MAX;
/// Straight cuts from the reflex corners of the filled regions into them, each along one of the
/// corner's edges (the shorter way) to the first boundary it meets. A rectilinear region falls
/// into rectangles this way; others lose their reflex corners. This is the explicit form of the
/// virtual decomposition behind submapping (Whiteley, White, Benzley, Blacker 1996), done in the
/// arrangement so that interval assignment sees every piece.
fn cuts(arr: &Arrangement, used: &[bool], tol: f64) -> Vec<(Vec<Q>, usize)> {
let at = |p: usize| arr.points.get(p).copied().unwrap_or([0.0; 2]);
let segments: Vec<(usize, usize)> = arr.curves.iter().flat_map(|c| c.points.windows(2).map(|w| (w[0], w[1]))).collect();
// The nearest boundary hit of the ray from point v along unit d (ignoring segments at v).
let hit = |v: usize, d: Q| -> Option<(f64, Q, Option<usize>)> {
let o = at(v);
let mut best: Option<(f64, Q, Option<usize>)> = None;
for &(a, b) in &segments {
if a == v || b == v {
continue;
}
let (pa, pb) = (at(a), at(b));
let e = [pb[0] - pa[0], pb[1] - pa[1]];
let den = d[0] * e[1] - d[1] * e[0];
if den.abs() <= f64::EPSILON * (e[0].abs() + e[1].abs()) {
continue;
}
let w = [pa[0] - o[0], pa[1] - o[1]];
let t = (w[0] * e[1] - w[1] * e[0]) / den;
let u = (w[0] * d[1] - w[1] * d[0]) / den;
if t > tol && (-1e-12..=1.0 + 1e-12).contains(&u) && best.is_none_or(|(bt, _, _)| t < bt) {
let x = [o[0] + t * d[0], o[1] + t * d[1]];
// End at a segment end that is this close to the hit.
let near = [(a, pa), (b, pb)].into_iter().find(|(_, p)| ((p[0] - x[0]).powi(2) + (p[1] - x[1]).powi(2)).sqrt() <= 0.1 * t);
best = Some((t, near.map_or(x, |n| n.1), near.map(|n| n.0)));
}
}
best
};
let mut out: Vec<(Vec<Q>, usize)> = Vec::new();
let mut seen: Vec<(usize, usize)> = Vec::new();
for (r, region) in arr.regions.iter().enumerate() {
if !used.get(r).copied().unwrap_or(false) {
continue;
}
for lp in &region.loops {
let n = lp.len();
for k in 0..n {
let (Some(&(pc, pf)), Some(&(nc, nf))) = (lp.get((k + n - 1) % n), lp.get(k)) else { continue };
let pts = |c: usize, f: bool| {
let mut v = arr.curves.get(c).map(|x| x.points.clone()).unwrap_or_default();
if !f {
v.reverse();
}
v
};
let (prev, next) = (pts(pc, pf), pts(nc, nf));
let (Some(&a), Some(&v), Some(&b)) = (prev.get(prev.len().wrapping_sub(2)), next.first(), next.get(1)) else { continue };
let (pa, pv, pb) = (at(a), at(v), at(b));
let (u, w) = ([pv[0] - pa[0], pv[1] - pa[1]], [pb[0] - pv[0], pb[1] - pv[1]]);
let turn = (u[0] * w[1] - u[1] * w[0]).atan2(u[0] * w[0] + u[1] * w[1]);
// The region is on the left: a right turn of more than 45° is a reflex corner.
if PI - turn < 1.25 * PI {
continue;
}
let norm2 = |x: Q| (x[0] * x[0] + x[1] * x[1]).sqrt();
let (lu, lw) = (norm2(u), norm2(w));
if !(lu > 0.0 && lw > 0.0) {
continue;
}
let rays = [[u[0] / lu, u[1] / lu], [-w[0] / lw, -w[1] / lw]];
let best = rays.iter().filter_map(|&d| hit(v, d)).min_by(|x, y| x.0.total_cmp(&y.0));
if let Some((_, end, end_point)) = best {
if let Some(e) = end_point {
let key = (v.min(e), v.max(e));
if seen.contains(&key) {
continue;
}
seen.push(key);
}
out.push((vec![pv, end], CUT));
}
}
}
}
out
}
fn region_error(r: usize, e: Error) -> Error {
match e {
Error::Limit(m) => Error::Limit(m),
other => Error::Failed(format!("cross-section region {r}: {other}")),
/// Which curves of `arr` carry an edge seed (a cut may not end inside them).
fn seeded_curves(ctx: &Ctx, frame: &Frame, h_ref: f64, intervals: &[(f64, f64)], h_tol: f64, arr: &Arrangement) -> Vec<bool> {
let curves: Vec<Vec<P>> =
arr.curves.iter().map(|cv| cv.points.iter().map(|&p| frame.point(arr.points.get(p).copied().unwrap_or([0.0; 2]), h_ref)).collect()).collect();
match Seeds::of(ctx, frame, h_ref, intervals, h_tol, &curves) {
Ok(seeds) => seeds.curves.iter().map(Option::is_some).collect(),
Err(_) => vec![false; arr.curves.len()],
}
}
/// A point well inside a region: off the middle of one of its outer edges, inside the outer
/// loop, outside the holes and clear of every boundary segment.
fn interior_point(arr: &Arrangement, region: &arrangement::Region) -> Result<Q> {
pub(crate) fn interior_point(arr: &Arrangement, region: &arrangement::Region) -> Result<Q> {
let at = |p: usize| arr.points.get(p).copied().unwrap_or([0.0; 2]);
let polygon = |lp: &[(usize, bool)]| -> Vec<Q> {
let mut out = Vec::new();
+2
View File
@@ -37,11 +37,13 @@
mod arrangement;
mod brep;
mod cells;
mod decompose;
pub(crate) mod geom;
mod interval;
mod layered;
mod mapped;
mod region;
mod section;
mod sweep;
#[cfg(test)]
+17 -6
View File
@@ -163,7 +163,22 @@ pub(crate) fn constrain(ia: &mut Intervals, loops: &[Vec<usize>], scheme: Scheme
/// positions where its curve uses start. Returns the mesh (boundary nodes first, in the order of
/// the loops) and the scheme used.
pub(crate) fn mesh(loops: &[Vec<Q>], starts: &[Vec<usize>], scheme: Scheme, size: &dyn SizeField, opts: &QuadOptions) -> Result<(QuadMesh, Scheme)> {
let structured = match scheme {
match mesh_structured(loops, starts, scheme, size, opts)? {
Some(m) => Ok((m, scheme)),
None => Ok((free_quad_mesh(loops, size, opts)?, Scheme::Free)),
}
}
/// The structured mesh of a region with its scheme, or `None` when the scheme is free or its
/// grid would fold.
pub(crate) fn mesh_structured(
loops: &[Vec<Q>],
starts: &[Vec<usize>],
scheme: Scheme,
size: &dyn SizeField,
opts: &QuadOptions,
) -> Result<Option<QuadMesh>> {
Ok(match scheme {
Scheme::Mapped(c) => {
let s0 = starts.first();
let pos = c.map(|k| s0.and_then(|s| s.get(k)).copied().unwrap_or(0));
@@ -181,11 +196,7 @@ pub(crate) fn mesh(loops: &[Vec<Q>], starts: &[Vec<usize>], scheme: Scheme, size
Scheme::Annulus => annulus(loops, size, opts)?,
Scheme::Disk => disk(loops, size, opts)?,
Scheme::Free => None,
};
match structured {
Some(m) => Ok((m, scheme)),
None => Ok((free_quad_mesh(loops, size, opts)?, Scheme::Free)),
}
})
}
/// The triangle primitive on one loop with corners at node positions `corners` (in loop order),
+139
View File
@@ -0,0 +1,139 @@
//! The quadrilateral mesh of a swept cross-section: the scheme of every filled region of the
//! arrangement and its interval constraints, then, on the assigned counts, the nodes of every
//! curve (shared by the regions on either side) and each region's quadrilaterals.
use std::collections::HashMap;
use super::arrangement::{Arrangement, Q};
use super::interval::Intervals;
use super::region::{self, Scheme};
use crate::quad::QuadOptions;
use crate::size::SizeField;
use crate::{Error, Result};
/// A meshed cross-section: its nodes and, per region of the arrangement, its quadrilaterals and
/// the scheme that made them (free for regions not filled).
pub(crate) struct Section {
pub nodes: Vec<Q>,
pub quads: Vec<Vec<[u32; 4]>>,
pub schemes: Vec<Scheme>,
}
/// The scheme of each filled region (`used`), its constraints added to `ia` (one variable per
/// curve of the arrangement, by curve index).
pub(crate) fn schemes(arr: &Arrangement, used: &[bool], ia: &mut Intervals) -> Vec<Scheme> {
let oriented = |c: usize, fwd: bool| -> Vec<Q> {
let mut v: Vec<Q> =
arr.curves.get(c).map(|x| x.points.iter().map(|&p| arr.points.get(p).copied().unwrap_or([0.0; 2])).collect()).unwrap_or_default();
if !fwd {
v.reverse();
}
v
};
let mut schemes: Vec<Scheme> = vec![Scheme::Free; arr.regions.len()];
for (r, region) in arr.regions.iter().enumerate() {
if !used.get(r).copied().unwrap_or(false) {
continue;
}
let polylines: Vec<Vec<Vec<Q>>> = region.loops.iter().map(|lp| lp.iter().map(|&(c, f)| oriented(c, f)).collect()).collect();
let scheme = region::classify(&polylines);
let vars: Vec<Vec<usize>> = region.loops.iter().map(|lp| lp.iter().map(|x| x.0).collect()).collect();
region::constrain(ia, &vars, scheme);
if let Some(slot) = schemes.get_mut(r) {
*slot = scheme;
}
}
schemes
}
/// Mesh the filled regions on the curve counts `counts`: `place(c, n)` gives the `n + 1` points
/// of curve `c` (its interior ones are used; its ends are the arrangement's points, shared).
/// The schemes come from [`schemes`]; a structured one whose grid would fold is meshed free.
pub(crate) fn mesh(
arr: &Arrangement,
used: &[bool],
mut schemes: Vec<Scheme>,
counts: &[usize],
place: &dyn Fn(usize, usize) -> Result<Vec<Q>>,
field: &dyn SizeField,
opts: &QuadOptions,
) -> Result<Section> {
// Nodes: curve ends, then curve interiors.
let mut nodes: Vec<Q> = Vec::new();
let mut point_node: HashMap<usize, u32> = HashMap::new();
let mut curve_nodes: Vec<Vec<u32>> = Vec::with_capacity(arr.curves.len());
for (c, curve) in arr.curves.iter().enumerate() {
let n = counts.get(c).copied().unwrap_or(1);
let pts = place(c, n)?;
let mut ids = Vec::with_capacity(pts.len());
let last = pts.len().saturating_sub(1);
for (k, p) in pts.iter().enumerate() {
let end = if k == 0 {
curve.points.first()
} else if k == last {
curve.points.last()
} else {
None
};
let id = match end {
Some(&pid) => *point_node.entry(pid).or_insert_with(|| {
nodes.push(arr.points.get(pid).copied().unwrap_or([0.0; 2]));
nodes.len() as u32 - 1
}),
None => {
nodes.push(*p);
nodes.len() as u32 - 1
}
};
ids.push(id);
}
curve_nodes.push(ids);
}
let mut region_quads: Vec<Vec<[u32; 4]>> = vec![Vec::new(); arr.regions.len()];
for (r, region) in arr.regions.iter().enumerate() {
if !used.get(r).copied().unwrap_or(false) {
continue;
}
let mut loops_ids: Vec<Vec<u32>> = Vec::with_capacity(region.loops.len());
let mut starts: Vec<Vec<usize>> = Vec::new();
for lp in &region.loops {
let mut ids = Vec::new();
let mut st = Vec::new();
for &(c, fwd) in lp {
st.push(ids.len());
let mut seq: Vec<u32> = curve_nodes.get(c).cloned().unwrap_or_default();
if !fwd {
seq.reverse();
}
seq.pop();
ids.extend(seq);
}
loops_ids.push(ids);
starts.push(st);
}
let at = |i: u32| nodes.get(i as usize).copied().unwrap_or([0.0; 2]);
let loops: Vec<Vec<Q>> = loops_ids.iter().map(|l| l.iter().map(|&i| at(i)).collect()).collect();
let scheme = schemes.get(r).copied().unwrap_or(Scheme::Free);
let (mesh, used_scheme) = region::mesh(&loops, &starts, scheme, field, opts).map_err(|e| region_error(r, e))?;
if let Some(slot) = schemes.get_mut(r) {
*slot = used_scheme;
}
let mut local: Vec<u32> = loops_ids.concat();
for p in mesh.nodes.iter().skip(mesh.boundary_nodes) {
nodes.push(*p);
local.push(nodes.len() as u32 - 1);
}
let quads = region_quads.get_mut(r).ok_or_else(|| Error::Failed("region index".into()))?;
for q in &mesh.quads {
quads.push(q.map(|i| local.get(i as usize).copied().unwrap_or(0)));
}
}
Ok(Section { nodes, quads: region_quads, schemes })
}
fn region_error(r: usize, e: Error) -> Error {
match e {
Error::Limit(m) => Error::Limit(m),
other => Error::Failed(format!("cross-section region {r}: {other}")),
}
}
+28
View File
@@ -208,6 +208,19 @@ pub fn split_edge(cx: &mut TetComplex, ctx: &Context, u: u32, w: u32, tag: u32,
None if tag != NONE => VertexKind::Face(tag),
None => return Ok(None),
};
// A midpoint inside the diametral sphere of a nearby feature edge encroaches it: split that
// edge instead (Shewchuk 1998), or face vertices pile up against the curve, each split
// halving their distance to it.
if matches!(kind, VertexKind::Face(_)) {
let mut near = vec![u, w];
if let Some(ring) = cx.edge_ring(u, w)? {
near.extend(ring.verts.first().copied());
near.extend(ring.verts.last().copied());
}
if let Some((a, b)) = encroached_curve_edge(cx, &near, m)? {
return split_edge(cx, ctx, a, b, tag, min_face);
}
}
let Some(p) = ctx.place(kind, m) else { return Ok(None) };
// The projection may only move the midpoint a little (a quarter of the edge).
if measure::dist(p.coords(), m) > 0.25 * len {
@@ -216,6 +229,21 @@ pub fn split_edge(cx: &mut TetComplex, ctx: &Context, u: u32, w: u32, tag: u32,
ops::insert_delaunay_apart(cx, p, kind, Site::Edge(u, w), MAX_CAVITY, MIN_GAP * ctx.h(m, len))
}
/// A feature-curve edge at one of the vertices `near` whose diametral sphere holds `p`.
fn encroached_curve_edge(cx: &mut TetComplex, near: &[u32], p: P) -> Result<Option<(u32, u32)>> {
for &c in near {
for n in cx.neighbours(c)? {
if cx.curve(c, n).is_some() {
let (a, b) = (cx.xyz(c)?, cx.xyz(n)?);
if measure::dist(p, measure::mid(a, b)) < 0.5 * measure::dist(a, b) {
return Ok(Some((c, n)));
}
}
}
}
Ok(None)
}
/// Whether a tetrahedron is bad, and its circumcentre.
fn badness(x: &[P; 4], ctx: &Context, o: &RefineOptions) -> Option<P> {
let c = measure::circumcenter(x)?;
+47
View File
@@ -195,6 +195,53 @@ mod pipeline {
assert_eq!(a, b);
}
/// Refinement splitting a face edge whose midpoint lies inside a feature edge's diametral
/// sphere splits the feature edge instead (#303): otherwise each split halves the distance
/// of the face vertices to the curve and they pile up against it.
#[test]
fn a_face_edge_split_that_encroaches_a_curve_splits_the_curve() {
use super::super::complex::{NONE, VertexKind, face_toward};
use super::super::geometry::Tessellation;
use super::super::ops::{Site, insert_delaunay};
use super::super::refine::{Context, MAX_CAVITY, split_edge};
use super::super::{complex_from_cdt, measure};
use crate::predicates::Point3;
use crate::size::UniformSize;
// Whether `p` lies in triangle `f` of the z = 0 plane.
let contains = |f: [[f64; 3]; 3], p: [f64; 3]| {
let side = |a: [f64; 3], b: [f64; 3]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
let s = [side(f[0], f[1]), side(f[1], f[2]), side(f[2], f[0])];
s.iter().all(|&x| x >= 0.0) || s.iter().all(|&x| x <= 0.0)
};
let s = box_surface();
let cdt = tetrahedralize(&s, &CdtOptions::default()).unwrap();
let (mut cx, curve_faces) = complex_from_cdt(&s, &cdt, &QualityOptions::default()).unwrap();
let (geometry, size) = (Tessellation::new(&s, Vec::new()), UniformSize(5.0));
let ctx = Context { size: &size, geometry: &geometry, curve_faces: &curve_faces };
// A curve edge on the z = 0 side, and a face vertex beside it on that side.
let ((a, b), _) = cx.curve_edges().into_iter().find(|&((a, b), _)| [a, b].iter().all(|&v| cx.xyz(v).unwrap()[2] == 0.0)).unwrap();
let (xa, xb) = (cx.xyz(a).unwrap(), cx.xyz(b).unwrap());
let len = measure::dist(xa, xb);
let centre = [5.0, 5.0, 0.0];
let mid = measure::mid(xa, xb);
let inward = measure::scale(measure::sub(centre, mid), 0.3 * len / measure::dist(centre, mid));
let q = Point3::try_from(measure::add(mid, inward)).unwrap();
let (t, k) = cx
.live_tets()
.flat_map(|(t, vs)| (0..4).map(move |k| (t, vs, k)))
.find(|&(t, vs, k)| {
cx.adj(t).unwrap()[k] == NONE && cx.tags(t).unwrap()[k] == 4 && contains(face_toward(vs, k).map(|v| cx.xyz(v).unwrap()), q.coords())
})
.map(|(t, _, k)| (t, k))
.unwrap();
let v = insert_delaunay(&mut cx, q, VertexKind::Face(4), Site::Face(t, k), MAX_CAVITY).unwrap().unwrap();
assert!(cx.edge_tet(v, a).unwrap().is_some());
let w = split_edge(&mut cx, &ctx, v, a, 4, 0.0).unwrap().unwrap();
assert!(matches!(cx.kind(w), VertexKind::Curve(_)), "{:?}", cx.kind(w));
assert!(measure::dist(cx.xyz(w).unwrap(), mid) < 1e-9);
cx.validate().unwrap();
}
#[test]
fn a_seeded_edge_gets_the_seeded_spacing_and_the_size_grows_from_it() {
use crate::seeds::EdgeSeed;
+6 -2
View File
@@ -232,10 +232,14 @@ impl Model {
replace_existing(&mut self.boundary_conditions, "boundary condition", name, bc)
}
/// The steps that list a boundary condition, as `step \`name\`` strings.
pub fn boundary_condition_users(&self, name: &str) -> Vec<String> {
self.steps.iter().filter(|s| s.boundary_conditions.contains(name)).map(|s| format!("step `{}`", s.name)).collect()
}
/// Remove a boundary condition that no step lists.
pub fn remove_boundary_condition(&mut self, name: &str) -> Result<()> {
let users = self.steps.iter().filter(|s| s.boundary_conditions.contains(name)).map(|s| format!("step `{}`", s.name)).collect();
not_in_use("boundary condition", name, users)?;
not_in_use("boundary condition", name, self.boundary_condition_users(name))?;
remove_existing(&mut self.boundary_conditions, "boundary condition", name).map(|_| ())
}
}
+7 -2
View File
@@ -95,6 +95,12 @@ impl Model {
}
pub fn remove_reference_point(&mut self, name: &str) -> Result<()> {
not_in_use("reference point", name, self.reference_point_users(name))?;
remove_existing(&mut self.reference_points, "reference point", name).map(|_| ())
}
/// The definitions that refer to a reference point, as `kind \`name\`` strings.
pub fn reference_point_users(&self, name: &str) -> Vec<String> {
let mut users: Vec<String> =
self.couplings.iter().filter(|(_, c)| c.reference_point == name).map(|(n, _)| format!("coupling `{n}`")).collect();
users.extend(
@@ -105,8 +111,7 @@ impl Model {
);
users.extend(self.loads.iter().filter(|(_, l)| l.reference_point() == Some(name)).map(|(n, _)| format!("load `{n}`")));
users.extend(self.discrete_users(|t| t.reference_point() == Some(name)));
not_in_use("reference point", name, users)?;
remove_existing(&mut self.reference_points, "reference point", name).map(|_| ())
users
}
/// The node label of each reference point in decks.
+46 -8
View File
@@ -1,8 +1,9 @@
//! Datum planes and axes, and the partitions that cut the geometry by datum planes.
//! Datum planes, axes and points, and the partitions that cut the geometry by datum planes.
//!
//! A datum is a construction reference in model coordinates: a plane (a point and its unit
//! normal) or an axis (a point and its unit direction). Partitions, mirrors and symmetry
//! conditions name them. A **partition** cuts bodies of the geometry by a datum plane into cells
//! normal), an axis (a point and its unit direction) or a point. Partitions, mirrors and symmetry
//! conditions name planes; points are inputs for planes and axes (their coordinates are taken
//! when those are made). A **partition** cuts bodies of the geometry by a datum plane into cells
//! (the geometry layer does the cutting); the model keeps the partitions in the order they were
//! made, because each cuts the cells the ones before it left.
@@ -19,7 +20,7 @@ pub const MAX_PARTITION_BODIES: usize = 1000;
/// Longest body name a partition holds.
const MAX_BODY_NAME: usize = 256;
/// A datum plane or axis.
/// A datum plane, axis or point.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Datum {
@@ -27,6 +28,8 @@ pub enum Datum {
Plane { point: [f64; 3], normal: [f64; 3] },
/// A point on the axis and its unit direction.
Axis { point: [f64; 3], direction: [f64; 3] },
/// A point.
Point { point: [f64; 3] },
}
impl Datum {
@@ -48,14 +51,40 @@ impl Datum {
Ok(match self {
Datum::Plane { point: p, normal } => Datum::Plane { point: point(p)?, normal: unit("normal", normal)? },
Datum::Axis { point: p, direction } => Datum::Axis { point: point(p)?, direction: unit("direction", direction)? },
Datum::Point { point: p } => Datum::Point { point: point(p)? },
})
}
/// The plane's point and unit normal; `None` for an axis.
/// The plane's point and unit normal; `None` for an axis or a point.
pub fn plane(&self) -> Option<([f64; 3], [f64; 3])> {
match *self {
Datum::Plane { point, normal } => Some((point, normal)),
Datum::Axis { .. } => None,
Datum::Axis { .. } | Datum::Point { .. } => None,
}
}
/// The axis's point and unit direction; `None` for a plane or a point.
pub fn axis(&self) -> Option<([f64; 3], [f64; 3])> {
match *self {
Datum::Axis { point, direction } => Some((point, direction)),
Datum::Plane { .. } | Datum::Point { .. } => None,
}
}
/// The point's coordinates; `None` for a plane or an axis.
pub fn point(&self) -> Option<[f64; 3]> {
match *self {
Datum::Point { point } => Some(point),
Datum::Plane { .. } | Datum::Axis { .. } => None,
}
}
/// `plane`, `axis` or `point`.
pub fn kind(&self) -> &'static str {
match self {
Datum::Plane { .. } => "plane",
Datum::Axis { .. } => "axis",
Datum::Point { .. } => "point",
}
}
}
@@ -101,7 +130,9 @@ impl Model {
remove_existing(&mut self.datums, "datum", name).map(|_| ())
}
fn datum_users(&self, name: &str) -> Vec<String> {
/// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\``
/// strings.
pub fn datum_users(&self, name: &str) -> Vec<String> {
self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name)).collect()
}
@@ -123,7 +154,9 @@ impl Model {
let invalid = |m: String| context("partition", &name, Error::Invalid(m));
match self.datums.get(&partition.datum) {
None => return Err(context("partition", &name, Error::NotFound(format!("datum `{}`", partition.datum)))),
Some(d) if d.plane().is_none() => return Err(invalid(format!("datum `{}` is an axis; a partition cuts by a plane", partition.datum))),
Some(d) if d.plane().is_none() => {
return Err(invalid(format!("datum `{}` is a datum {}; a partition cuts by a plane", partition.datum, d.kind())));
}
Some(_) => {}
}
if partition.bodies.len() > MAX_PARTITION_BODIES {
@@ -168,6 +201,11 @@ mod tests {
m.add_partition(p("Half", "Mid")).unwrap();
assert!(m.add_partition(p("Half", "Mid")).is_err());
assert!(m.add_partition(p("Bad", "Axis")).is_err());
m.add_datum("P", Datum::Point { point: [1.0, 2.0, 3.0] }).unwrap();
assert_eq!((m.datum("P").and_then(Datum::point), m.datum("P").map(Datum::kind)), (Some([1.0, 2.0, 3.0]), Some("point")));
assert!(m.add_partition(p("Bad", "P")).is_err());
assert!(m.add_datum("Q", Datum::Point { point: [f64::NAN, 0.0, 0.0] }).is_err());
assert_eq!(m.datum_users("Mid"), ["partition `Half`"]);
assert!(m.add_partition(p("Bad", "Nope")).is_err());
assert!(m.add_partition(Partition { bodies: vec!["A".into(), "A".into()], ..p("Bad", "Mid") }).is_err());
assert!(matches!(m.remove_datum("Mid"), Err(Error::InUse(_))));
+7 -3
View File
@@ -423,9 +423,13 @@ impl Model {
replace_existing(&mut self.fluid_domains, "fluid domain", name, domain)
}
/// The steps that solve a fluid domain, as `step \`name\`` strings.
pub fn fluid_domain_users(&self, name: &str) -> Vec<String> {
self.steps.iter().filter(|s| s.procedure.fluid_domain() == Some(name)).map(|s| format!("step `{}`", s.name)).collect()
}
pub fn remove_fluid_domain(&mut self, name: &str) -> Result<()> {
let users = self.steps.iter().filter(|s| s.procedure.fluid_domain() == Some(name)).map(|s| format!("step `{}`", s.name)).collect();
not_in_use("fluid domain", name, users)?;
not_in_use("fluid domain", name, self.fluid_domain_users(name))?;
remove_existing(&mut self.fluid_domains, "fluid domain", name).map(|_| ())
}
@@ -437,7 +441,7 @@ impl Model {
}
/// The fluid domains that use a material.
pub(crate) fn fluid_domain_users(&self, material: &str) -> impl Iterator<Item = &str> {
pub(crate) fn fluid_domains_of_material(&self, material: &str) -> impl Iterator<Item = &str> {
self.fluid_domains.iter().filter(move |(_, d)| d.material == material).map(|(n, _)| n.as_str())
}
}
+6 -2
View File
@@ -222,10 +222,14 @@ impl Model {
replace_existing(&mut self.loads, "load", name, load)
}
/// The steps that list a load, as `step \`name\`` strings.
pub fn load_users(&self, name: &str) -> Vec<String> {
self.steps.iter().filter(|s| s.loads.contains(name)).map(|s| format!("step `{}`", s.name)).collect()
}
/// Remove a load that no step lists.
pub fn remove_load(&mut self, name: &str) -> Result<()> {
let users = self.steps.iter().filter(|s| s.loads.contains(name)).map(|s| format!("step `{}`", s.name)).collect();
not_in_use("load", name, users)?;
not_in_use("load", name, self.load_users(name))?;
remove_existing(&mut self.loads, "load", name).map(|_| ())
}
}
+9 -4
View File
@@ -511,17 +511,22 @@ impl Model {
pub fn replace_material(&mut self, name: &str, material: Material) -> Result<()> {
material.validate().map_err(|e| context("material", name, e))?;
if material.fluid.is_none()
&& let Some(d) = self.fluid_domain_users(name).next()
&& let Some(d) = self.fluid_domains_of_material(name).next()
{
return Err(Error::Invalid(format!("material `{name}`: fluid domain `{d}` needs its fluid behaviour")));
}
replace_existing(&mut self.materials, "material", name, material)
}
pub fn remove_material(&mut self, name: &str) -> Result<()> {
/// The definitions that refer to a material, as `kind \`name\`` strings.
pub fn material_users(&self, name: &str) -> Vec<String> {
let mut users: Vec<String> = self.sections.iter().filter(|(_, s)| s.material == name).map(|(n, _)| format!("section `{n}`")).collect();
users.extend(self.fluid_domain_users(name).map(|n| format!("fluid domain `{n}`")));
not_in_use("material", name, users)?;
users.extend(self.fluid_domains_of_material(name).map(|n| format!("fluid domain `{n}`")));
users
}
pub fn remove_material(&mut self, name: &str) -> Result<()> {
not_in_use("material", name, self.material_users(name))?;
remove_existing(&mut self.materials, "material", name).map(|_| ())
}
}
+1 -1
View File
@@ -134,7 +134,7 @@ impl Model {
}
/// The definitions that refer to a set, as `kind \`name\`` strings.
pub(crate) fn set_users(&self, set: &str) -> Vec<String> {
pub fn set_users(&self, set: &str) -> Vec<String> {
let mut users = Vec::new();
users.extend(self.sections.iter().filter(|(_, s)| s.set == set).map(|(n, _)| format!("section `{n}`")));
users.extend(self.couplings.iter().filter(|(_, c)| c.set == set).map(|(n, _)| format!("coupling `{n}`")));
+212 -32
View File
@@ -722,6 +722,16 @@ impl Glyphs {
Glyphs { owners: b.owners, groups: b.groups, marks: b.marks, unit }
}
/// [`Glyphs::from_model`] without the definitions `hidden` names (the view's hidden items).
pub fn from_model_hiding(model: &Model, hidden: &dyn Fn(Definition, &str) -> bool) -> Glyphs {
let mut g = Glyphs::from_model(model);
let gone: Vec<bool> = g.owners.iter().map(|(d, n)| hidden(*d, n)).collect();
let kept = |owner: usize| !gone.get(owner).copied().unwrap_or(false);
g.groups.retain(|x| kept(x.owner));
g.marks.retain(|(owner, _)| kept(*owner));
g
}
/// The model's own symbol unit (a fortieth of its bounding box diagonal): the size symbols
/// are drawn at when no camera gives one.
pub fn model_unit(&self) -> f64 {
@@ -838,46 +848,130 @@ impl Glyphs {
}
}
/// How far a datum plane's square reaches from the model's centre, and a datum axis's line,
/// as a fraction of the bounding box diagonal.
const DATUM_REACH: f64 = 0.6;
/// Hatch lines across a datum plane's square.
/// The margin around a datum plane's rectangle and beyond a datum axis's ends, as a fraction
/// of the model's extent drawn.
const DATUM_MARGIN: f64 = 0.05;
/// A datum point's cross: each arm's length, as a fraction of the bounding box diagonal.
const DATUM_POINT: f64 = 0.02;
/// Hatch lines across a datum plane's rectangle (its light fill).
const DATUM_HATCH: usize = 4;
/// Draw the model's datum planes (an outlined, hatched square around the model, `bounds`) and
/// axes (a line through the model), each with its name; returns the box around them.
pub fn datums(model: &Model, bounds: &Aabb3, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 {
/// Corner `k` (0..8, one bit per axis) of box `b`.
fn box_corner(b: &Aabb3, k: usize) -> Vec3 {
let pick = |bit: usize, lo: f64, hi: f64| if k & bit == 0 { lo } else { hi };
Vec3::new(pick(1, b.min.x, b.max.x), pick(2, b.min.y, b.max.y), pick(4, b.min.z, b.max.z))
}
/// The rectangle datum plane (`p`, unit normal `n`) is drawn as, in its own axes `(u, v)`
/// ([`perpendiculars`]): the model's extent within the plane (where the plane cuts `bounds`;
/// the box's shadow on the plane when it misses it) with a [`DATUM_MARGIN`] around it. Returns
/// the rectangle's centre on the plane and its half sizes along `u` and `v`.
fn plane_rect(bounds: &Aabb3, p: Vec3, n: Vec3, u: Vec3, v: Vec3) -> (Vec3, f64, f64) {
if bounds.is_empty() || !bounds.diagonal().is_finite() {
return (p, 1.0, 1.0);
}
let (lo, hi) = (bounds.min, bounds.max);
let corner =
|k: usize| Vec3::new(if k & 1 == 0 { lo.x } else { hi.x }, if k & 2 == 0 { lo.y } else { hi.y }, if k & 4 == 0 { lo.z } else { hi.z });
let height = |q: Vec3| (q - p).dot(n);
// The box's twelve edges join corners differing in one coordinate bit.
let mut cut: Vec<Vec3> = Vec::with_capacity(12);
for a in 0..8usize {
for bit in [1usize, 2, 4].into_iter().filter(|bit| a & bit == 0) {
let b = a | bit;
let (qa, qb) = (corner(a), corner(b));
let (ha, hb) = (height(qa), height(qb));
if (ha <= 0.0 && hb >= 0.0) || (ha >= 0.0 && hb <= 0.0) {
let t = if (ha - hb).abs() > 0.0 { ha / (ha - hb) } else { 0.0 };
cut.push(qa + (qb - qa) * t);
}
}
}
if cut.is_empty() {
cut = (0..8).map(|k| corner(k) - n * height(corner(k))).collect();
}
let (mut u0, mut u1, mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for q in &cut {
let (a, b) = ((*q - p).dot(u), (*q - p).dot(v));
(u0, u1, v0, v1) = (u0.min(a), u1.max(a), v0.min(b), v1.max(b));
}
let size = (u1 - u0).max(v1 - v0);
let margin = if size > 0.0 { DATUM_MARGIN * size } else { DATUM_MARGIN * bounds.diagonal().max(1e-9) };
let centre = p + u * ((u0 + u1) * 0.5) + v * ((v0 + v1) * 0.5);
(centre, (u1 - u0) * 0.5 + margin, (v1 - v0) * 0.5 + margin)
}
/// The ends datum axis (`p`, unit direction `a`) is drawn between: the model's extent along it
/// (`bounds`' corners projected on it) with a [`DATUM_MARGIN`] beyond each end.
fn axis_ends(bounds: &Aabb3, p: Vec3, a: Vec3) -> (Vec3, Vec3) {
if bounds.is_empty() || !bounds.diagonal().is_finite() {
return (p - a, p + a);
}
let (mut t0, mut t1) = (f64::INFINITY, f64::NEG_INFINITY);
for k in 0..8usize {
let t = (box_corner(bounds, k) - p).dot(a);
(t0, t1) = (t0.min(t), t1.max(t));
}
let span = t1 - t0;
let margin = if span > 0.0 { DATUM_MARGIN * span } else { DATUM_MARGIN * bounds.diagonal().max(1e-9) };
(p + a * (t0 - margin), p + a * (t1 + margin))
}
/// Draw the model's datum planes (an outlined, lightly hatched rectangle: the model's extent
/// within the plane, `bounds`, plus a margin), axes (a line along the model's extent) and points
/// (a small cross), each with its name; returns the box around them. Only the datums `shown`
/// takes are drawn, and `highlight` (the one selected in the browser) whether shown or not, in
/// the highlight colour.
pub fn datums(
model: &Model,
bounds: &Aabb3,
shown: &dyn Fn(&str) -> bool,
highlight: Option<&str>,
colors: &GlyphColors,
scene: &mut Scene,
) -> Aabb3 {
let mut d = Draw { scene, bounds: Aabb3::EMPTY, color: colors.datum, width: OUTLINE_WIDTH, on_top: false };
let (centre, diagonal) = if bounds.is_empty() { (Vec3::ZERO, 1.0) } else { (bounds.center(), bounds.diagonal().max(1e-9)) };
let reach = DATUM_REACH * diagonal;
for (name, datum) in model.datums() {
let diagonal = if bounds.is_empty() { 1.0 } else { bounds.diagonal().max(1e-9) };
for (name, datum) in model.datums().iter().filter(|(n, _)| shown(n) || highlight == Some(n.as_str())) {
let picked = highlight == Some(name.as_str());
let color = if picked { colors.highlight } else { colors.datum };
let (outline, thin) = if picked { (HIGHLIGHT_WIDTH, OUTLINE_WIDTH) } else { (OUTLINE_WIDTH, COUPLING_WIDTH) };
// The one selected in the browser shows through the part.
(d.color, d.on_top) = (color, picked);
match *datum {
fieldcraft_model::Datum::Plane { point, normal } => {
let (p, n) = (Vec3::from(point), Vec3::from(normal));
// The model's centre moved onto the plane.
let c = centre - n * (centre - p).dot(n);
let (u, v) = perpendiculars(n);
let corner = |a: f64, b: f64| c + u * (a * reach) + v * (b * reach);
let (c, hu, hv) = plane_rect(bounds, p, n, u, v);
let corner = |a: f64, b: f64| c + u * (a * hu) + v * (b * hv);
let ring = [corner(-1.0, -1.0), corner(1.0, -1.0), corner(1.0, 1.0), corner(-1.0, 1.0)];
d.width = outline;
for (k, a) in ring.iter().enumerate() {
let b = ring.get((k + 1) % 4).copied().unwrap_or(*a);
d.width = OUTLINE_WIDTH;
d.line(*a, b);
}
d.width = thin;
for k in 1..DATUM_HATCH {
let t = -1.0 + 2.0 * k as f64 / DATUM_HATCH as f64;
d.width = COUPLING_WIDTH;
d.line(corner(t, -1.0), corner(t, 1.0));
d.line(corner(-1.0, t), corner(1.0, t));
}
d.scene.labels.push(SceneLabel { p: corner(1.0, 1.0), text: name.clone(), color: colors.datum });
d.scene.labels.push(SceneLabel { p: corner(1.0, 1.0), text: name.clone(), color });
}
fieldcraft_model::Datum::Axis { point, direction } => {
let (p, a) = (Vec3::from(point), Vec3::from(direction));
let c = p + a * (centre - p).dot(a);
d.width = SHAFT_WIDTH;
d.line(c - a * reach, c + a * reach);
d.scene.labels.push(SceneLabel { p: c + a * reach, text: name.clone(), color: colors.datum });
let (a, b) = axis_ends(bounds, Vec3::from(point), Vec3::from(direction));
d.width = if picked { SHAFT_WIDTH + 1.0 } else { SHAFT_WIDTH };
d.line(a, b);
d.scene.labels.push(SceneLabel { p: b, text: name.clone(), color });
}
fieldcraft_model::Datum::Point { point } => {
let p = Vec3::from(point);
let arm = DATUM_POINT * diagonal;
d.width = outline;
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
d.line(p - axis * arm, p + axis * arm);
}
d.scene.labels.push(SceneLabel { p: p + (Vec3::X + Vec3::Y) * arm, text: name.clone(), color });
}
}
}
@@ -1126,6 +1220,12 @@ mod tests {
let (s, _) = drawn_at(&g, &m, &view(GlyphShow::ALL, u));
assert!(s.tris.is_empty() && s.on_top.is_empty());
assert_eq!(s.lines.len(), 4 * 3 * 6);
// A hidden definition draws nothing; hiding another leaves it.
let (hidden, _) =
drawn_at(&Glyphs::from_model_hiding(&m, &|d, n| d == Definition::BoundaryCondition && n == "Clamp"), &m, &view(GlyphShow::ALL, u));
assert!(hidden.lines.is_empty());
let (other, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, _| d == Definition::Load), &m, &view(GlyphShow::ALL, u));
assert_eq!(other.lines.len(), s.lines.len());
assert!(s.lines.iter().all(|l| l.color == COLORS.constraint && l.width == LINE_WIDTH));
// Tip on the node, each triangle along its axis (two crossed bases per axis).
let b = bases(&s, Vec3::ZERO);
@@ -1489,23 +1589,103 @@ mod tests {
b.diagonal()
}
fn cube(lo: f64, hi: f64) -> Aabb3 {
let mut b = Aabb3::EMPTY;
b.add(Vec3::new(lo, lo, lo));
b.add(Vec3::new(hi, hi, hi));
b
}
#[test]
fn datums_draw_around_the_model_with_their_names() {
fn datums_draw_at_the_model_with_their_names() {
let mut m = Model::default();
m.add_datum("Mid", fieldcraft_model::Datum::Plane { point: [5.0, 0.0, 0.0], normal: [1.0, 0.0, 0.0] }).unwrap();
m.add_datum("Ax", fieldcraft_model::Datum::Axis { point: [0.0; 3], direction: [0.0, 0.0, 1.0] }).unwrap();
let mut bounds = Aabb3::EMPTY;
bounds.add(Vec3::ZERO);
bounds.add(Vec3::new(10.0, 10.0, 10.0));
m.add_datum("Pt", fieldcraft_model::Datum::Point { point: [1.0, 2.0, 3.0] }).unwrap();
let bounds = cube(0.0, 10.0);
let mut scene = Scene::default();
let drawn = datums(&m, &bounds, &COLORS, &mut scene);
// A square with its hatch (4 + 2 × 3 lines) and the axis line.
assert_eq!(scene.lines.len(), 11);
let drawn = datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
// A rectangle with its hatch (4 + 2 × 3 lines), the axis line and the point's three arms,
// each crossing at the point.
assert_eq!(scene.lines.len(), 14);
let p = Vec3::new(1.0, 2.0, 3.0);
assert!(scene.lines.iter().rev().take(3).all(|l| ((l.a + l.b) * 0.5).dist(p) < 1e-12));
let mut names: Vec<&str> = scene.labels.iter().map(|l| l.text.as_str()).collect();
names.sort_unstable();
assert_eq!(names, ["Ax", "Mid"]);
// The plane's lines lie on x = 5, centred on the model.
assert!(scene.lines.iter().skip(1).all(|l| (l.a.x - 5.0).abs() < 1e-12 && (l.b.x - 5.0).abs() < 1e-12));
assert!(drawn.diagonal() > bounds.diagonal());
assert_eq!(names, ["Ax", "Mid", "Pt"]);
// The axis spans the model along z with a 5 % margin: z from -0.5 to 10.5.
let axis = scene.lines.iter().find(|l| l.width == SHAFT_WIDTH).unwrap();
assert!((axis.a.z + 0.5).abs() < 1e-9 && (axis.b.z - 10.5).abs() < 1e-9, "{axis:?}");
// Everything drawn stays within the model's box and its margin.
let mut reach = bounds;
reach.add(Vec3::new(-0.5, -0.5, -0.5));
reach.add(Vec3::new(10.5, 10.5, 10.5));
assert!(drawn.min.x >= reach.min.x - 1e-9 && drawn.max.z <= reach.max.z + 1e-9, "{drawn:?}");
// A datum left out draws nothing; the highlighted one draws anyway, on top.
let mut only = Scene::default();
datums(&m, &bounds, &|n| n == "Ax", None, &COLORS, &mut only);
assert_eq!((only.lines.len(), only.labels.len()), (1, 1));
let mut picked = Scene::default();
datums(&m, &bounds, &|_| false, Some("Mid"), &COLORS, &mut picked);
assert_eq!((picked.lines.len(), picked.on_top.len()), (0, 10));
assert!(picked.on_top.iter().all(|l| l.color == COLORS.highlight));
}
/// A datum plane at any offset or angle is drawn as the model's extent within it plus the
/// margin: every corner lies on the plane and within the model's box grown by the margin.
#[test]
fn datum_planes_fit_the_model_at_any_offset_and_angle() {
let bounds = cube(-20.0, 20.0);
let size = 40.0;
let normals = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 0.0], [1.0, 2.0, 3.0], [0.3, -0.2, 0.9]];
for nr in normals {
let n = Vec3::from(nr).normalized().unwrap();
for offset in [-19.0, -7.5, 0.0, 3.0, 15.0] {
let p = n * offset;
let mut m = Model::default();
m.add_datum("D", fieldcraft_model::Datum::Plane { point: [p.x, p.y, p.z], normal: [n.x, n.y, n.z] }).unwrap();
let mut scene = Scene::default();
datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
// Every line lies on the plane.
assert!(scene.lines.iter().all(|l| ((l.a - p).dot(n)).abs() < 1e-9 && ((l.b - p).dot(n)).abs() < 1e-9), "{nr:?} at {offset}");
// The outline is the tightest rectangle in the plane's axes around the points
// where the plane cuts the box's edges, plus the margin on each side.
let (u, v) = perpendiculars(n);
let mut cut = Vec::new();
for a in 0..8usize {
for bit in [1usize, 2, 4] {
if a & bit == 0 {
let at = |k: usize| Vec3::new([-20.0, 20.0][k & 1], [-20.0, 20.0][(k >> 1) & 1], [-20.0, 20.0][(k >> 2) & 1]);
let (qa, qb) = (at(a), at(a | bit));
let (ha, hb) = ((qa - p).dot(n), (qb - p).dot(n));
if ha * hb <= 0.0 && ha != hb {
cut.push(qa + (qb - qa) * (ha / (ha - hb)));
}
}
}
}
let range = |pts: &mut dyn Iterator<Item = f64>| pts.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(x), b.max(x)));
let (cu, cv) = (range(&mut cut.iter().map(|q| (*q - p).dot(u))), range(&mut cut.iter().map(|q| (*q - p).dot(v))));
let margin = DATUM_MARGIN * (cu.1 - cu.0).max(cv.1 - cv.0);
let (ru, rv) = (
range(&mut scene.lines.iter().take(4).map(|l| (l.a - p).dot(u))),
range(&mut scene.lines.iter().take(4).map(|l| (l.a - p).dot(v))),
);
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - (b.0 - margin)).abs() < 1e-9 && (a.1 - (b.1 + margin)).abs() < 1e-9;
assert!(close(ru, cu) && close(rv, cv), "{nr:?} at {offset}: {ru:?} {rv:?} around {cu:?} {cv:?}");
// Never larger than the model's section can be, plus the margins.
assert!((ru.1 - ru.0).hypot(rv.1 - rv.0) <= (1.0 + 2.0 * DATUM_MARGIN) * 2f64.sqrt() * bounds.diagonal() + 1e-9);
if nr.iter().filter(|x| **x != 0.0).count() == 1 {
// An axis-aligned plane: the cube's face plus 5 % on each side.
assert!((ru.1 - ru.0 - size * 1.1).abs() < 1e-9 && (rv.1 - rv.0 - size * 1.1).abs() < 1e-9);
}
}
}
// A plane missing the model is drawn as the model's shadow on it.
let mut m = Model::default();
m.add_datum("Far", fieldcraft_model::Datum::Plane { point: [0.0, 0.0, 100.0], normal: [0.0, 0.0, 1.0] }).unwrap();
let mut scene = Scene::default();
let drawn = datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
assert!((drawn.max.x - 22.0).abs() < 1e-9 && (drawn.min.y + 22.0).abs() < 1e-9, "{drawn:?}");
}
}
+3
View File
@@ -15,6 +15,8 @@ fieldcraft-render = { workspace = true }
fieldcraft-explicit = { workspace = true }
fieldcraft-geometry = { workspace = true }
fieldcraft-decks = { workspace = true }
# Releases and staged installs, for the update notice (the host does the downloading).
fieldcraft-update = { workspace = true }
egui = { workspace = true }
craft-ui = { workspace = true }
egui-wgpu = { workspace = true }
@@ -22,4 +24,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
resvg = { workspace = true }
png = { workspace = true }
+24 -1
View File
@@ -330,13 +330,36 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.set_max_width(340.0);
crate::frame::menu_heading(ui, &t, "SAMPLES");
let mut open = None;
crate::files::sample_rows(ui, &t, &mut open);
crate::samples::rows(ui, &t, &mut open);
ui.separator();
crate::frame::menu_heading(ui, &t, "NAVIGATION");
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator();
let report = ui
.button("Report a Bug…")
.on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
.clicked();
let (mut check, mut reinstall) = (false, false);
if app.updates_available() {
check =
ui.button("Check for Updates").on_hover_text("Ask GitHub Releases whether a newer FieldCraft is published (app.update.check)").clicked();
reinstall = ui
.button("Reinstall FieldCraft…")
.on_hover_text("Download this version again, verify it and swap it in, to repair a damaged install")
.clicked();
}
let settings = ui.button("Settings…").on_hover_text("Privacy and updates").clicked();
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
} else if check {
app.check_for_updates();
} else if reinstall {
app.reinstall();
} else if settings {
app.open_settings();
}
if let Some(n) = open {
app.ui.menu = None;
app.open_sample(n);
+154 -58
View File
@@ -2,8 +2,11 @@
//! partitions) and Mesh with eye toggles, then the model's
//! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions)
//! with counts (Loads & BCs and Interactions with eye toggles for their viewport symbols), and
//! the solved steps' results. Clicking a definition selects it (its properties show on the
//! right), a double click opens its edit form, and clicking a result step shows it.
//! the solved steps' results. Clicking a definition, datum or partition selects it (its
//! properties show on the right), a double click opens its edit form, and clicking a result step
//! shows it. Each item the viewport draws on its own (bodies, surfaces, datums, boundary
//! conditions, loads, reference points, couplings) has its own eye, and a right click on an item
//! opens its menu ([`crate::menu`]: edit, hide, isolate, active in step, delete).
//!
//! The tree also shows where the model is in the analysis workflow
//! ([`fieldcraft_engine::progress`]): each top-level node that is a setup stage carries a mark at
@@ -24,6 +27,7 @@ use serde_json::{Value, json};
use crate::ModelApp;
use crate::icons::{self, Look};
use crate::menu::{self, Action, Target};
use crate::theme::Tokens;
/// The browser's width.
@@ -161,9 +165,27 @@ pub enum Def {
Coupling,
Contact,
FluidDomain,
/// A datum plane, axis or point.
Datum,
Partition,
}
impl Def {
pub const ALL: [Def; 12] = [
Def::Set,
Def::Material,
Def::Section,
Def::Step,
Def::BoundaryCondition,
Def::Load,
Def::ReferencePoint,
Def::Coupling,
Def::Contact,
Def::FluidDomain,
Def::Datum,
Def::Partition,
];
/// The kind of setup symbols a definition of this kind draws, if any.
pub fn symbols(self) -> Option<fieldcraft_render::glyphs::Definition> {
use fieldcraft_render::glyphs::Definition;
@@ -174,7 +196,7 @@ impl Def {
Def::Coupling => Definition::Coupling,
Def::Contact => Definition::Contact,
Def::Step => Definition::Step,
Def::Set | Def::Material | Def::Section | Def::FluidDomain => return None,
Def::Set | Def::Material | Def::Section | Def::FluidDomain | Def::Datum | Def::Partition => return None,
})
}
@@ -191,6 +213,8 @@ impl Def {
Def::Coupling => Collection::Coupling,
Def::Contact => Collection::Contact,
Def::FluidDomain => Collection::FluidDomain,
Def::Datum => Collection::Datum,
Def::Partition => Collection::Partition,
}
}
@@ -207,6 +231,8 @@ impl Def {
Def::Coupling => "coupling",
Def::Contact => "contact",
Def::FluidDomain => "fluid_domain",
Def::Datum => "datum",
Def::Partition => "partition",
}
}
@@ -222,6 +248,8 @@ impl Def {
Def::Coupling => "Coupling",
Def::Contact => "Contact",
Def::FluidDomain => "Fluid Domain",
Def::Datum => "Datum",
Def::Partition => "Partition",
}
}
@@ -238,19 +266,75 @@ impl Def {
Def::Coupling => "model.coupling.create",
Def::Contact => "interaction.contact.pair",
Def::FluidDomain => "model.fluid_domain.create",
Def::Datum => "geometry.datum",
Def::Partition => "geometry.partition",
}
}
/// The command of this kind with `verb` (`create`, `edit`, `delete`); contacts are
/// interactions, created as pairs or general contact.
/// interactions, created as pairs or general contact; datums and partitions are geometry
/// (a datum's edit command depends on its kind: [`Def::edit`]).
pub fn command(self, verb: &str) -> String {
match (self, verb) {
(Def::Contact, "create") => "interaction.contact.pair".into(),
(Def::Contact, _) => format!("interaction.contact.{verb}"),
(Def::Datum | Def::Partition, "create") => self.icon().into(),
(Def::Datum | Def::Partition, _) => format!("geometry.{}.{verb}", self.stem()),
_ => format!("model.{}.{verb}", self.stem()),
}
}
/// The command that edits definition `name` (without building its parameters); `None` when
/// it is gone or cannot be edited.
pub fn edit_command(self, m: &Model, name: &str) -> Option<String> {
match self {
Def::Partition => None,
Def::Datum => m.datum(name).map(|d| fieldcraft_engine::cmd::datum_edit_params(name, d).0.to_string()),
_ => self.exists(m, name).then(|| self.command("edit")),
}
}
/// The command that edits definition `name` and its parameters as it is (its edit form,
/// filled in); `None` when it is gone or cannot be edited (a partition).
pub fn edit(self, m: &Model, name: &str) -> Option<(String, Value)> {
match self {
Def::Partition => None,
Def::Datum => m.datum(name).map(|d| {
let (command, params) = fieldcraft_engine::cmd::datum_edit_params(name, d);
(command.to_string(), params)
}),
_ => definition(m, self, name).map(|v| (self.command("edit"), v)),
}
}
/// The viewport item it is, for the kinds the viewport draws one by one (and can hide).
pub fn item_kind(self) -> Option<fieldcraft_engine::ItemKind> {
use fieldcraft_engine::ItemKind as K;
match self {
Def::BoundaryCondition => Some(K::BoundaryCondition),
Def::Load => Some(K::Load),
Def::ReferencePoint => Some(K::ReferencePoint),
Def::Coupling => Some(K::Coupling),
Def::Datum => Some(K::Datum),
_ => None,
}
}
/// The definitions that refer to `name`, as `kind \`name\`` strings: what refuses its
/// deletion (a datum's partitions are deleted with it instead, when confirmed).
pub fn users(self, m: &Model, name: &str) -> Vec<String> {
match self {
Def::Set => m.set_users(name),
Def::Material => m.material_users(name),
Def::BoundaryCondition => m.boundary_condition_users(name),
Def::Load => m.load_users(name),
Def::ReferencePoint => m.reference_point_users(name),
Def::FluidDomain => m.fluid_domain_users(name),
Def::Datum => m.datum_users(name),
Def::Section | Def::Step | Def::Coupling | Def::Contact | Def::Partition => Vec::new(),
}
}
/// How many definitions of this kind the model has.
pub fn count(self, m: &Model) -> usize {
match self {
@@ -264,6 +348,8 @@ impl Def {
Def::Coupling => m.couplings().len(),
Def::Contact => m.contacts().len(),
Def::FluidDomain => m.fluid_domains().len(),
Def::Datum => m.datums().len(),
Def::Partition => m.partitions().len(),
}
}
@@ -280,6 +366,8 @@ impl Def {
Def::Coupling => m.couplings().contains_key(name),
Def::Contact => m.contacts().contains_key(name),
Def::FluidDomain => m.fluid_domain(name).is_some(),
Def::Datum => m.datum(name).is_some(),
Def::Partition => m.partitions().iter().any(|p| p.name == name),
}
}
@@ -296,6 +384,8 @@ impl Def {
Def::Coupling => m.couplings().keys().cloned().collect(),
Def::Contact => m.contacts().keys().cloned().collect(),
Def::FluidDomain => m.fluid_domains().keys().cloned().collect(),
Def::Datum => m.datums().keys().cloned().collect(),
Def::Partition => m.partitions().iter().map(|p| p.name.clone()).collect(),
}
}
}
@@ -313,6 +403,8 @@ pub fn definition(m: &Model, kind: Def, name: &str) -> Option<Value> {
Def::Coupling => serde_json::to_value(m.couplings().get(name)?),
Def::Contact => serde_json::to_value(m.contacts().get(name)?),
Def::FluidDomain => serde_json::to_value(m.fluid_domain(name)?),
Def::Datum => serde_json::to_value(m.datum(name)?),
Def::Partition => serde_json::to_value(m.partitions().iter().find(|p| p.name == name)?),
}
.ok()?;
let mut v = if v.is_object() { v } else { json!({"value": v}) };
@@ -351,6 +443,8 @@ struct RowResp {
double: bool,
fold: bool,
eye: bool,
/// The row's response, for its context menu (`None` when the row was not drawn).
response: Option<egui::Response>,
}
fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
@@ -446,9 +540,39 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, row.label));
out.clicked = resp.clicked() && click_on == 0;
out.double = resp.double_clicked() && click_on == 0;
out.response = Some(resp);
out
}
/// A row for one item: its eye when the viewport draws it on its own, selection by a click and
/// its edit form by a double click (definitions, datums, partitions), and its menu by a right
/// click.
fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, target: Target, acts: &mut Vec<Act>) {
if let Some(item) = target.item() {
row.eye = Some(app.session.shows(item.kind, &item.name));
}
if let Target::Def(kind, name) = &target {
row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name);
}
let r = draw_row(app, ui, id, &row);
if let Some(resp) = &r.response {
resp.context_menu(|ui| {
if let Some(a) = menu::show(app, ui, &target) {
acts.push(Act::Menu(target.clone(), a));
}
});
}
if r.eye {
acts.push(Act::Menu(target, if row.eye == Some(true) { Action::Hide } else { Action::Show }));
} else if let Target::Def(kind, name) = target {
if r.double {
acts.push(Act::Edit(kind, name));
} else if r.clicked {
acts.push(Act::Select(kind, name));
}
}
}
/// ✓ done (success), → next (accent), ⚠ blocked (muted); nothing for the rest.
fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) {
let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height());
@@ -513,6 +637,8 @@ enum Act {
ShowResult(String),
/// A ghost row's command.
Start(&'static str),
/// An item menu's entry, or an item's eye.
Menu(Target, Action),
}
/// A seed in a few words: `8 exact`, `2.5 min, bias 4 end`.
@@ -557,11 +683,12 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) {
app.selected = if app.selected.as_ref() == Some(&(kind, name.clone())) { None } else { Some((kind, name)) };
}
Act::Edit(kind, name) => {
if let Some(v) = definition(&app.session.model, kind, &name) {
if let Some((command, params)) = kind.edit(&app.session.model, &name) {
app.selected = Some((kind, name));
app.open_form(&kind.command("edit"), v);
app.open_form(&command, params);
}
}
Act::Menu(target, action) => menu::apply(app, target, action),
Act::ShowResult(step) => {
// Errors show in the status bar.
let _ = app.run("results.deformed", json!({"step": step}));
@@ -641,41 +768,29 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
&Row { depth: 2, icon: Some("geometry.open"), label: &label, note: Some(format!("{bodies} bodies")), ..Default::default() },
);
for (i, b) in d.bodies.iter().enumerate() {
draw_row(
app,
ui,
egui::Id::new(("fc_row_body", i)),
&Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() },
);
let row = Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() };
item_row(app, ui, egui::Id::new(("fc_row_body", i)), row, Target::Body(b.name.clone()), acts);
}
}
for (i, s) in surfaces.iter().enumerate() {
draw_row(
app,
ui,
egui::Id::new(("fc_row_surface", i)),
&Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() },
);
let row = Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() };
item_row(app, ui, egui::Id::new(("fc_row_surface", i)), row, Target::Surface(s.clone()), acts);
}
// Datum planes and axes, then the partitions in the order they cut.
let datums: Vec<(String, &'static str)> =
app.session.model.datums().iter().map(|(n, d)| (n.clone(), if d.plane().is_some() { "plane" } else { "axis" })).collect();
// Datum planes, axes and points, then the partitions in the order they cut.
let datums: Vec<(String, &'static str)> = app.session.model.datums().iter().map(|(n, d)| (n.clone(), d.kind())).collect();
for (i, (n, kind)) in datums.iter().enumerate() {
draw_row(
app,
ui,
egui::Id::new(("fc_row_datum", i)),
&Row { depth: 2, icon: Some("geometry.datum"), label: n, note: Some((*kind).into()), ..Default::default() },
);
let icon = match *kind {
"axis" => "geometry.datum.axis",
"point" => "geometry.datum.point",
_ => "geometry.datum",
};
let row = Row { depth: 2, icon: Some(icon), label: n, note: Some((*kind).into()), ..Default::default() };
item_row(app, ui, egui::Id::new(("fc_row_datum", i)), row, Target::Def(Def::Datum, n.clone()), acts);
}
let partitions: Vec<(String, String)> = app.session.model.partitions().iter().map(|p| (p.name.clone(), p.datum.clone())).collect();
for (i, (n, datum)) in partitions.iter().enumerate() {
draw_row(
app,
ui,
egui::Id::new(("fc_row_partition", i)),
&Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() },
);
let row = Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() };
item_row(app, ui, egui::Id::new(("fc_row_partition", i)), row, Target::Def(Def::Partition, n.clone()), acts);
}
}
ghost_rows(app, ui, &stages, "geometry", acts);
@@ -809,18 +924,9 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
}
if open(app, key) {
for (kind, name) in items {
let selected = app.selected.as_ref() == Some(&(kind, name.clone()));
let r = draw_row(
app,
ui,
egui::Id::new(("fc_row_def", kind.stem(), &name)),
&Row { depth: 2, icon: Some(kind.icon()), label: &name, selected, ..Default::default() },
);
if r.double {
acts.push(Act::Edit(kind, name));
} else if r.clicked {
acts.push(Act::Select(kind, name));
}
let id = egui::Id::new(("fc_row_def", kind.stem(), &name));
let row = Row { depth: 2, icon: Some(kind.icon()), label: &name, ..Default::default() };
item_row(app, ui, id, row, Target::Def(kind, name.clone()), acts);
}
}
ghost_rows(app, ui, &stages, key, acts);
@@ -948,19 +1054,9 @@ mod tests {
assert_eq!(steel["name"], "Steel");
assert!(steel.get("elastic").is_some(), "{steel}");
assert!(definition(m, Def::Load, "nope").is_none());
for kind in [
Def::Set,
Def::Material,
Def::Section,
Def::Step,
Def::BoundaryCondition,
Def::Load,
Def::ReferencePoint,
Def::Coupling,
Def::Contact,
Def::FluidDomain,
] {
for verb in ["create", "edit", "delete"] {
for kind in Def::ALL {
let verbs: &[&str] = if kind == Def::Partition { &["create", "delete"] } else { &["create", "edit", "delete"] };
for verb in verbs {
assert!(fieldcraft_engine::find_command(&kind.command(verb)).is_some(), "{kind:?} {verb}");
}
assert!(fieldcraft_engine::catalogue::find(kind.icon()).is_some());
+9 -6
View File
@@ -253,16 +253,19 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name
});
ui.add_space(6.0);
let (mut edit, mut delete) = (false, false);
let editing = kind.edit_command(&app.session.model, name);
ui.horizontal(|ui| {
edit = ui.button("Edit…").on_hover_text(kind.command("edit")).clicked();
delete = ui.button("Delete").on_hover_text(kind.command("delete")).clicked();
if let Some(command) = &editing {
edit = ui.button("Edit…").on_hover_text(command).clicked();
}
delete = ui.button("Delete…").on_hover_text(format!("{} (Delete key)", kind.command("delete"))).clicked();
});
if deselect {
app.selected = None;
} else if edit && let Some(v) = browser::definition(&app.session.model, kind, name) {
app.open_form(&kind.command("edit"), v);
} else if delete && app.run(&kind.command("delete"), json!({"name": name})).is_ok() {
app.selected = None;
} else if edit && let Some((command, params)) = kind.edit(&app.session.model, name) {
app.open_form(&command, params);
} else if delete {
app.ask_delete(kind, name);
}
}
+104 -1
View File
@@ -158,12 +158,26 @@ fn select(app: &mut ModelApp, p: &Value) -> Result<Value, String> {
Ok(selection_json(app))
}
/// The privacy choices, for `ui.inspect` and `ui.prompt`.
fn privacy_json(app: &ModelApp) -> Value {
let p = app.prefs();
json!({
"available": app.usage_available(),
"usage_sharing": p.usage_sharing,
"usage_automation": p.usage_automation,
"recording": fieldcraft_engine::usage::enabled(),
"queued": fieldcraft_engine::usage::queued(),
})
}
pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let mods = || egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
// A crash nothing catches (#248) still says which request came last.
fieldcraft_engine::crash::activity(&format!("control {}", req.method));
match req.method.as_str() {
"engine.execute" => {
let Some(id) = s("command") else { return err("missing `command`") };
@@ -196,16 +210,29 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
crate::files::Prompt::Unsaved(next) => json!({"unsaved": match next {
crate::files::Replace::File(p) => p.clone(),
crate::files::Replace::Sample(n) => format!("sample {n}"),
crate::files::Replace::Update => "update".to_string(),
}}),
crate::files::Prompt::Recover => json!("recover"),
crate::files::Prompt::Samples => json!("samples"),
crate::files::Prompt::Design(p) => json!({"design": p}),
crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}),
crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}),
crate::files::Prompt::Crashed(r) => json!({"crashed": r}),
crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}),
crate::files::Prompt::Consent => json!({"consent": crate::privacy::QUESTION}),
crate::files::Prompt::Settings => json!("settings"),
crate::files::Prompt::Update => json!({"update": app.update_json()}),
}),
"privacy": privacy_json(app),
"updates": app.update_json(),
"recent": app.recent.paths,
"version": crate::VERSION,
})),
"ui.toolbar" => ok(crate::toolbar::describe(app)),
"ui.settings" => {
app.open_settings();
ok(privacy_json(app))
}
"ui.select" if p.get("kind").is_none() && VIEWPORT_SELECT.iter().any(|k| p.get(*k).is_some()) => wrap(select(app, p)),
"ui.selection" => ok(selection_json(app)),
"ui.select" => {
@@ -303,6 +330,35 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
}
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
}
"ui.item" => {
// An item menu's entry, as a right click on the item's browser row and a click on the
// entry do (crate::menu).
let kind = s("kind");
let name = s("name").unwrap_or_default().to_string();
let target = match kind {
Some("body") => crate::menu::Target::Body(name),
Some("surface") => crate::menu::Target::Surface(name),
Some(k) => match serde_json::from_value::<crate::browser::Def>(json!(k)) {
Ok(def) => crate::menu::Target::Def(def, name),
Err(_) => return err(format!("unknown item kind `{}`", k.chars().take(40).collect::<String>())),
},
None => return err("missing `kind`"),
};
let action = match (s("action"), s("step")) {
(Some("edit"), _) => crate::menu::Action::Edit,
(Some("hide"), _) => crate::menu::Action::Hide,
(Some("show"), _) => crate::menu::Action::Show,
(Some("isolate"), _) => crate::menu::Action::Isolate,
(Some("show_all"), _) => crate::menu::Action::ShowAll,
(Some("delete"), _) => crate::menu::Action::Delete,
(Some(a @ ("activate" | "deactivate")), Some(step)) => {
crate::menu::Action::Active { step: step.to_string(), active: a == "activate" }
}
(a, _) => return err(format!("`action` {a:?}: edit, hide, show, isolate, show_all, delete, or activate/deactivate with `step`")),
};
crate::menu::apply(app, target, action);
ok(json!({"status": app.status, "prompt": app.prompt.is_some(), "dialog": app.dialog.as_ref().map(|d| d.command)}))
}
"ui.prompt" => {
let Some(prompt) = app.prompt.clone() else { return err("no prompt is open") };
match (prompt, s("answer")) {
@@ -328,6 +384,10 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.answer_replace_mesh(a == "replace");
ok(json!({"busy": app.busy()}))
}
(crate::files::Prompt::Delete(_), Some(a @ ("delete" | "cancel"))) => {
app.answer_delete(a == "delete");
ok(json!({"status": app.status}))
}
(crate::files::Prompt::Samples, Some("open")) => match s("name") {
Some(name) => {
app.prompt = None;
@@ -340,6 +400,48 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::Crashed(r), Some("report")) => {
app.report_bug(r.first().map(|c| c.id.clone()));
ok(json!({"prompt": "report_bug"}))
}
(crate::files::Prompt::Crashed(_), Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
// The form's fields; sending opens the browser, which only a click does.
(crate::files::Prompt::ReportBug, Some("set")) => wrap(app.set_report(p)),
(crate::files::Prompt::Consent, Some(a @ ("share" | "decline"))) => {
app.answer_consent(a == "share");
ok(privacy_json(app))
}
(crate::files::Prompt::Settings, Some("set")) if p.get("update_check").is_some() || p.get("update_skip").is_some() => {
let automatic = p.get("update_check").and_then(Value::as_bool).unwrap_or(app.prefs().update_check);
let skip = p.get("update_skip").and_then(Value::as_str).map(str::to_string);
app.set_update_prefs(automatic, skip);
ok(app.update_json())
}
(crate::files::Prompt::Update, Some(a @ ("restart" | "later"))) => {
app.answer_update(a == "restart");
ok(app.update_json())
}
(crate::files::Prompt::Settings, Some("set")) => {
if !app.usage_available() {
return err("this run cannot share usage (no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation)");
}
let sharing = p.get("usage_sharing").and_then(Value::as_bool).unwrap_or(app.prefs().usage_sharing == Some(true));
let automation = p.get("usage_automation").and_then(Value::as_bool).unwrap_or(app.prefs().usage_automation);
app.set_usage(sharing, automation);
ok(privacy_json(app))
}
(crate::files::Prompt::Settings, Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::ReportBug, Some("close" | "cancel")) => {
app.prompt = None;
app.report = None;
ok(Value::Null)
}
(crate::files::Prompt::Recover, Some("recover")) => match s("id") {
Some(id) => {
app.recover(id);
@@ -348,7 +450,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
None => err("missing `id` (from file.recovery.list)"),
},
(_, a) => err(format!(
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; recover: recover with id, close; samples: open with name, close)"
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close; consent: share, decline; settings: set with usage_sharing/usage_automation or update_check/update_skip, close; update: restart, later)"
)),
}
}
@@ -853,6 +955,7 @@ mod tests {
"ui.capture",
"ui.toolbar",
"ui.prompt",
"ui.item",
"ui.select",
"ui.selection",
"ui.fullscreen",
+134 -52
View File
@@ -33,6 +33,8 @@ pub struct Filter {
pub type PickOpen = Box<dyn Fn(&[Filter]) -> Option<String>>;
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
/// The host opens a web link in the user's browser.
pub type OpenUrl = Box<dyn Fn(&str) -> Result<(), String>>;
/// What the host provides: native file pickers and where the recent-files list is kept. The
/// desktop app installs them; tests answer the pickers with closures.
@@ -42,8 +44,12 @@ pub struct Services {
pub pick_save: Option<PickSave>,
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
pub recent_path: Option<PathBuf>,
/// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only.
pub prefs_path: Option<PathBuf>,
/// Where the user's preferences ([`Prefs`]) are kept: a file on the desktop, the browser's
/// storage on the web; `None` keeps them for this run only.
pub prefs: Option<PrefsStore>,
/// Opens a link in the browser (Help › Report a Bug); without it, egui's own
/// `open_url` (the browser build opens the link itself).
pub open_url: Option<OpenUrl>,
}
const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] };
@@ -169,6 +175,30 @@ impl Recent {
/// Largest preferences file read.
const MAX_PREFS_BYTES: u64 = 64 * 1024;
/// Where the preferences are kept: reads their JSON (`None` when there is none) and writes it.
pub struct PrefsStore {
pub load: Box<dyn Fn() -> Option<Vec<u8>>>,
pub save: Box<dyn Fn(&[u8]) -> Result<(), String>>,
}
impl PrefsStore {
/// The preferences in `file` (an unreadable or oversized file reads as none).
pub fn file(file: PathBuf) -> PrefsStore {
let read = file.clone();
PrefsStore {
load: Box::new(move || {
std::fs::metadata(&read).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES).then(|| std::fs::read(&read).ok()).flatten()
}),
save: Box::new(move |bytes| {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display()))
}),
}
}
}
/// What the window remembers for the user between runs (`prefs.json` beside `recent.json`).
/// Keys it no longer knows (`show_setup`, from before the setup card went) are ignored.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -178,34 +208,46 @@ pub struct Prefs {
pub nav_preset: String,
/// The wheel zooms the other way.
pub reverse_zoom: bool,
/// Share anonymous usage (docs/privacy.md); `None` until the user is asked.
pub usage_sharing: Option<bool>,
/// Count commands run by scripts and AI agents (MCP) too.
pub usage_automation: bool,
/// The random id usage is sent under, made when the user first agrees; empty before.
pub install_id: String,
/// Check for updates at start-up, at most once a day ([`crate::updates`]).
pub update_check: bool,
/// When the last automatic check was made (seconds since the Unix epoch; 0: never).
pub update_checked_at: u64,
/// The version the user skipped (the automatic check does not offer it); empty for none.
pub update_skip: String,
}
impl Default for Prefs {
fn default() -> Self {
Prefs { nav_preset: crate::navigation::DEFAULT_PRESET.into(), reverse_zoom: false }
Prefs {
nav_preset: crate::navigation::DEFAULT_PRESET.into(),
reverse_zoom: false,
usage_sharing: None,
usage_automation: true,
install_id: String::new(),
update_check: true,
update_checked_at: 0,
update_skip: String::new(),
}
}
}
impl Prefs {
/// The preferences kept in `file` (an unreadable or malformed file gives the defaults).
pub fn load(file: Option<&Path>) -> Prefs {
file.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES))
.and_then(|f| std::fs::read(f).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
/// The preferences kept in `store` (unreadable or malformed ones give the defaults).
pub fn load(store: Option<&PrefsStore>) -> Prefs {
store.and_then(|s| (s.load)()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
/// Write them to `file`; a failure is logged (the preferences stay for this run).
pub fn save(&self, file: Option<&Path>) {
let Some(file) = file else { return };
let write = || -> std::io::Result<()> {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(file, serde_json::to_vec_pretty(self).unwrap_or_default())
};
if let Err(e) = write() {
log::warn!("preferences {}: {e}", file.display());
/// Write them to `store`; a failure is logged (the preferences stay for this run).
pub fn save(&self, store: Option<&PrefsStore>) {
let Some(store) = store else { return };
if let Err(e) = (store.save)(&serde_json::to_vec_pretty(self).unwrap_or_default()) {
log::warn!("preferences: {e}");
}
}
}
@@ -246,6 +288,8 @@ pub enum Replace {
File(String),
/// A sample (`file.sample`).
Sample(&'static str),
/// Restarting to finish installing an update ([`crate::updates`]).
Update,
}
impl Replace {
@@ -253,6 +297,7 @@ impl Replace {
match self {
Replace::File(p) => file_name(p),
Replace::Sample(n) => fieldcraft_engine::sample::find(n).map_or_else(|| (*n).to_string(), |s| format!("the {} sample", s.label)),
Replace::Update => "the update".into(),
}
}
}
@@ -272,6 +317,19 @@ pub enum Prompt {
/// A design (its path) has none of the bodies the model's mesh and sets are on: start a new
/// model with it, or keep the model. The opened session waits in [`ModelApp::held`].
Design(String),
/// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and
/// the deletion refused or (a datum's partitions) done with it.
Delete(crate::menu::DeleteAsk),
/// The first run asks once whether to share anonymous usage ([`crate::privacy`]).
Consent,
/// Help › Settings: the privacy and update choices ([`crate::privacy`]).
Settings,
/// An update is downloaded and verified: restart to finish it, or later ([`crate::updates`]).
Update,
/// The last run crashed (its new crash reports, newest first): report it, or not now.
Crashed(Vec<fieldcraft_engine::crash::Summary>),
/// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]).
ReportBug,
}
/// The answer to [`Prompt::Design`].
@@ -373,7 +431,15 @@ impl ModelApp {
if self.ensure_idle().is_err() {
return false;
}
let base = if fresh { Session::default() } else { self.session.clone() };
// A fresh session is the app's own model too (its commands go into the crash journal).
let base = if fresh {
let mut s = Session::default();
s.crash_journal = self.session.crash_journal;
s.origin = self.session.origin;
s
} else {
self.session.clone()
};
let (rx, pending) = spawn(base, command, params);
self.set_status(format!("{title}…"), false);
self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false });
@@ -433,7 +499,7 @@ impl ModelApp {
}
/// Whether replacing the model would lose work: unsaved changes made by commands.
fn has_unsaved_work(&self) -> bool {
pub(crate) fn has_unsaved_work(&self) -> bool {
self.session.is_dirty() && !self.session.journal.is_empty()
}
@@ -572,7 +638,11 @@ impl ModelApp {
/// A finished job's session replaces the window's.
fn take_opened(&mut self, o: Opened) {
// The session it replaces may be large: it is freed off the window thread.
let old = std::mem::replace(&mut self.session, *o.session);
let mut session = *o.session;
// Its commands come from where the window's do now (the job may have started from the
// command search or the control channel).
session.origin = self.session.origin;
let old = std::mem::replace(&mut self.session, session);
off_thread("fieldcraft drop".into(), move || drop(old));
self.model_changed();
if let Some(p) = &o.recent {
@@ -709,6 +779,7 @@ impl ModelApp {
}
}
Replace::Sample(name) => self.load_sample(name),
Replace::Update => self.close_to_update(),
}
}
@@ -727,6 +798,7 @@ impl ModelApp {
}
"file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover),
"file.sample" => self.prompt = Some(Prompt::Samples),
"diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None),
"solve.run" => self.solve_all(),
"deck.write" if can_save => match preset.get("format").and_then(Value::as_str) {
Some(f) => self.write_deck(f),
@@ -822,7 +894,10 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
Some(Prompt::Unsaved(next)) => {
let mut answer = None;
crate::frame::window(ctx, "Unsaved changes", crate::frame::Width::Normal).id(egui::Id::new("fc_unsaved")).show(ctx, |ui| {
ui.label(format!("Save the changes to {} before opening {}?", app.model_name(), next.name()));
ui.label(match next {
Replace::Update => format!("Save the changes to {} before restarting to update?", app.model_name()),
_ => format!("Save the changes to {} before opening {}?", app.model_name(), next.name()),
});
ui.label(egui::RichText::new("Without saving, they are lost.").color(t.text_dim));
ui.add_space(6.0);
ui.horizontal(|ui| {
@@ -871,6 +946,32 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.answer_design(a);
}
}
Some(Prompt::Delete(ask)) => {
let mut answer = None;
crate::frame::window(ctx, "Delete", crate::frame::Width::Normal).id(egui::Id::new("fc_delete")).show(ctx, |ui| {
ui.label(ask.question());
if ask.allowed() && !ask.users.is_empty() {
ui.label(egui::RichText::new("They are deleted too; Undo brings everything back.").color(t.text_dim));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if ask.allowed() && ui.button("Delete").clicked() {
answer = Some(true);
}
if ui.button(if ask.allowed() { "Cancel" } else { "Close" }).clicked() {
answer = Some(false);
}
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(false);
} else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
answer = Some(ask.allowed());
}
if let Some(delete) = answer {
app.answer_delete(delete);
}
}
Some(Prompt::ReplaceMesh(r)) => {
let mut answer = None;
crate::frame::window(ctx, "Replace mesh", crate::frame::Width::Normal).id(egui::Id::new("fc_replace_mesh")).show(ctx, |ui| {
@@ -947,8 +1048,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
Some(Prompt::Samples) => {
let (mut open, mut close) = (None, false);
crate::frame::window(ctx, "Samples", crate::frame::Width::Normal).id(egui::Id::new("fc_samples")).show(ctx, |ui| {
sample_rows(ui, &t, &mut open);
crate::frame::window(ctx, "Samples", crate::frame::Width::Wide).id(egui::Id::new("fc_samples")).show(ctx, |ui| {
ui.set_width(crate::frame::WINDOW_WIDE_MAX_W.min(ctx.content_rect().width() - 48.0));
crate::samples::grid(ui, &t, (ctx.content_rect().height() * 0.6).max(160.0), &mut open);
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
@@ -959,29 +1061,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
}
}
None => {}
}
}
/// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked.
pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) {
let width = ui.available_width().clamp(280.0, 420.0);
egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| sample_list(ui, t, open, width));
}
fn sample_list(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>, width: f32) {
for sample in fieldcraft_engine::sample::SAMPLES {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(width - 70.0);
ui.label(egui::RichText::new(sample.label).strong());
ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap());
});
if ui.button("Open").clicked() {
*open = Some(sample.name);
}
});
ui.add_space(4.0);
// Drawn by `crate::report::show` and `crate::privacy::show`.
Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings | Prompt::Update) | None => {}
}
}
@@ -1078,8 +1159,9 @@ mod tests {
fn navigation_preferences_are_remembered() {
let file = temp("prefs").join("prefs.json");
std::fs::write(&file, br#"{"show_setup": false}"#).unwrap();
assert_eq!(Prefs::load(Some(&file)), Prefs::default());
let services = || Services { prefs_path: Some(file.clone()), ..Default::default() };
let store = PrefsStore::file(file.clone());
assert_eq!(Prefs::load(Some(&store)), Prefs::default());
let services = || Services { prefs: Some(PrefsStore::file(file.clone())), ..Default::default() };
let mut app = ModelApp::new(Session::default()).with_services(services());
app.ui.nav_preset = "solidworks".into();
app.ui.reverse_zoom = true;
@@ -1087,7 +1169,7 @@ mod tests {
let again = ModelApp::new(Session::default()).with_services(services());
assert_eq!((again.ui.nav_preset.as_str(), again.ui.reverse_zoom), ("solidworks", true));
std::fs::write(&file, b"{nope").unwrap();
assert_eq!(Prefs::load(Some(&file)), Prefs::default());
assert_eq!(Prefs::load(Some(&store)), Prefs::default());
}
/// A picker that answers `path` and records the filters it was offered.
+178 -19
View File
@@ -36,17 +36,22 @@ pub mod frame;
pub mod framing;
pub mod gpu;
pub mod icons;
pub mod menu;
pub mod navigation;
pub mod palette;
pub mod privacy;
pub mod reply;
pub mod report;
pub mod results;
pub mod results_panel;
pub mod samples;
pub mod scene;
pub mod selection;
pub mod theme;
pub mod timeline;
pub mod titlebar;
pub mod toolbar;
pub mod updates;
pub mod viewport;
use std::hash::{Hash, Hasher};
@@ -276,6 +281,8 @@ pub struct Viewport {
pub drag: Option<NavMode>,
/// The point the current orbit turns about (drawn as a marker while orbiting).
pub orbit_pivot: Option<Vec3>,
/// The item the open context menu was opened on (a datum or a partition), if any.
pub menu_target: Option<menu::Target>,
}
/// A probed node: its values as lines of text, for the step, mode and frame they were read from.
@@ -378,7 +385,14 @@ pub struct ModelApp {
pub recent: files::Recent,
/// The user's preferences as last saved.
prefs: files::Prefs,
/// The user's preferences as last saved.
/// The host's go-ahead to share usage, when this run may ([`privacy`]).
usage: Option<privacy::UsageHost>,
/// The consent question was asked in this run.
consent_asked: bool,
/// Checking for and installing updates, when this run may ([`updates`]).
updates: Option<updates::State>,
/// The web app: a new deployment is out, and this reloads the page into it.
reload: Option<Box<dyn Fn()>>,
/// The setup progress the browser marks, and what it was worked out for.
setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc<Vec<fieldcraft_engine::progress::StageState>>),
/// The file opening in the background, and files waiting to open after it.
@@ -386,6 +400,10 @@ pub struct ModelApp {
pub queued: std::collections::VecDeque<String>,
/// The question the window is asking (unsaved changes, recover).
pub prompt: Option<files::Prompt>,
/// Help › Report a Bug's form, while open ([`files::Prompt::ReportBug`]).
pub report: Option<report::ReportForm>,
/// The last error the status bar showed (what Report a Bug starts from).
pub last_error: Option<String>,
/// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer.
pub held: Option<files::Opened>,
/// The window draws its own title bar and caption buttons (Windows, Linux).
@@ -421,7 +439,9 @@ pub struct ModelApp {
}
impl ModelApp {
pub fn new(session: Session) -> ModelApp {
pub fn new(mut session: Session) -> ModelApp {
// The window's own model: its commands count as clicks unless said otherwise.
session.origin.get_or_insert(fieldcraft_engine::usage::Origin::Ui);
ModelApp {
session,
ui: UiState::default(),
@@ -460,10 +480,16 @@ impl ModelApp {
services: files::Services::default(),
recent: files::Recent::default(),
prefs: files::Prefs::default(),
usage: None,
consent_asked: false,
updates: None,
reload: None,
setup: (None, std::sync::Arc::default()),
loading: None,
queued: std::collections::VecDeque::new(),
prompt: None,
report: None,
last_error: None,
held: None,
custom_titlebar: false,
integrated_titlebar: false,
@@ -492,7 +518,7 @@ impl ModelApp {
/// Use the host's services (file pickers, the recent-files file) and load the recent list.
pub fn with_services(mut self, services: files::Services) -> Self {
self.recent = files::Recent::load(services.recent_path.clone());
self.prefs = files::Prefs::load(services.prefs_path.as_deref());
self.prefs = files::Prefs::load(services.prefs.as_ref());
self.ui.nav_preset = self.prefs.nav_preset.clone();
self.ui.reverse_zoom = self.prefs.reverse_zoom;
self.services = services;
@@ -518,7 +544,7 @@ impl ModelApp {
/// label (the engine's message names its key, which agents need).
fn run_form(&mut self, id: &str, params: Value, form: bool) -> Result<Value, Failure> {
self.ensure_idle().map_err(|message| Failure { message, refused: None })?;
let revision = self.session.revision();
let (revision, view) = (self.session.revision(), self.session.view.clone());
let label = command_label(id);
let r = self.session.execute(id, &params).map_err(|e| match e {
EngineError::Param(p) => {
@@ -529,7 +555,8 @@ impl ModelApp {
e => Failure { message: e.to_string(), refused: None },
});
let changed = self.session.revision() != revision;
if changed {
// A view change (hide, show) draws the model again, as an edit does.
if changed || self.session.view != view {
self.model_changed();
}
// The results display commands reply with what is shown, which the viewport shows.
@@ -612,6 +639,10 @@ impl ModelApp {
let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?;
// A command missing what it needs opens no form: the status names what is missing.
if let Err(u) = self.session.availability(spec) {
self.record_ui(fieldcraft_engine::usage::Event::UnavailableClicked {
command: u.command,
missing: u.missing.iter().map(|m| m.0).collect(),
});
let e = u.to_string();
self.set_status(e.clone(), true);
return Err(e);
@@ -677,6 +708,9 @@ impl ModelApp {
/// Close the open form: back to the form it was opened from, if any.
pub fn close_dialog(&mut self) {
if let Some(d) = &self.dialog {
self.record_ui(fieldcraft_engine::usage::Event::FormCancelled { command: d.command });
}
self.dialog = self.dialog.take().and_then(|d| d.back).map(|b| b.dialog);
}
@@ -778,6 +812,7 @@ impl ModelApp {
let text = text.into();
if error {
log::warn!("{text}");
self.last_error = Some(text.clone());
}
self.status = Some(Status { text, error });
}
@@ -835,13 +870,14 @@ impl ModelApp {
}
}
self.pick.names.clear();
for (names, mesh) in &self.bodies.1 {
// Hidden bodies and surfaces are neither drawn nor picked.
for (names, mesh) in self.bodies.1.iter().filter(|(n, _)| self.session.shows(fieldcraft_engine::ItemKind::Body, &n.name)) {
d.add_curves(&mesh.edges);
d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face);
self.pick.names.push(names.clone());
}
// An imported surface's faces are named by their tags.
for s in &self.session.surfaces {
for s in self.session.surfaces.iter().filter(|s| self.session.shows(fieldcraft_engine::ItemKind::Surface, &s.name)) {
d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face);
self.pick.names.push(selection::BodyNames {
name: s.name.clone(),
@@ -851,7 +887,7 @@ impl ModelApp {
}
let was_empty = self.display.1.bounds.is_empty();
self.display = (Some(self.revision), d);
self.glyphs = Glyphs::from_model(&self.session.model);
self.glyphs = self.session.glyphs();
if was_empty && !self.display.1.bounds.is_empty() {
self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Fit);
}
@@ -869,7 +905,7 @@ impl ModelApp {
pub fn scene_key(&self) -> u64 {
let mut h = std::hash::DefaultHasher::new();
let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize);
(self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot).hash(&mut h);
(self.revision, self.ui.style, self.ui.show(), self.ui.dark, plot, self.selected_datum()).hash(&mut h);
h.finish()
}
@@ -915,19 +951,28 @@ impl ModelApp {
};
let mut datums = Aabb3::EMPTY;
if show.geometry && self.plot.1.is_none() {
datums = scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t);
datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, self.selected_datum(), &t);
scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
}
self.scene = (Some(key), s, datums);
}
// The clipping radius holds the symbols too (framing leaves them out: their size in the
// model follows the zoom).
// The clipping radius holds the symbols and the datums too (framing leaves them out: the
// symbols' size in the model follows the zoom, the datums are drawn at the model).
let mut bounds = self.drawn_bounds();
bounds.union(self.symbols.2.bounds);
bounds.union(self.scene.2);
self.scene.1.radius = scene::radius(&bounds, &self.cam);
(key, &self.scene.1)
}
/// The datum selected in the browser: drawn highlighted, shown or hidden.
fn selected_datum(&self) -> Option<&str> {
match &self.selected {
Some((browser::Def::Datum, name)) => Some(name.as_str()),
_ => None,
}
}
/// The definition selected in the browser, when it has symbols.
fn highlighted(&self) -> Option<(Definition, &str)> {
let (def, name) = self.selected.as_ref()?;
@@ -967,13 +1012,13 @@ impl ModelApp {
}
/// What the scene built last draws, as framing sees it: the model, a results plot's
/// (deformed) shape, the datums and the reference points.
/// (deformed) shape and the reference points. Datums are never framed (#295): they are drawn
/// at the model's extent, so framing them would only shrink the part.
fn drawn_bounds(&self) -> Aabb3 {
let mut bounds = self.display.1.bounds;
if let Some(p) = &self.plot.1 {
bounds.union(p.display.bounds);
}
bounds.union(self.scene.2);
// Reference points are places in the model, framed with it (their symbols are not).
for p in self.session.model.reference_points().values() {
let p = Vec3::from(p.xyz);
@@ -1312,6 +1357,7 @@ impl ModelApp {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
self.dropped_files(ctx);
self.poll_updates(ctx);
self.shortcuts(ctx);
self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 };
if !self.synthetic.is_empty() {
@@ -1337,8 +1383,8 @@ impl ModelApp {
}
/// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save
/// (Ctrl/Cmd+S) and save as (Ctrl/Cmd+Shift+S), unless a text field has the keyboard (it
/// undoes its own typing).
/// (Ctrl/Cmd+S), save as (Ctrl/Cmd+Shift+S) and delete the browser's selection (Delete),
/// unless a text field has the keyboard (it undoes its own typing).
fn shortcuts(&mut self, ctx: &egui::Context) {
if ctx.memory(|m| m.focused().is_some()) {
return;
@@ -1374,6 +1420,14 @@ impl ModelApp {
if open {
self.open_dialog(None);
}
// Delete (or Backspace) deletes the definition selected in the browser, after asking.
if self.prompt.is_none()
&& let Some((kind, name)) = self.selected.clone()
&& ctx
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
{
self.ask_delete(kind, &name);
}
// The command search: S (as in SolidCraft) or Cmd/Ctrl+K.
let search = ctx.input_mut(|i| {
i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::K)) || i.consume_key(egui::Modifiers::NONE, egui::Key::S)
@@ -1428,6 +1482,10 @@ impl ModelApp {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui));
palette::popup(self, ui.ctx());
files::show(self, ui.ctx());
report::show(self, ui.ctx());
self.ask_consent();
privacy::show(self, ui.ctx());
updates::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
@@ -1448,8 +1506,9 @@ impl ModelApp {
/// Save the preferences the window changed (a View menu toggle, `ui.set`).
pub(crate) fn save_prefs(&mut self) {
if self.ui.nav_preset != self.prefs.nav_preset || self.ui.reverse_zoom != self.prefs.reverse_zoom {
self.prefs = files::Prefs { nav_preset: self.ui.nav_preset.clone(), reverse_zoom: self.ui.reverse_zoom };
self.prefs.save(self.services.prefs_path.as_deref());
self.prefs.nav_preset = self.ui.nav_preset.clone();
self.prefs.reverse_zoom = self.ui.reverse_zoom;
self.prefs.save(self.services.prefs.as_ref());
}
}
@@ -1470,7 +1529,12 @@ impl ModelApp {
let Some(rx) = self.control_rx.take() else { return };
for _ in 0..PER_FRAME {
let Ok(req) = rx.try_recv() else { break };
match control::handle(self, ctx, &req) {
// The MCP server says so; anything else on the channel is a script.
let origin = match req.params.get("client").and_then(Value::as_str) {
Some("mcp") => fieldcraft_engine::usage::Origin::Mcp,
_ => fieldcraft_engine::usage::Origin::Script,
};
match self.with_origin(origin, |app| control::handle(app, ctx, &req)) {
control::Outcome::Done(v) => {
let _ = req.reply.send(v);
}
@@ -1949,6 +2013,101 @@ mod tests {
(cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9
}
/// Every registered sample is on the Start card and opens with a click, at 1280 × 800 and in a
/// small window where the grid scrolls (#245). Unsaved work (a material, no geometry) keeps the
/// Start card up and turns the click into the question naming that sample, so nothing loads.
#[test]
fn every_sample_is_reachable_from_the_start_card() {
for (w, h) in [(1280.0, 800.0), (900.0, 560.0)] {
let mut scrolled = false;
for sample in fieldcraft_engine::sample::SAMPLES {
let mut app = ModelApp::new(Session::default());
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}))
.unwrap();
let ctx = egui::Context::default();
let mut time = 0.0;
let mut frame = |app: &mut ModelApp, events: Vec<egui::Event>| {
time += 0.1;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| app.ui(ui));
out.textures_delta.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
let card = |ctx: &egui::Context| ctx.read_response(samples::card_id(sample.name)).map(|r| (r.rect, r.interact_rect));
// Wheel down over the grid until the card is in view: wholly, or (when the grid is
// shorter than a card) most of it.
let in_view = |c: Option<(egui::Rect, egui::Rect)>| {
c.is_some_and(|(r, i)| i.is_positive() && i.height() >= (r.height() * 0.6).min(r.height() - 0.5))
};
// Over the first card, which is in view before any scrolling.
let first = ctx
.read_response(samples::card_id(samples::ordered()[0].name))
.map_or(egui::pos2(w / 2.0, h / 2.0), |r| r.interact_rect.center());
for _ in 0..40 {
if in_view(card(&ctx)) {
break;
}
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
};
frame(&mut app, vec![egui::Event::PointerMoved(first), wheel]);
frame(&mut app, vec![]);
scrolled = true;
}
assert!(in_view(card(&ctx)), "{w}×{h}: {} is never in view: {:?}", sample.name, card(&ctx));
let at = card(&ctx).map_or(egui::Pos2::ZERO, |(_, i)| i.center());
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, vec![button(true)]);
frame(&mut app, vec![button(false)]);
assert_eq!(app.prompt, Some(files::Prompt::Unsaved(files::Replace::Sample(sample.name))), "{w}×{h}: clicking {}", sample.name);
}
// The small window cannot show them all at once.
if w < 1000.0 {
assert!(scrolled, "{w}×{h}");
}
}
}
/// Fit and home never frame datums (#295); a hidden datum selected in the browser is drawn,
/// highlighted, until the selection moves on.
#[test]
fn datums_are_not_framed_and_a_selected_one_shows() {
let mut app = ModelApp::new(Session::default());
let ctx = egui::Context::default();
app.run("file.sample", json!({"name": "cantilever"})).unwrap();
draw(&mut app, &ctx, 1280.0, 800.0);
app.home_view();
let home = app.cam;
app.run("geometry.datum", json!({"name": "Far", "by": {"type": "offset", "plane": "xy", "offset": 5000}})).unwrap();
draw(&mut app, &ctx, 1280.0, 800.0);
app.home_view();
// What framing sees is the model alone; the view stays as close as before (the overlays
// may differ a little after a command).
assert_eq!(app.drawn_bounds(), app.display.1.bounds, "a datum far off is not framed");
assert!(app.cam.distance < home.distance * 1.2, "{:?} vs {:?}", app.cam, home);
app.fit_view();
assert!(app.cam.distance < home.distance * 1.2);
let highlight = theme::Tokens::get(app.ui.dark).glyph_colors().highlight;
let lit = |app: &mut ModelApp| app.scene().1.on_top.iter().filter(|l| l.color == highlight).count();
app.run("view.hide", json!({"items": [{"kind": "datum", "name": "Far"}]})).unwrap();
assert_eq!(lit(&mut app), 0);
app.selected = Some((browser::Def::Datum, "Far".into()));
assert!(lit(&mut app) > 0, "the selected datum is drawn though hidden");
app.selected = None;
assert_eq!(lit(&mut app), 0);
}
/// Opening a sample, then another, frames each in the home view; an edit keeps the camera
/// (#233, #223).
#[test]
+319
View File
@@ -0,0 +1,319 @@
//! The item menu: what a right click on a browser row offers, and on a datum or a partition's cut
//! face in the viewport. Edit… opens the item's edit form, Hide/Show and Isolate change the view
//! (`view.*`), Active in step switches a boundary condition or load on or off per step
//! (`model.step.activate`), and Delete… asks first ([`crate::files::Prompt::Delete`]): it lists
//! the definitions that use the item, and refuses, or for a datum deletes its partitions with it.
//! Every entry runs the same command as the ribbon, the forms and scripts.
use fieldcraft_engine::{ItemKind, ViewItem};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::browser::Def;
/// What the menu is for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
/// A body of the open design.
Body(String),
/// An imported surface.
Surface(String),
/// A model definition, datum or partition.
Def(Def, String),
}
impl Target {
/// The viewport item it is, when the viewport draws it on its own.
pub fn item(&self) -> Option<ViewItem> {
let (kind, name) = match self {
Target::Body(n) => (ItemKind::Body, n),
Target::Surface(n) => (ItemKind::Surface, n),
Target::Def(def, n) => (def.item_kind()?, n),
};
Some(ViewItem { kind, name: name.clone() })
}
}
/// A menu entry chosen.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Edit,
Hide,
Show,
Isolate,
ShowAll,
/// Switch a boundary condition or load on or off in a step.
Active {
step: String,
active: bool,
},
Delete,
}
/// Whether `target` is active in each step (boundary conditions and loads; empty for the rest).
fn activity(app: &ModelApp, target: &Target) -> Vec<(String, bool)> {
let steps = app.session.model.steps();
match target {
Target::Def(Def::BoundaryCondition, n) => steps.iter().map(|s| (s.name.clone(), s.boundary_conditions.contains(n))).collect(),
Target::Def(Def::Load, n) => steps.iter().map(|s| (s.name.clone(), s.loads.contains(n))).collect(),
_ => Vec::new(),
}
}
/// Draw the menu's entries for `target`; the entry clicked.
pub fn show(app: &ModelApp, ui: &mut egui::Ui, target: &Target) -> Option<Action> {
let mut chosen = None;
let mut entry = |ui: &mut egui::Ui, enabled: bool, label: &str, tip: &str, action: Action| {
if ui.add_enabled(enabled, egui::Button::new(label)).on_hover_text(tip).clicked() {
chosen = Some(action);
}
};
if let Target::Def(def, name) = target {
let edit = def.edit_command(&app.session.model, name);
let tip = edit.clone().unwrap_or_else(|| "Not editable: delete it and make it again".to_string());
entry(ui, edit.is_some(), "Edit…", &tip, Action::Edit);
}
if let Some(item) = target.item() {
if app.session.shows(item.kind, &item.name) {
entry(ui, true, "Hide", "view.hide", Action::Hide);
} else {
entry(ui, true, "Show", "view.show", Action::Show);
}
entry(ui, true, "Isolate", "view.isolate: show only this", Action::Isolate);
}
if !app.session.view.is_empty() {
entry(ui, true, "Show All", "view.show_all", Action::ShowAll);
}
let steps = activity(app, target);
if !steps.is_empty() {
ui.menu_button("Active in step", |ui| {
for (step, on) in steps {
let mut checked = on;
if ui.checkbox(&mut checked, &step).on_hover_text("model.step.activate").changed() {
chosen = Some(Action::Active { step, active: checked });
ui.close();
}
}
});
}
if let Target::Def(def, _) = target {
ui.separator();
let command = def.command("delete");
if ui.button("Delete…").on_hover_text(format!("{command} (Delete key)")).clicked() {
chosen = Some(Action::Delete);
}
}
if chosen.is_some() {
ui.close();
}
chosen
}
/// Do what the menu entry chosen asks.
pub fn apply(app: &mut ModelApp, target: Target, action: Action) {
let items = |t: &Target| json!({"items": t.item().into_iter().collect::<Vec<_>>()});
// Errors show in the status bar.
let _ = match action {
Action::Edit => {
if let Target::Def(def, name) = target
&& let Some((command, params)) = def.edit(&app.session.model, &name)
{
app.selected = Some((def, name));
app.open_form(&command, params);
}
Ok(Value::Null)
}
Action::Hide => app.run("view.hide", items(&target)),
Action::Show => app.run("view.show", items(&target)),
Action::Isolate => app.run("view.isolate", items(&target)),
Action::ShowAll => app.run("view.show_all", json!({})),
Action::Active { step, active } => match target {
Target::Def(def @ (Def::BoundaryCondition | Def::Load), name) => {
let key = if def == Def::Load { "loads" } else { "boundary_conditions" };
app.run("model.step.activate", json!({"step": step, key: [name], "active": active}))
}
_ => Ok(Value::Null),
},
Action::Delete => {
if let Target::Def(def, name) = target {
app.ask_delete(def, &name);
}
Ok(Value::Null)
}
};
}
/// The question a deletion asks ([`crate::files::Prompt::Delete`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeleteAsk {
pub kind: Def,
pub name: String,
/// The definitions that use it.
pub users: Vec<String>,
}
impl DeleteAsk {
/// Whether deleting goes ahead with its users: a datum's partitions are deleted with it; any
/// other definition in use is refused.
pub fn cascades(&self) -> bool {
self.kind == Def::Datum
}
/// Whether it can be deleted at all.
pub fn allowed(&self) -> bool {
self.users.is_empty() || self.cascades()
}
/// The question, in words.
pub fn question(&self) -> String {
let what = format!("{} `{}`", self.kind.label().to_lowercase(), self.name);
match (self.users.is_empty(), self.cascades()) {
(true, _) => format!("Delete {what}?"),
(false, true) => format!("Delete {what} and what uses it: {}?", self.users.join(", ")),
(false, false) => format!("{what} is used by {}: change or delete those first.", self.users.join(", ")),
}
}
}
impl ModelApp {
/// Ask before deleting definition `name` of `kind` (the item menu's Delete…, the Delete key).
pub fn ask_delete(&mut self, kind: Def, name: &str) {
if !kind.exists(&self.session.model, name) {
return;
}
let users = kind.users(&self.session.model, name);
self.prompt = Some(crate::files::Prompt::Delete(DeleteAsk { kind, name: name.to_string(), users }));
}
/// Answer [`crate::files::Prompt::Delete`]: delete (with its partitions, for a datum in use)
/// or leave it.
pub fn answer_delete(&mut self, delete: bool) {
let Some(crate::files::Prompt::Delete(ask)) = self.prompt.clone() else { return };
self.prompt = None;
if !delete || !ask.allowed() {
return;
}
let mut params = json!({"name": ask.name});
if ask.cascades() && !ask.users.is_empty() {
params["cascade"] = json!(true);
}
if self.run(&ask.kind.command("delete"), params).is_ok() && self.selected.as_ref() == Some(&(ask.kind, ask.name)) {
self.selected = None;
}
}
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use fieldcraft_render::StandardView;
use super::*;
use crate::viewport;
/// The plate design open in an app with a datum plane `Mid` cutting it (partition `Half`)
/// and a datum point `P`, framed in an 800 × 600 viewport.
fn plate() -> (ModelApp, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-menu-{}-{:?}", std::process::id(), std::thread::current().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());
app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap();
app.run("geometry.datum", json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
app.run("geometry.partition", json!({"name": "Half", "datum": "Mid"})).unwrap();
// The partition hides its plane (#295); shown again here to be picked.
app.run("view.show", json!({"items": [{"kind": "datum", "name": "Mid"}]})).unwrap();
app.run("geometry.datum.point", json!({"name": "P", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0)));
app.set_view(StandardView::Iso);
app.fit_view();
(app, dir)
}
/// A screen point on datum `name`'s drawn lines.
fn on_datum(app: &mut ModelApp, name: &str) -> egui::Pos2 {
let bounds = app.display().bounds;
let mut s = fieldcraft_render::Scene::default();
fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, None, &crate::theme::Tokens::dark().glyph_colors(), &mut s);
let l = s.lines[0];
viewport::to_screen(app, (l.a + l.b) * 0.5).unwrap()
}
/// The menu's entries run the commands the ribbon, forms and scripts run; hiding is view
/// state (nothing journalled) and a hidden item is not picked.
#[test]
fn menu_entries_run_the_same_commands_and_hidden_items_are_not_picked() {
let (mut app, dir) = plate();
let mid = Target::Def(Def::Datum, "Mid".into());
let at = on_datum(&mut app, "Mid");
assert_eq!(viewport::menu_target(&mut app, at), Some(mid.clone()));
let journal = app.session.journal.len();
apply(&mut app, mid.clone(), Action::Hide);
assert!(!app.session.shows(ItemKind::Datum, "Mid") && app.session.is_dirty());
assert_eq!(app.session.journal.len(), journal);
assert_ne!(viewport::menu_target(&mut app, at), Some(mid.clone()), "a hidden datum is not picked");
apply(&mut app, mid.clone(), Action::Show);
assert!(app.session.shows(ItemKind::Datum, "Mid"));
// A hidden body is neither drawn nor picked.
let cell = app.session.design.as_ref().unwrap().bodies[0].name.clone();
let centre = app.session.design.as_ref().unwrap().bodies[0].measure().unwrap().centroid;
let top = viewport::to_screen(&app, fieldcraft_render::Vec3::new(centre[0], centre[1], 10.0)).unwrap();
assert!(app.pick_at(top).is_some());
apply(&mut app, Target::Body(cell.clone()), Action::Isolate);
assert!(app.session.shows(ItemKind::Body, &cell) && !app.session.shows(ItemKind::Datum, "P"));
apply(&mut app, Target::Body(cell.clone()), Action::Hide);
app.display();
assert!(app.pick.names.iter().all(|n| n.name != cell));
assert!(app.pick_at(top).is_none_or(|i| !i.describe().contains(cell.as_str())));
apply(&mut app, Target::Body(cell), Action::ShowAll);
assert!(app.session.view.is_empty());
// Edit… opens the datum's edit form filled in; running it is the edit command.
apply(&mut app, Target::Def(Def::Datum, "P".into()), Action::Edit);
let d = app.dialog.as_mut().unwrap();
assert_eq!(d.command, "geometry.datum.point.edit");
assert!(d.text.contains("[0.0,0.0,0.0]"), "{}", d.text);
d.text = json!({"name": "P", "by": {"type": "coordinates", "point": [1, 0, 0]}}).to_string();
app.run_dialog().unwrap();
assert_eq!(app.session.journal.last().map(|(c, _)| c.as_str()), Some("geometry.datum.point.edit"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Delete asks first: a datum a partition uses names it and deletes both on confirmation;
/// a definition in use elsewhere is refused.
#[test]
fn deleting_lists_what_uses_it_and_cascades_or_refuses() {
let (mut app, dir) = plate();
apply(&mut app, Target::Def(Def::Datum, "Mid".into()), Action::Delete);
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!("{:?}", app.prompt) };
assert_eq!(ask.users, ["partition `Half`"]);
assert!(ask.allowed() && ask.question().contains("partition `Half`"));
// Cancel changes nothing; Delete deletes the datum and its partition in one step.
app.answer_delete(false);
assert!(app.prompt.is_none() && app.session.model.datum("Mid").is_some());
app.selected = Some((Def::Datum, "Mid".into()));
app.ask_delete(Def::Datum, "Mid");
app.answer_delete(true);
assert_eq!(app.session.journal.last(), Some(&("geometry.datum.delete".to_string(), json!({"name": "Mid", "cascade": true}))));
assert!(app.session.model.partitions().is_empty() && app.selected.is_none());
// A material a section uses is refused: no Delete.
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
app.ask_delete(Def::Material, "Steel");
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!() };
assert!(!ask.allowed() && !ask.users.is_empty());
let journal = app.session.journal.len();
app.answer_delete(true);
assert!(app.session.model.materials().contains_key("Steel"));
assert_eq!(app.session.journal.len(), journal);
// Active in step switches a boundary condition per step.
let bc = app.session.model.boundary_conditions().keys().next().unwrap().clone();
let step = app.session.model.steps()[0].name.clone();
apply(&mut app, Target::Def(Def::BoundaryCondition, bc.clone()), Action::Active { step: step.clone(), active: false });
assert!(!app.session.model.step(&step).unwrap().boundary_conditions.contains(&bc));
let _ = std::fs::remove_dir_all(&dir);
}
}
+6 -1
View File
@@ -73,6 +73,10 @@ pub fn matches(query: &str) -> Vec<Hit> {
/// Run a line of command-line input: `id {json}` runs the command with those parameters, `id`
/// starts it, anything else starts the best match.
pub fn submit(app: &mut ModelApp, line: &str) {
app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| submit_line(app, line));
}
fn submit_line(app: &mut ModelApp, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
@@ -152,6 +156,7 @@ pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.separator();
ui.label(RichText::new(format!("{nodes} nodes {elements} elements")).size(11.5).color(t.text_dim));
ui.separator();
crate::updates::notice(app, ui);
let (text, color) = match &app.status {
Some(s) => (s.text.as_str(), if s.error { t.error } else { t.text }),
None => ("Ready", t.text_dim),
@@ -246,7 +251,7 @@ pub fn popup(app: &mut ModelApp, ctx: &egui::Context) {
if let Some(id) = picked {
app.palette_text.clear();
app.ui.palette = false;
let _ = app.start(id);
let _ = app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| app.start(id));
} else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.palette = false;
app.palette_text.clear();
+344
View File
@@ -0,0 +1,344 @@
//! Anonymous usage sharing (docs/privacy.md): the question the first run asks once, and Help ›
//! Settings, where it is changed. The choice is kept in the preferences ([`crate::files::Prefs`]);
//! the events are [`fieldcraft_engine::usage`]'s, and the host sends them.
//!
//! The window records only when the host says this run may ([`ModelApp::with_usage`]: the build
//! has an analytics key, and neither `FIELDCRAFT_TELEMETRY=0`, CI nor automation turned it off)
//! and the user said yes.
use egui::RichText;
use fieldcraft_engine::usage::{self, Event, Identity, Origin};
use crate::ModelApp;
use crate::files::Prompt;
/// What is sent, event by event.
pub const PRIVACY_URL: &str = "https://github.com/jyviko/fieldcraft/blob/main/docs/privacy.md";
/// The question, as the first run asks it.
pub const QUESTION: &str = "Help improve FieldCraft by sharing anonymous usage: which tools you use, nothing from your models.";
const DETAIL: &str = "Counted: the commands you run (their names, how long they took, whether they failed), the steps from a design to results, \
the samples you open, and the version, OS and a random id for this install. Never sent: file names or paths, geometry, model \
contents, parameter values or messages.";
/// What the host provides when this run may share usage.
pub struct UsageHost {
/// `desktop` or `web`.
pub platform: &'static str,
pub os: &'static str,
pub arch: &'static str,
/// A new random install id; `None` when there is no randomness to make one.
pub new_id: Box<dyn Fn() -> Option<String>>,
}
impl ModelApp {
/// This run may share usage (the host checked the build's key and the environment): ask the
/// user once, or record if they already agreed.
pub fn with_usage(mut self, host: UsageHost) -> Self {
self.usage = Some(host);
self.apply_usage();
self
}
/// The user's preferences as last saved.
pub fn prefs(&self) -> &crate::files::Prefs {
&self.prefs
}
/// Whether this run can share usage at all.
pub fn usage_available(&self) -> bool {
self.usage.is_some()
}
/// Start or stop recording as the preferences say.
fn apply_usage(&mut self) {
let Some(host) = &self.usage else { return };
if self.prefs.usage_sharing != Some(true) {
usage::disable();
return;
}
if self.prefs.install_id.is_empty() {
let Some(id) = (host.new_id)() else {
log::warn!("usage sharing: no random install id could be made");
return;
};
self.prefs.install_id = id;
}
let identity = Identity {
install_id: self.prefs.install_id.clone(),
platform: host.platform,
os: host.os,
arch: host.arch,
app_version: crate::VERSION,
};
usage::enable(identity, self.prefs.usage_automation);
}
/// Change the choices (the consent question, Help › Settings, `ui.prompt`) and keep them.
pub fn set_usage(&mut self, sharing: bool, automation: bool) {
self.prefs.usage_sharing = Some(sharing);
self.prefs.usage_automation = automation;
// Agreeing makes the install id, which is kept with the choice.
self.apply_usage();
self.prefs.save(self.services.prefs.as_ref());
}
/// Answer the first run's question.
pub fn answer_consent(&mut self, share: bool) {
if self.prompt == Some(Prompt::Consent) {
self.prompt = None;
}
self.set_usage(share, self.prefs.usage_automation);
}
/// Open Help › Settings.
pub fn open_settings(&mut self) {
self.prompt = Some(Prompt::Settings);
self.ui.menu = None;
}
/// The window is closing: the session's length.
pub fn usage_closing(&self) {
if self.usage.is_some() {
usage::closing();
}
}
/// Ask the question once, when nothing else is being asked.
pub(crate) fn ask_consent(&mut self) {
if self.usage.is_some() && self.prefs.usage_sharing.is_none() && self.prompt.is_none() && !self.consent_asked {
self.consent_asked = true;
self.prompt = Some(Prompt::Consent);
}
}
/// Record something the user did in the window (not a script or an agent driving it).
pub(crate) fn record_ui(&self, event: Event) {
if !self.session.origin.is_some_and(Origin::automated) {
usage::record(event);
}
}
/// Run `f` with the session's commands counted as coming from `origin`.
pub fn with_origin<R>(&mut self, origin: Origin, f: impl FnOnce(&mut Self) -> R) -> R {
let before = self.session.origin.replace(origin);
let r = f(self);
self.session.origin = before;
r
}
pub(crate) fn open_link(&mut self, ctx: &egui::Context, url: &str) {
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(url) {
self.set_status(format!("Could not open the browser: {e}"), true);
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(url)),
}
}
}
/// The consent question and Help › Settings, when open.
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt {
Some(Prompt::Consent) => {
let (mut share, mut decline, mut link, mut updates) = (false, false, false, None);
crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
ui.label(QUESTION);
ui.add_space(4.0);
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.add_space(2.0);
ui.label(RichText::new("You can change this any time in Help › Settings.").color(t.text_dim).size(11.0));
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
if app.updates_available() {
ui.add_space(4.0);
updates = update_check(ui, &t, app.prefs.update_check);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
share = ui.button("Share Anonymous Usage").clicked();
decline = ui.button("Don't Share").clicked();
});
});
if link {
app.open_link(ctx, PRIVACY_URL);
}
if let Some(on) = updates {
app.set_update_prefs(on, None);
}
if share {
app.answer_consent(true);
} else if decline || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.answer_consent(false);
}
}
Some(Prompt::Settings) => {
let available = app.usage_available();
let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation);
let (mut close, mut link, mut updates, mut unskip, mut check) = (false, false, None, false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
if app.updates_available() {
crate::frame::menu_heading(ui, &t, "UPDATES");
updates = update_check(ui, &t, app.prefs.update_check);
ui.horizontal(|ui| {
check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked();
if !app.prefs.update_skip.is_empty() {
ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
unskip = ui.small_button("Offer It Again").clicked();
}
});
ui.add_space(6.0);
}
crate::frame::menu_heading(ui, &t, "PRIVACY");
ui.add_enabled(available, egui::Checkbox::new(&mut sharing, "Share anonymous usage")).on_hover_text(QUESTION);
ui.indent("fc_usage_automation", |ui| {
ui.add_enabled(available && sharing, egui::Checkbox::new(&mut automation, "Include scripts and AI agents (MCP)"))
.on_hover_text("Count the commands scripts, the control channel and AI agents run, apart from yours");
});
if !available {
ui.label(
RichText::new(
"This build or run sends nothing: it has no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation turned it off.",
)
.color(t.text_dim)
.size(11.0),
);
}
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.label(
RichText::new("Crash reports and the log stay on this computer; Help › Report a Bug sends only what you submit.")
.color(t.text_dim)
.size(11.0),
);
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
if available && (sharing != (app.prefs.usage_sharing == Some(true)) || automation != app.prefs.usage_automation) {
app.set_usage(sharing, automation);
}
if updates.is_some() || unskip {
app.set_update_prefs(updates.unwrap_or(app.prefs.update_check), unskip.then(String::new));
}
if check {
app.check_for_updates();
}
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
}
_ => {}
}
}
/// The automatic update check's checkbox; the new value when it was clicked.
fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option<bool> {
let mut checked = on;
let changed = ui
.checkbox(&mut checked, "Check for updates automatically")
.on_hover_text(
"At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
)
.changed();
ui.label(
RichText::new("The check sends only the request (your IP address and the FieldCraft version reach GitHub); off means no check.")
.color(t.text_dim)
.size(11.0),
);
changed.then_some(checked)
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use fieldcraft_engine::usage::TEST_LOCK;
use serde_json::json;
use super::*;
use crate::files::{PrefsStore, Services};
fn host() -> UsageHost {
UsageHost { platform: "desktop", os: "test", arch: "test", new_id: Box::new(|| Some("11111111-2222-4333-8444-555555555555".into())) }
}
fn app(file: &std::path::Path) -> ModelApp {
ModelApp::new(Session::default()).with_services(Services { prefs: Some(PrefsStore::file(file.to_path_buf())), ..Default::default() })
}
fn temp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fieldcraft-privacy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("prefs.json")
}
/// The first run asks once; nothing is recorded before the answer or after a no, and the
/// answer is kept: the next run neither asks again nor forgets it.
#[test]
fn consent_is_asked_once_and_kept() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
usage::disable();
let file = temp("consent");
// Without the host's go-ahead (no key, automation, FIELDCRAFT_TELEMETRY=0) nothing asks.
let mut a = app(&file);
a.ask_consent();
assert_eq!(a.prompt, None);
let mut a = app(&file).with_usage(host());
a.ask_consent();
assert_eq!(a.prompt, Some(Prompt::Consent));
let _ = a.run("model.info", json!({}));
assert_eq!(usage::queued(), 0, "nothing before the answer");
a.answer_consent(false);
assert_eq!(a.prompt, None);
let _ = a.run("model.info", json!({}));
assert!(!usage::enabled() && usage::queued() == 0);
a.ask_consent();
assert_eq!(a.prompt, None, "asked once");
let mut again = app(&file).with_usage(host());
again.ask_consent();
assert_eq!((again.prompt.clone(), again.prefs.usage_sharing), (None, Some(false)));
// Settings › Privacy turns it on: the install id is made and kept, and commands count.
again.set_usage(true, true);
assert!(usage::enabled());
let _ = again.run("model.info", json!({}));
let events: Vec<Event> = std::iter::from_fn(usage::take_batch).flat_map(|b| b.events).collect();
assert!(events.contains(&Event::AppStarted), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::CommandRun { command: "model.info", from: Origin::Ui, .. })), "{events:?}");
let kept = crate::files::Prefs::load(Some(&PrefsStore::file(file.clone())));
assert_eq!((kept.usage_sharing, kept.install_id.as_str()), (Some(true), "11111111-2222-4333-8444-555555555555"));
usage::disable();
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
/// A cancelled form and a command clicked before the model is ready are counted, by command
/// id and the requirements' names; the control channel's are not the user's.
#[test]
fn cancelled_forms_and_unavailable_clicks_are_counted() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
usage::disable();
let file = temp("events");
let mut a = app(&file).with_usage(host());
a.set_usage(true, true);
let _ = std::iter::from_fn(usage::take_batch).count();
let _ = a.start("solve.run");
a.start("model.material.create").unwrap();
a.close_dialog();
a.with_origin(Origin::Script, |a| {
let _ = a.start("solve.run");
});
let events: Vec<Event> = std::iter::from_fn(usage::take_batch).flat_map(|b| b.events).collect();
let unavailable: Vec<_> = events.iter().filter(|e| matches!(e, Event::UnavailableClicked { command: "solve.run", .. })).collect();
assert_eq!(unavailable.len(), 1, "{events:?}");
assert!(events.contains(&Event::FormCancelled { command: "model.material.create" }), "{events:?}");
assert_eq!(a.session.origin, Some(Origin::Ui));
usage::disable();
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
}
+368
View File
@@ -0,0 +1,368 @@
//! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! unexpectedly — Report it").
//!
//! The form shows exactly what the GitHub issue form will be filled with
//! ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
//! default) and, after a crash, where it happened. **Share my session** adds the commands that
//! built the model, **Include the model file** the model as well. Nothing leaves the computer
//! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
//! submit it there, or not); files too big for the link are saved to a folder to attach from, and
//! a long session is also put on the clipboard.
use egui::RichText;
use fieldcraft_engine::bug_report::{self, Draft, Input};
use fieldcraft_engine::crash::{self, Summary};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::files::Prompt;
/// The Report a Bug form.
#[derive(Debug, Default)]
pub struct ReportForm {
pub title: String,
pub observed: String,
/// The crash report being reported (its id).
pub crash: Option<String>,
pub share_session: bool,
pub include_model: bool,
/// What the draft is built from (the session's commands and model, or the crash report's),
/// read when the form opens and when the model is first included.
input: Input,
model_read: bool,
/// The draft, and the form state it was made for.
draft: Option<Draft>,
drafted: Option<Key>,
/// Why the input could not be read.
pub problem: Option<String>,
}
type Key = (String, String, bool, bool);
impl ReportForm {
/// The form and its draft as last made (for `ui.inspect`).
pub fn drafted_json(&self) -> Option<Value> {
let mut v = self.draft.as_ref()?.to_json();
v["share_session"] = json!(self.share_session);
v["include_model"] = json!(self.include_model);
v["crash"] = json!(self.crash);
Some(v)
}
/// The draft for the form as it is now (made again only when something changed).
pub fn draft(&mut self) -> &Draft {
let key = (self.title.clone(), self.observed.clone(), self.share_session, self.include_model);
if self.drafted.as_ref() != Some(&key) {
self.draft = None;
self.drafted = Some(key);
}
self.draft.get_or_insert_with(|| {
let mut input = self.input.clone();
input.title = self.title.clone();
input.observed = self.observed.clone();
input.share_session = self.share_session;
input.include_model = self.include_model;
bug_report::draft(&input, &crash::Build::current().version_line())
})
}
}
/// The input for `form` from the session (or its crash report).
fn read_input(app: &ModelApp, form: &ReportForm) -> Result<Input, String> {
let mut p = json!({"share_session": true, "include_model": form.include_model});
if let Some(id) = &form.crash {
p["crash"] = json!(id);
}
fieldcraft_engine::cmd::diagnostics::bug_report_input(&app.session, &p).map_err(|e| e.to_string())
}
impl ModelApp {
/// Open Help › Report a Bug, for `crash` (a report's id) or what happened last.
pub fn report_bug(&mut self, crash: Option<String>) {
let mut form = ReportForm {
observed: if crash.is_some() { String::new() } else { self.last_error.clone().unwrap_or_default() },
crash,
..Default::default()
};
match read_input(self, &form) {
Ok(input) => form.input = input,
Err(e) => form.problem = Some(e),
}
self.report = Some(form);
self.prompt = Some(Prompt::ReportBug);
self.ui.menu = None;
}
/// The host found these crash reports at startup: ask about the real crashes.
pub fn announce_crashes(&mut self, fresh: &[Summary]) {
let crashes: Vec<Summary> = fresh.iter().filter(|r| r.kind != "recovered_panic").cloned().collect();
if crashes.is_empty() {
return;
}
log::warn!(
"{} crash report(s) since the last start: {}",
crashes.len(),
crashes.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(", ")
);
if self.prompt.is_none() {
self.prompt = Some(Prompt::Crashed(crashes));
}
}
/// Change the open form (`ui.prompt` `set`): any of title, observed, share_session,
/// include_model.
pub fn set_report(&mut self, p: &Value) -> Result<Value, String> {
let mut form = self.report.take().ok_or("Report a Bug is not open")?;
if let Some(t) = p.get("title").and_then(Value::as_str) {
form.title = t.chars().take(500).collect();
}
if let Some(t) = p.get("observed").and_then(Value::as_str) {
form.observed = t.chars().take(20_000).collect();
}
if let Some(b) = p.get("share_session").and_then(Value::as_bool) {
form.share_session = b;
}
if let Some(b) = p.get("include_model").and_then(Value::as_bool) {
form.include_model = b;
}
self.include_model_if_asked(&mut form);
let draft = form.draft().to_json();
self.report = Some(form);
Ok(draft)
}
/// Read the model once it is first included.
fn include_model_if_asked(&mut self, form: &mut ReportForm) {
if form.include_model && !form.model_read {
form.model_read = true;
match read_input(self, form) {
Ok(input) => form.input = input,
Err(e) => form.problem = Some(e),
}
}
}
/// Open the draft in the browser: attachments saved to a folder of their own, the session on
/// the clipboard when it is too long for the link.
fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut saved = None;
if !draft.attachments.is_empty() {
match bug_report::save_attachments(draft) {
Ok(dir) => saved = Some(dir),
Err(e) => {
self.set_status(format!("Report a Bug: could not save the files to attach: {e}"), true);
return;
}
}
}
if let Some(text) = &draft.clipboard {
ctx.copy_text(text.clone());
}
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(&draft.url) {
self.set_status(format!("Report a Bug: could not open the browser: {e}"), true);
return;
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(draft.url.clone())),
}
log::info!("bug report opened in the browser ({} bytes, {} attachment(s))", draft.url.len(), draft.attachments.len());
let text = match saved {
Some(dir) => format!("The issue form opened in your browser: attach the files in {} and submit it there.", dir.display()),
None => "The issue form opened in your browser: review it and submit it there.".to_string(),
};
self.set_status(text, false);
self.prompt = None;
self.report = None;
}
}
fn size(bytes: usize) -> String {
if bytes < 1024 { format!("{bytes} B") } else { format!("{} KB", bytes.div_ceil(1024)) }
}
fn kind_label(kind: &str) -> &'static str {
match kind {
"unclean_exit" => "FieldCraft closed unexpectedly",
"command_panic" => "A command hit an internal error",
_ => "FieldCraft hit an internal error",
}
}
/// The crash question and the Report a Bug form, when open.
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt.clone() {
Some(Prompt::Crashed(reports)) => {
let Some(first) = reports.first() else {
app.prompt = None;
return;
};
let (mut report, mut close) = (false, false);
crate::frame::window(ctx, kind_label(&first.kind), crate::frame::Width::Normal).id(egui::Id::new("fc_crashed")).show(ctx, |ui| {
ui.label(match first.kind.as_str() {
"unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
_ => "FieldCraft hit an internal error the last time it ran.",
});
ui.label(RichText::new(&first.message).color(t.text_dim));
if reports.len() > 1 {
ui.label(RichText::new(format!("And {} more since.", reports.len() - 1)).color(t.text_dim));
}
ui.add_space(4.0);
ui.label(
RichText::new(
"A crash report was saved on this computer. Reporting it opens a GitHub issue you review first; nothing is sent until you submit it.",
)
.color(t.text_dim),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
report = ui.button("Report It…").clicked();
close = ui.button("Not Now").clicked();
});
});
if report {
app.report_bug(Some(first.id.clone()));
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
}
Some(Prompt::ReportBug) => {
let Some(mut form) = app.report.take() else {
app.prompt = None;
return;
};
let (mut send, mut close) = (false, false);
let commands = form.input.commands.len();
crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
ui.label(
RichText::new(
"This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
)
.color(t.text_dim),
);
if let Some(p) = &form.problem {
ui.label(RichText::new(p).color(t.error));
}
ui.add_space(4.0);
ui.label("Title");
let hint = form.draft().title.clone();
ui.add(egui::TextEdit::singleline(&mut form.title).hint_text(hint).desired_width(f32::INFINITY));
ui.label("What happened");
ui.add(
egui::TextEdit::multiline(&mut form.observed)
.hint_text("What you did, and what went wrong")
.desired_rows(3)
.desired_width(f32::INFINITY),
);
ui.add_space(4.0);
ui.checkbox(&mut form.share_session, "Share my session")
.on_hover_text("Attach the commands that built the model, to replay it. They can name your files.");
ui.label(
RichText::new(format!("The {commands} command(s) that built the model, the version and the OS; they can name your files."))
.color(t.text_dim)
.size(11.0),
);
ui.add_enabled_ui(form.share_session, |ui| {
ui.checkbox(&mut form.include_model, "Include the model file")
.on_hover_text("Attach the model too (you attach the file yourself).");
});
if !form.share_session {
form.include_model = false;
}
app.include_model_if_asked(&mut form);
let draft = form.draft().clone();
ui.add_space(6.0);
ui.horizontal(|ui| {
send = ui.button("Open GitHub Issue").clicked();
close = ui.button("Cancel").clicked();
});
ui.add_space(4.0);
egui::CollapsingHeader::new("What is sent").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
for (k, v) in &draft.fields {
if !v.is_empty() {
ui.label(RichText::new(format!("{k}:")).strong().size(11.0));
ui.label(RichText::new(v).monospace().size(11.0));
}
}
for a in &draft.attachments {
ui.label(RichText::new(format!("Attach {} ({}): {}", a.name, size(a.bytes.len()), a.what)).size(11.0).color(t.accent));
}
if draft.clipboard.is_some() {
ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
}
});
});
});
let draft = form.draft().clone();
app.report = Some(form);
if send {
app.send_report(ctx, &draft);
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
app.report = None;
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fieldcraft_engine::Session;
#[test]
fn report_a_bug_shows_the_last_error_and_shares_only_when_ticked() {
let mut app = ModelApp::new(Session::default());
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
let _ = app.run("mesh.node.remove", json!({"label": 99}));
let error = app.last_error.clone().expect("the failure is the last error");
app.report_bug(None);
assert_eq!(app.prompt, Some(Prompt::ReportBug));
let form = app.report.as_mut().unwrap();
assert_eq!(form.observed, error);
let d = form.draft().clone();
assert!(d.attachments.is_empty() && !bug_report_text(&d).contains("mesh.node.add"), "no session unless shared");
let d = app.set_report(&json!({"share_session": true})).unwrap();
assert!(d["url"].as_str().unwrap().contains("mesh.node.add"), "{d}");
let d = app.set_report(&json!({"include_model": true})).unwrap();
assert_eq!(d["attachments"][0]["name"], "model.fieldcraft", "{d}");
// The model goes only with the session.
let d = app.set_report(&json!({"share_session": false})).unwrap();
assert_eq!(d["attachments"], json!([]), "{d}");
}
fn bug_report_text(d: &Draft) -> String {
d.fields.iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>().join("\n")
}
#[test]
fn a_crash_at_startup_asks_and_report_it_opens_the_form() {
let mut app = ModelApp::new(Session::default());
let quiet = Summary {
id: "20261014-101500-0000000000000001".into(),
kind: "recovered_panic".into(),
time: String::new(),
message: String::new(),
command: None,
count: 1,
version: String::new(),
path: String::new(),
replayable: false,
};
app.announce_crashes(std::slice::from_ref(&quiet));
assert_eq!(app.prompt, None, "recovered panics do not interrupt");
let crashed = Summary { kind: "unclean_exit".into(), message: "FieldCraft ended without a clean exit".into(), ..quiet };
app.announce_crashes(std::slice::from_ref(&crashed));
assert!(matches!(&app.prompt, Some(Prompt::Crashed(r)) if r.len() == 1));
app.report_bug(Some(crashed.id.clone()));
let form = app.report.as_ref().unwrap();
assert_eq!(form.crash.as_deref(), Some(crashed.id.as_str()));
// The report is not in this test's crash folder: the form says so and still drafts.
assert!(form.problem.as_deref().is_some_and(|p| p.contains("no crash report")), "{:?}", form.problem);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! The samples as a grid of cards (the Start card, File › Samples) and as rows (Help › Samples):
//! every sample of `fieldcraft_engine::sample::SAMPLES`, the flagship ones first, each with its
//! thumbnail where one exists. Thumbnails are the gallery captures (docs/media/examples) cropped
//! and scaled to 320 px, embedded at build time so they work on the web too.
use egui::{Rect, Sense, TextureHandle, vec2};
use fieldcraft_engine::sample::{SAMPLES, Sample};
use crate::theme::Tokens;
/// The order the samples are offered in: these first, then any other in the registry's order.
const FLAGSHIP: &[&str] = &["crank-arm", "drop-test", "rubber-seal", "heated-bracket", "bracket-hex", "flow-past", "cantilever", "impact", "parts"];
/// The embedded thumbnails (PNG, 320 px wide), by sample name.
const THUMBNAILS: &[(&str, &[u8])] = &[
("crank-arm", include_bytes!("../thumbnails/crank-arm.png")),
("crank-mirror", include_bytes!("../thumbnails/crank-mirror.png")),
("drop-test", include_bytes!("../thumbnails/drop-test.png")),
("rubber-seal", include_bytes!("../thumbnails/rubber-seal.png")),
("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")),
("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")),
];
/// The thumbnails' aspect ratio (height over width).
const THUMB_ASPECT: f32 = 213.0 / 320.0;
/// The narrowest card; the grid fits as many columns as this allows.
const MIN_CARD_W: f32 = 180.0;
const MAX_COLUMNS: usize = 4;
const GAP: f32 = 10.0;
/// Every sample, flagship first.
pub fn ordered() -> Vec<&'static Sample> {
let rank = |s: &Sample| FLAGSHIP.iter().position(|n| *n == s.name).unwrap_or(FLAGSHIP.len());
let mut out: Vec<&'static Sample> = SAMPLES.iter().collect();
// Stable: samples outside the flagship list keep the registry's order.
out.sort_by_key(|s| rank(s));
out
}
/// The id of a sample's card in the grid (tests and the control channel read its response).
pub fn card_id(name: &str) -> egui::Id {
egui::Id::new(("fc_sample_card", name))
}
/// A PNG decoded to an egui image; `None` when it does not decode.
fn decode(bytes: &[u8]) -> Option<egui::ColorImage> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().ok()?;
let mut buf = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut buf).ok()?;
let size = [info.width as usize, info.height as usize];
let pixels = buf.get(..info.buffer_size())?;
match info.color_type {
png::ColorType::Rgba => Some(egui::ColorImage::from_rgba_unmultiplied(size, pixels)),
png::ColorType::Rgb => Some(egui::ColorImage::from_rgb(size, pixels)),
_ => None,
}
}
/// The thumbnail of sample `name`, uploaded once; `None` when it has none.
fn thumbnail(ctx: &egui::Context, name: &'static str) -> Option<TextureHandle> {
let key = egui::Id::new(("fc_sample_thumbnail", name));
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<TextureHandle>>(key)) {
return cached;
}
let texture = THUMBNAILS.iter().find(|(n, _)| *n == name).and_then(|(_, bytes)| {
let image = decode(bytes);
if image.is_none() {
log::warn!("sample thumbnail {name}: does not decode");
}
image.map(|i| ctx.load_texture(format!("sample:{name}"), i, egui::TextureOptions::LINEAR))
});
ctx.data_mut(|d| d.insert_temp(key, texture.clone()));
texture
}
/// The samples as a grid of cards in a vertical scroll area with a solid (always drawn)
/// scrollbar; `open` gets the one clicked.
pub fn grid(ui: &mut egui::Ui, t: &Tokens, max_height: f32, open: &mut Option<&'static str>) {
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().id_salt("fc_sample_grid").max_height(max_height).auto_shrink([false, true]).show(ui, |ui| {
let width = ui.available_width();
let columns = (((width + GAP) / (MIN_CARD_W + GAP)).floor() as usize).clamp(1, MAX_COLUMNS);
let card_w = ((width - GAP * (columns as f32 - 1.0)) / columns as f32).max(1.0);
for row in ordered().chunks(columns) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = GAP;
for sample in row {
if card(ui, t, sample, card_w) {
*open = Some(sample.name);
}
}
});
ui.add_space(GAP);
}
});
}
/// One sample's card: thumbnail, name and a one-line description (in full on hover). True when
/// clicked.
fn card(ui: &mut egui::Ui, t: &Tokens, sample: &'static Sample, w: f32) -> bool {
let thumb_h = (w * THUMB_ASPECT).round();
let text_h = 40.0;
let (rect, _) = ui.allocate_exact_size(vec2(w, thumb_h + text_h), Sense::hover());
let response = ui.interact(rect, card_id(sample.name), Sense::click()).on_hover_text(sample.about);
let painter = ui.painter_at(rect);
let fill = if response.hovered() { t.hover } else { t.panel };
painter.rect_filled(rect, 6.0, fill);
let image_rect = Rect::from_min_size(rect.min, vec2(w, thumb_h)).shrink(4.0);
match thumbnail(ui.ctx(), sample.name) {
Some(texture) => {
egui::Image::new(&texture).corner_radius(4.0).paint_at(ui, image_rect);
}
None => {
// No capture yet: the name's initial on a plain tile.
painter.rect_filled(image_rect, 4.0, t.accent_soft);
let initial = sample.label.chars().next().map(String::from).unwrap_or_default();
painter.text(image_rect.center(), egui::Align2::CENTER_CENTER, initial, egui::FontId::proportional(thumb_h * 0.4), t.accent);
}
}
let stroke = if response.hovered() { t.accent } else { t.border };
painter.rect_stroke(rect, 6.0, egui::Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
let text_rect = Rect::from_min_max(egui::pos2(rect.left() + 6.0, rect.top() + thumb_h + 2.0), rect.max - vec2(6.0, 2.0));
let mut text_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect).layout(egui::Layout::top_down(egui::Align::Min)));
text_ui.add(egui::Label::new(egui::RichText::new(sample.label).strong().color(t.overlay_text)).truncate().selectable(false));
text_ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).truncate().selectable(false));
response.clicked()
}
/// The samples as rows (name, what it shows, Open) for a narrow menu; `open` gets the one clicked.
pub fn rows(ui: &mut egui::Ui, t: &Tokens, open: &mut Option<&'static str>) {
let width = ui.available_width().clamp(280.0, 420.0);
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().id_salt("fc_sample_rows").auto_shrink([false, true]).show(ui, |ui| {
for sample in ordered() {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(width - 70.0);
ui.label(egui::RichText::new(sample.label).strong());
ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap());
});
if ui.button("Open").clicked() {
*open = Some(sample.name);
}
});
ui.add_space(4.0);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Every registered sample is offered exactly once, flagship first.
#[test]
fn every_sample_is_offered_once_flagship_first() {
let names: Vec<&str> = ordered().iter().map(|s| s.name).collect();
assert_eq!(names.len(), SAMPLES.len());
for s in SAMPLES {
assert_eq!(names.iter().filter(|n| **n == s.name).count(), 1, "{}", s.name);
}
assert_eq!(names.first(), Some(&"crank-arm"));
}
/// Every flagship name and every thumbnail names a registered sample, and every thumbnail
/// decodes at 320 px wide.
#[test]
fn thumbnails_and_flagship_names_are_registered_samples() {
for name in FLAGSHIP.iter().chain(THUMBNAILS.iter().map(|(n, _)| n)) {
assert!(fieldcraft_engine::sample::find(name).is_some(), "{name}");
}
for (name, bytes) in THUMBNAILS {
let image = decode(bytes).unwrap_or_else(|| panic!("{name} does not decode"));
assert_eq!(image.size[0], 320, "{name}");
}
}
}
+5 -3
View File
@@ -82,9 +82,11 @@ pub fn symbols(
glyphs.draw(pos, &view, s)
}
/// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them.
pub fn datums(s: &mut Scene, model: &fieldcraft_model::Model, bounds: &Aabb3, t: &Tokens) -> Aabb3 {
fieldcraft_render::glyphs::datums(model, bounds, &t.glyph_colors(), s)
/// Draw the datums the session's view shows at the model (`bounds`) into `s`, and `selected` (the
/// datum selected in the browser), shown or hidden, highlighted; returns the box around them.
pub fn datums(s: &mut Scene, session: &fieldcraft_engine::Session, bounds: &Aabb3, selected: Option<&str>, t: &Tokens) -> Aabb3 {
let shown = |name: &str| session.shows(fieldcraft_engine::ItemKind::Datum, name);
fieldcraft_render::glyphs::datums(&session.model, bounds, &shown, selected, &t.glyph_colors(), s)
}
/// The scene for `display` drawn in `style`.
+579
View File
@@ -0,0 +1,579 @@
//! In-app updates (docs/updates.md): the check at start-up (at most once a day, when the user
//! leaves it on) and from Help › Check for Updates, the quiet notice in the status bar
//! ("FieldCraft 0.5.9 is available — What's new · Install"), Help › Reinstall FieldCraft, and
//! the question that finishes an install. The host does the network and file work
//! ([`UpdateHost`], the desktop app's `update.rs`) on its own threads; the window only asks and
//! shows. Without a host (the browser, tests) nothing here runs and no network call is made.
//!
//! Nothing installs without the user's click: Install downloads and verifies the release, then
//! asks to restart. Restarting saves or asks about unsaved changes first, as opening another
//! model does; the swap happens after the window has closed.
use std::path::Path;
use std::sync::mpsc::Receiver;
use egui::RichText;
use fieldcraft_update::{Check, Finish, Release, Staged};
use crate::ModelApp;
use crate::files::{Prompt, Replace};
/// What the host is asked to do, on its own thread.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Job {
/// Ask for the latest release (`manual`: the user asked, so the answer is shown even when
/// there is nothing new or the check fails).
Check { manual: bool },
/// Download, verify and stage this release.
Stage(Release),
/// Download, verify and stage the running version again (Help › Reinstall FieldCraft).
Reinstall,
}
/// What the host reports back.
#[derive(Clone, Debug)]
pub enum Event {
Checked {
manual: bool,
result: Result<Check, String>,
},
/// Bytes downloaded so far, and the total (0 when not known).
Progress(u64, u64),
Staged(Result<Staged, String>),
}
/// What the desktop app provides for updates.
pub struct UpdateHost {
/// Why this copy cannot install updates itself (a development build, an unknown layout);
/// `None` when it can. It still checks.
pub unsupported: Option<String>,
/// Automatic checks may run in this run (not under CI or automation, unless a test server
/// is set). Help › Check for Updates works either way.
pub automatic: bool,
/// Start a job; its events come back on `events`.
pub start: Box<dyn Fn(Job)>,
pub events: Receiver<Event>,
/// Seconds since the Unix epoch.
pub now: Box<dyn Fn() -> u64>,
/// Open a downloaded package with the system's installer.
pub open_file: Box<dyn Fn(&Path) -> Result<(), String>>,
}
/// The window's update state.
pub(crate) struct State {
host: UpdateHost,
/// A newer release the notice offers (not skipped, or asked for by hand).
available: Option<Release>,
/// What runs in the background, in words ("Checking for updates…").
busy: Option<String>,
/// The verified release, ready to finish, and how.
staged: Option<(Staged, Finish)>,
/// The automatic check of this run has been decided.
auto_done: bool,
/// Close the window to finish the install (sent with the next frame's context).
close: bool,
}
impl ModelApp {
/// Updates are available in this run (the desktop app).
pub fn with_updates(mut self, host: UpdateHost) -> Self {
self.updates = Some(State { host, available: None, busy: None, staged: None, auto_done: false, close: false });
self
}
/// Whether this run checks for updates at all.
pub fn updates_available(&self) -> bool {
self.updates.is_some()
}
/// The automatic check, once per run, when it is on, due (a day since the last), allowed in
/// this run, and the first run's question has been answered. Off means no network call.
fn auto_check(&mut self) {
let Some(u) = &mut self.updates else { return };
if u.auto_done || self.prompt == Some(Prompt::Consent) {
return;
}
u.auto_done = true;
let now = (u.host.now)();
if !u.host.automatic || !fieldcraft_update::due(self.prefs.update_check, self.prefs.update_checked_at, now) {
return;
}
(u.host.start)(Job::Check { manual: false });
self.prefs.update_checked_at = now;
self.prefs.save(self.services.prefs.as_ref());
}
/// Help › Check for Updates.
pub fn check_for_updates(&mut self) {
self.ui.menu = None;
let Some(u) = &mut self.updates else {
self.set_status("This build does not check for updates", true);
return;
};
u.busy = Some("Checking for updates…".into());
(u.host.start)(Job::Check { manual: true });
self.set_status("Checking for updates…", false);
}
/// Help › Reinstall FieldCraft: download, verify and swap in the running version again.
pub fn reinstall(&mut self) {
self.ui.menu = None;
let Some(u) = &mut self.updates else { return };
if let Some(why) = u.host.unsupported.clone() {
self.set_status(format!("Cannot reinstall: {why}"), true);
return;
}
u.busy = Some(format!("Downloading FieldCraft {}…", crate::VERSION));
(u.host.start)(Job::Reinstall);
self.set_status(format!("Downloading FieldCraft {} to reinstall it…", crate::VERSION), false);
}
/// The notice's Install: download and verify, then ask to restart. A copy that cannot
/// install itself, or a release that is not signed, opens the release page instead.
pub fn install_update(&mut self, ctx: &egui::Context) {
let Some(u) = &mut self.updates else { return };
let Some(release) = u.available.clone() else { return };
let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("FieldCraft {} is not signed", release.version)));
if let Some(why) = blocked {
self.set_status(format!("{why}: opening the download page"), true);
self.open_link(ctx, &release.url);
return;
}
u.busy = Some(format!("Downloading FieldCraft {}…", release.version));
(u.host.start)(Job::Stage(release.clone()));
self.set_status(format!("Downloading FieldCraft {}…", release.version), false);
}
/// Skip the offered version: the automatic check no longer offers it.
pub fn skip_update(&mut self) {
let Some(u) = &mut self.updates else { return };
if let Some(r) = u.available.take() {
self.prefs.update_skip = r.version.to_string();
self.prefs.save(self.services.prefs.as_ref());
}
}
/// Settings › Updates: automatic checks on or off, and the skipped version.
pub fn set_update_prefs(&mut self, automatic: bool, skip: Option<String>) {
self.prefs.update_check = automatic;
if let Some(skip) = skip {
self.prefs.update_skip = skip;
}
self.prefs.save(self.services.prefs.as_ref());
}
/// The release the notice offers.
pub fn update_offered(&self) -> Option<&Release> {
self.updates.as_ref()?.available.as_ref()
}
/// Restart to finish the install (or, `false`, finish it when the window closes). Unsaved
/// changes are saved or asked about first, as when opening another model.
pub fn answer_update(&mut self, restart: bool) {
if self.prompt == Some(Prompt::Update) {
self.prompt = None;
}
if !restart {
if let Some(u) = &self.updates
&& let Some((s, Finish::Swap(_))) = &u.staged
{
let v = s.version.to_string();
self.set_status(format!("FieldCraft {v} installs when you quit"), false);
}
return;
}
if self.has_unsaved_work() {
self.prompt = Some(Prompt::Unsaved(Replace::Update));
return;
}
self.close_to_update();
}
/// Close the window; the host swaps the staged release in after it has closed.
pub(crate) fn close_to_update(&mut self) {
if let Some(u) = &mut self.updates {
u.close = true;
}
}
/// The verified release to swap in once the window has closed (the desktop app's exit).
pub fn take_staged_update(&mut self) -> Option<Staged> {
let u = self.updates.as_mut()?;
match &u.staged {
Some((_, Finish::Swap(_))) => u.staged.take().map(|(s, _)| s),
_ => None,
}
}
/// Take the host's reports, run the automatic check when due, and close the window when
/// an install is waiting on it.
pub(crate) fn poll_updates(&mut self, ctx: &egui::Context) {
self.auto_check();
let Some(u) = &mut self.updates else { return };
if std::mem::take(&mut u.close) {
self.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
let events: Vec<Event> = u.host.events.try_iter().collect();
if u.busy.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
for e in events {
self.update_event(e);
}
}
fn update_event(&mut self, e: Event) {
let Some(u) = &mut self.updates else { return };
match e {
Event::Checked { manual, result } => {
u.busy = None;
match result {
Ok(check) if check.newer() && (manual || self.prefs.update_skip != check.latest.version.to_string()) => {
let v = check.latest.version.to_string();
u.available = Some(check.latest);
if manual {
self.set_status(format!("FieldCraft {v} is available"), false);
}
}
Ok(check) => {
// Nothing newer (or skipped): a notice from an earlier check goes.
u.available = None;
if manual {
self.set_status(format!("FieldCraft {} is up to date", check.current), false);
}
}
Err(e) if manual => self.set_status(format!("Could not check for updates: {e}"), true),
// An automatic check fails silently (logged).
Err(e) => log::info!("update check: {e}"),
}
}
Event::Progress(done, total) => {
if let Some(b) = &mut u.busy {
let head = b.split(" (").next().unwrap_or_default().to_string();
*b = match done.saturating_mul(100).checked_div(total) {
Some(percent) => format!("{head} ({percent}%)"),
None => format!("{head} ({} MB)", done / (1024 * 1024)),
};
}
}
Event::Staged(Ok(staged)) => {
u.busy = None;
match fieldcraft_update::install::plan(&staged, true) {
Ok(finish) => {
u.staged = Some((staged, finish));
self.status = None;
self.prompt = Some(Prompt::Update);
}
Err(e) => self.set_status(format!("Cannot install: {e}"), true),
}
}
Event::Staged(Err(e)) => {
u.busy = None;
self.set_status(format!("Update not installed: {e}"), true);
}
}
}
/// The web app: a new deployment is out; the status bar offers to reload into it.
pub fn offer_reload(&mut self, reload: Box<dyn Fn()>) {
self.reload = Some(reload);
}
/// Whether the status bar offers to reload into a new deployment.
pub fn reload_offered(&self) -> bool {
self.reload.is_some()
}
/// For the control channel's `ui.inspect`.
pub fn update_json(&self) -> serde_json::Value {
let Some(u) = &self.updates else { return serde_json::Value::Null };
serde_json::json!({
"automatic": self.prefs.update_check,
"checked_at": self.prefs.update_checked_at,
"skip": self.prefs.update_skip,
"available": u.available.as_ref().map(|r| r.version.to_string()),
"busy": u.busy,
"staged": u.staged.as_ref().map(|(s, _)| s.version.to_string()),
"unsupported": u.host.unsupported,
})
}
}
/// The status bar's notice: an update offered, or one downloading; in the browser, a new
/// deployment to reload into.
pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(app.ui.dark);
if let Some(reload) = &app.reload {
let tip = if app.has_unsaved_work() { "Reloads the page; you are asked about the unsaved changes first" } else { "Reloads the page" };
if ui.add(egui::Link::new(RichText::new("Reload to update").size(11.5))).on_hover_text(tip).clicked() {
reload();
}
ui.label(RichText::new("A new version of FieldCraft is available —").size(11.5).color(t.text));
ui.separator();
return;
}
let Some(u) = &app.updates else { return };
if let Some(busy) = &u.busy {
ui.label(RichText::new(busy).size(11.5).color(t.text_dim));
ui.separator();
return;
}
let Some(release) = u.available.clone() else { return };
// Right to left: the links, then the sentence.
let small = |s: &str| RichText::new(s).size(11.5);
let skip = ui.add(egui::Link::new(small("Skip"))).on_hover_text("Don't offer this version again (Help › Settings)").clicked();
ui.label(small("·").color(t.text_dim));
let install = ui.add(egui::Link::new(small("Install"))).on_hover_text("Download, verify and install it; you choose when to restart").clicked();
ui.label(small("·").color(t.text_dim));
let notes = ui.add(egui::Link::new(small("What's new"))).on_hover_text(release.url.as_str()).clicked();
ui.label(small(&format!("FieldCraft {} is available —", release.version)).color(t.text));
ui.separator();
if notes {
app.open_link(ui.ctx(), &release.url);
}
if install {
app.install_update(ui.ctx());
}
if skip {
app.skip_update();
}
}
/// The question that finishes an install ([`Prompt::Update`]).
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
if app.prompt != Some(Prompt::Update) {
return;
}
let t = crate::theme::Tokens::get(app.ui.dark);
let Some((staged, finish)) = app.updates.as_ref().and_then(|u| u.staged.clone()) else {
app.prompt = None;
return;
};
let reinstall = staged.version.to_string() == crate::VERSION;
let (mut restart, mut later, mut open) = (false, false, false);
crate::frame::window(ctx, "Update FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish {
Finish::Swap(_) => {
ui.label(if reinstall {
format!("FieldCraft {} was downloaded again and verified. Restart to reinstall it?", staged.version)
} else {
format!("FieldCraft {} is downloaded and verified. Restart now to finish installing it?", staged.version)
});
ui.label(
RichText::new(format!("It replaces {}. Unsaved changes are saved or asked about first.", staged.install.describe()))
.color(t.text_dim)
.size(11.0),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
restart = ui.button("Restart Now").clicked();
later = ui.button("Later").on_hover_text("It installs when you quit FieldCraft").clicked();
});
}
Finish::Package { file, command } => {
ui.label(format!("FieldCraft {} is downloaded and verified:", staged.version));
ui.label(RichText::new(file.display().to_string()).monospace().size(11.0));
ui.label("Install it with your system's package manager:");
let mut text = command.clone();
ui.add(egui::TextEdit::singleline(&mut text).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY));
ui.add_space(6.0);
ui.horizontal(|ui| {
open = ui.button("Open with Installer").clicked();
later = ui.button("Close").clicked();
});
}
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
later = true;
}
if open && let Finish::Package { file, .. } = &finish {
let opened = app.updates.as_ref().map(|u| (u.host.open_file)(file));
if let Some(Err(e)) = opened {
app.set_status(format!("Could not open the installer: {e}"), true);
}
app.prompt = None;
}
if restart {
app.answer_update(true);
} else if later {
app.answer_update(false);
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc;
use fieldcraft_engine::Session;
use fieldcraft_update::{Install, Version};
use serde_json::json;
use super::*;
use crate::files::{PrefsStore, Services};
const DAY: u64 = fieldcraft_update::CHECK_INTERVAL;
fn release(v: &str) -> Release {
Release::from_github(&json!({"tag_name": format!("v{v}"), "html_url": "https://example.invalid/r", "assets": [
{"name": "SHA256SUMS.txt", "browser_download_url": "https://example.invalid/s"},
{"name": "SHA256SUMS.txt.minisig", "browser_download_url": "https://example.invalid/m"},
]}))
.unwrap()
}
/// An app with a host that records the jobs it is given; the sender plays the host's thread.
fn app(prefs: &Path, now: u64, automatic: bool) -> (ModelApp, Rc<RefCell<Vec<Job>>>, mpsc::Sender<Event>) {
let jobs = Rc::new(RefCell::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let j = jobs.clone();
let host = UpdateHost {
unsupported: None,
automatic,
start: Box::new(move |job| j.borrow_mut().push(job)),
events: rx,
now: Box::new(move || now),
open_file: Box::new(|_| Ok(())),
};
let a = ModelApp::new(Session::default())
.with_services(Services { prefs: Some(PrefsStore::file(prefs.to_path_buf())), ..Default::default() })
.with_updates(host);
(a, jobs, tx)
}
fn temp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fieldcraft-updates-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("prefs.json")
}
/// At most one automatic check a day, none when the user turned them off or the run does
/// not allow them, and none while the first run's question is open.
#[test]
fn automatic_checks_are_daily_and_none_when_off() {
let ctx = egui::Context::default();
let file = temp("auto");
let (mut a, jobs, _tx) = app(&file, 10 * DAY, true);
a.poll_updates(&ctx);
a.poll_updates(&ctx);
assert_eq!(*jobs.borrow(), [Job::Check { manual: false }], "once per run");
// The next run on the same day does not check; a day later it does.
let (mut a, jobs, _tx) = app(&file, 10 * DAY + 3600, true);
a.poll_updates(&ctx);
assert!(jobs.borrow().is_empty());
let (mut a, jobs, _tx) = app(&file, 11 * DAY, true);
a.poll_updates(&ctx);
assert_eq!(jobs.borrow().len(), 1);
// Off in Settings: no network call, ever; Check for Updates still asks.
a.set_update_prefs(false, None);
let (mut a, jobs, _tx) = app(&file, 30 * DAY, true);
a.poll_updates(&ctx);
assert!(jobs.borrow().is_empty(), "off means no check");
a.check_for_updates();
assert_eq!(*jobs.borrow(), [Job::Check { manual: true }]);
// A run that may not check automatically (CI, automation) does not.
let (mut a, jobs, _tx) = app(&temp("ci"), 30 * DAY, false);
a.poll_updates(&ctx);
assert!(jobs.borrow().is_empty());
// Not while the first run's question is open; after it is answered.
let (mut a, jobs, _tx) = app(&temp("consent"), 30 * DAY, true);
a.prompt = Some(Prompt::Consent);
a.poll_updates(&ctx);
assert!(jobs.borrow().is_empty());
a.prompt = None;
a.poll_updates(&ctx);
assert_eq!(jobs.borrow().len(), 1);
// Without a host (the browser, tests) there is nothing to call.
let mut plain = ModelApp::new(Session::default());
plain.poll_updates(&ctx);
assert!(!plain.updates_available());
}
/// A newer release is offered (unless skipped, when the check was automatic); a manual
/// check says when FieldCraft is up to date or why the check failed.
#[test]
fn checks_offer_newer_releases_and_skip_skipped_ones() {
let ctx = egui::Context::default();
let (mut a, _jobs, tx) = app(&temp("offer"), DAY, false);
let current = Version::parse(crate::VERSION).unwrap();
tx.send(Event::Checked { manual: false, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.update_offered().map(|r| r.version.to_string()), Some("99.0.0".into()));
a.skip_update();
assert_eq!((a.update_offered(), a.prefs.update_skip.as_str()), (None, "99.0.0"));
tx.send(Event::Checked { manual: false, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.update_offered(), None, "skipped");
tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap();
a.poll_updates(&ctx);
assert!(a.update_offered().is_some(), "asked for by hand");
tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release(crate::VERSION) }) }).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.status.as_ref().map(|s| s.text.clone()), Some(format!("FieldCraft {} is up to date", crate::VERSION)));
assert_eq!(a.update_offered(), None, "the earlier offer goes");
tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap();
a.status = None;
a.poll_updates(&ctx);
assert!(a.status.is_none(), "an automatic check fails silently");
tx.send(Event::Checked { manual: true, result: Err("offline".into()) }).unwrap();
a.poll_updates(&ctx);
assert!(a.status.as_ref().is_some_and(|s| s.error && s.text.contains("offline")));
}
/// Install downloads first; restarting asks about unsaved changes, then closes the window
/// and hands the staged release to the host's exit.
#[test]
fn installing_asks_to_restart_and_saves_first() {
let ctx = egui::Context::default();
let dir = temp("install");
let (mut a, jobs, tx) = app(&dir, DAY, false);
let current = Version::parse(crate::VERSION).unwrap();
tx.send(Event::Checked { manual: true, result: Ok(Check { current, latest: release("99.0.0") }) }).unwrap();
a.poll_updates(&ctx);
a.install_update(&ctx);
assert_eq!(jobs.borrow().last(), Some(&Job::Stage(release("99.0.0"))));
let staged = Staged {
install: Install::TarGz { root: "/opt/fieldcraft".into() },
version: Version::parse("99.0.0").unwrap(),
dir: "/opt/.fieldcraft-update-99.0.0".into(),
payload: "/opt/.fieldcraft-update-99.0.0/new/x".into(),
entries: vec![],
};
tx.send(Event::Staged(Ok(staged.clone()))).unwrap();
a.poll_updates(&ctx);
assert_eq!(a.prompt, Some(Prompt::Update));
// Unsaved work: asked first; Cancel keeps the window open.
let _ = a.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}));
a.answer_update(true);
assert_eq!(a.prompt, Some(Prompt::Unsaved(Replace::Update)));
a.answer_unsaved(crate::files::Answer::Cancel);
a.poll_updates(&ctx);
assert!(!a.quit_requested);
a.prompt = Some(Prompt::Update);
a.answer_update(true);
a.answer_unsaved(crate::files::Answer::Discard);
a.poll_updates(&ctx);
assert!(a.quit_requested, "the window closes");
assert_eq!(a.take_staged_update(), Some(staged));
// A copy that cannot install itself opens the release page instead of downloading.
let (mut a, jobs, tx) = app(&temp("dev"), DAY, false);
a.updates.as_mut().unwrap().host.unsupported = Some("a development build".into());
tx.send(Event::Checked { manual: true, result: Ok(Check { current: Version::parse(crate::VERSION).unwrap(), latest: release("99.0.0") }) })
.unwrap();
a.poll_updates(&ctx);
a.install_update(&ctx);
assert!(jobs.borrow().iter().all(|j| !matches!(j, Job::Stage(_))));
assert!(a.status.as_ref().is_some_and(|s| s.text.contains("development build")));
a.reinstall();
assert!(jobs.borrow().iter().all(|j| *j != Job::Reinstall));
}
}
+78 -3
View File
@@ -13,13 +13,16 @@
//! click selects it (Shift adds, Cmd/Ctrl adds or removes, a click on nothing clears), Escape
//! clears. The selection bar (top centre) chooses what is picked and turns on box selection,
//! where a left drag selects the visible nodes, element faces or elements inside the box. A
//! right click offers a set made from the selection.
//! right click offers a set made from the selection, and on a datum or a partition's cut face
//! that item's menu ([`crate::menu`]), as its browser row does.
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
use fieldcraft_render::{StandardView, Vec3};
use crate::browser::Def;
use crate::gpu::{GpuScene, ViewportCallback};
use crate::icons::{self, Look};
use crate::menu::{self, Target};
use crate::navigation::{self, NavButton};
use crate::selection::{Filter, Mode};
use crate::theme::Tokens;
@@ -89,11 +92,20 @@ fn overlays(app: &mut ModelApp, ui: &egui::Ui, rect: Rect, t: &Tokens) -> Vec<Re
out
}
/// The Start card's largest size; a smaller viewport shrinks it and its samples scroll.
const START_W: f32 = 820.0;
const START_H: f32 = 620.0;
/// The margins the view cube and the navigation bar keep.
const START_RIGHT: f32 = 130.0;
const START_BOTTOM: f32 = 56.0;
/// The empty viewport: open a file or a sample (the samples are ready to solve).
fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
let mut open = None;
let mut pick = false;
let area = Rect::from_center_size(rect.center(), vec2(440.0_f32.min(rect.width() - 24.0), 300.0_f32.min(rect.height() - 24.0)));
// Clear of the view cube (top right) and the navigation bar (bottom centre).
let free = Rect::from_min_max(rect.min + vec2(12.0, 12.0), rect.max - vec2(START_RIGHT, START_BOTTOM));
let area = Rect::from_center_size(free.center(), vec2(START_W.min(free.width()), START_H.min(free.height())));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min)));
egui::Frame::NONE.fill(t.overlay).corner_radius(6.0).inner_margin(egui::Margin::same(14)).show(&mut child, |ui| {
ui.label(egui::RichText::new("Start").size(16.0).color(t.overlay_text));
@@ -102,7 +114,9 @@ fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
pick = ui.button("Open…").clicked();
ui.add_space(8.0);
ui.label(egui::RichText::new("Samples").color(t.overlay_text));
crate::files::sample_rows(ui, t, &mut open);
// The grid takes what the card has left and scrolls the rest.
let left = (area.bottom() - ui.cursor().top() - 14.0).max(60.0);
crate::samples::grid(ui, t, left, &mut open);
});
if pick {
app.open_dialog(None);
@@ -179,8 +193,69 @@ fn hover_label(app: &ModelApp, painter: &egui::Painter, t: &Tokens) {
}
/// The right-click menu: a set from the selection, clearing it, and the filter.
/// Most screen distance (points) from a datum's drawn lines at which a right click is on it.
const DATUM_REACH: f32 = 6.0;
/// The datum drawn under window point `at`: the nearest shown datum whose lines pass within
/// [`DATUM_REACH`].
fn datum_at(app: &mut ModelApp, at: Pos2) -> Option<String> {
if app.plot().is_some() || !app.ui.show_geometry {
return None;
}
let bounds = app.display().bounds;
let colors = Tokens::get(app.ui.dark).glyph_colors();
let names: Vec<String> =
app.session.model.datums().keys().filter(|n| app.session.shows(fieldcraft_engine::ItemKind::Datum, n)).cloned().collect();
let mut best: Option<(f32, String)> = None;
for name in names {
let mut drawn = fieldcraft_render::Scene::default();
fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, None, &colors, &mut drawn);
for l in &drawn.lines {
let (Some(a), Some(b)) = (to_screen(app, l.a), to_screen(app, l.b)) else { continue };
let xy = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
let d = fieldcraft_render::pick::segment_distance(xy(at), xy(a), xy(b)).0 as f32;
if d <= DATUM_REACH && best.as_ref().is_none_or(|(e, _)| d < *e) {
best = Some((d, name.clone()));
}
}
}
best.map(|(_, n)| n)
}
/// The partition whose cut face is under window point `at` (`partition:<name>:a|b`).
fn partition_at(app: &mut ModelApp, at: Pos2) -> Option<String> {
let rect = app.viewport.rect.filter(|r| r.contains(at))?;
let proj = app.projector()?;
let local = [f64::from(at.x - rect.left()), f64::from(at.y - rect.top())];
let item = app.with_picker(|p| p.pick(&proj, local, Filter::Faces))?;
let crate::selection::Item::Geometry(g) = item else { return None };
let name = g.entity.name()?.strip_prefix("partition:")?.split(':').next()?.to_string();
app.session.model.partitions().iter().any(|p| p.name == name).then_some(name)
}
/// What a right click at `at` offers its item menu for: a datum drawn there, else a partition
/// whose cut face is there.
pub(crate) fn menu_target(app: &mut ModelApp, at: Pos2) -> Option<Target> {
if let Some(name) = datum_at(app, at) {
return Some(Target::Def(Def::Datum, name));
}
partition_at(app, at).map(|name| Target::Def(Def::Partition, name))
}
fn context_menu(app: &mut ModelApp, response: &egui::Response) {
if response.secondary_clicked() {
app.viewport.menu_target = response.interact_pointer_pos().and_then(|at| menu_target(app, at));
}
response.context_menu(|ui| {
if let Some(target) = app.viewport.menu_target.clone() {
let Target::Def(def, name) = &target else { return };
ui.label(egui::RichText::new(format!("{} {name}", def.label())).strong());
if let Some(action) = menu::show(app, ui, &target) {
menu::apply(app, target, action);
return;
}
ui.separator();
}
let has = !app.pick.selection.is_empty();
if ui
.add_enabled(has, egui::Button::new("Create set from selection…"))
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more