Compare commits

...
352 Commits
Author SHA1 Message Date
Christian Svensson 8201f9a954 docs: a flow-past gallery clip and solved still
Add the start-up-transient clip and a solved still of the flow-past sample to
the examples gallery: air flowing past the L-bracket, the wake forming behind
the upright and the drag settling over the 16 s run (it does not shed; that
needs a finer grid or a collision operator stable near the lattice's stability
limit, #557).

tools/flow-clip/make.py regenerates them: it drives the desktop app over the
control channel, solves the lattice Boltzmann step with a dense frame output,
renders the model-only viewport at each frame with a fixed legend clamp
(results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each
frame captioned with the elapsed flow time. The geometry is the sample's own
upright-bracket recipe; nothing comes from a commercial product.

Part of #164.
2026-10-18 10:15:45 -04:00
Christian Svensson 6996fd71b0 engine: animate a fluid step's frames
results.animate refused fluid steps because nothing deforms. A fluid step
instead plays its frames over time, the start-up transient from the field at
rest to the settled flow, the way an explicit step plays its frames. The
animation clock maps across the fluid frames once per cycle, and the fluid
results panel gains an Animate control beside its frame slider.

Part of #164.
2026-10-18 10:15:45 -04:00
jyvikoandClaude Opus 5.5 7fdb348d05 ci: draft pull requests start no run; marking one ready does
CI and the packaging lint listen for ready_for_review too, and their
pull-request jobs skip drafts, so a contributor's work in progress no
longer takes runners from develop and the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 09:12:50 -04:00
Christian Svensson db561be51b engine: report principal stresses in solve.probe
A static step's probe gives every node its principal stresses, largest
first, beside its stress and von Mises: the eigenvalues of the same
nodal stress, from the same function the principal stress contours use.
An oracle quantity reads one number at a JSON pointer, so a benchmark
whose target is a principal stress (NAFEMS LE6) needs it in a reply.

Closes #574. Part of #97.
2026-10-18 08:31:53 -04:00
Christian Svensson a8040063a6 tests: add the NAFEMS LE7 cylinder/sphere oracle case
The thin-walled cylinder with a hemispherical cap under internal
pressure as four axisymmetric quad8 blocks: axial stress on the outer
surface at D 25.283.

The NAFEMS target, 25.86, is the axisymmetric thin-shell solution; our
solid model converges to 25.27, the value ESRD reports for its converged
axisymmetric solid. So the NAFEMS quantity keeps a 1% tolerance and is
a known failure until there is an axisymmetric shell element, and a
second quantity checks the model against the solid reference (pass,
0.05%).

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 65066b8451 tests: add the NAFEMS LE5 Z-section oracle case
The Z-section cantilever under an end torque as five hex20 blocks (two
flanges, two corner blocks, the web; two elements through every wall):
axial stress at A, on the mid-surface of the lower flange at its free
edge, -109.45 against -108 (2%).

The solid model converges to about -109.7, 1.6% beyond the thin-walled
reference, as ESRD's converged p-version solid (-109.5) does. The README
also records the MITC4 mid-surface shell model of the same problem,
which converges to about -111.3 (3.1%), and why the case is the solid.

Part of #97.
2026-10-18 08:31:48 -04:00
Christian Svensson 68aff646b2 tests: add NAFEMS LE2 and LE3 shell oracle cases
LE2, the cylindrical shell bending patch test, in its two load cases
(edge moment; pressure with the balancing edge traction) on an 8 × 8
MITC4 patch: tangential stress at E 59.936 and 59.904 against 60.

LE3, the hemispherical shell with point loads, on a 32 × 32
longitude-latitude mesh (planar quad4 cells, tri3 at the pole): radial
displacement at A 0.18418 against 0.185.

Both meshes are written node by node from the generator each README
lists, with the refinement that supports its tolerance. Targets are
transcribed from the reproductions of TNSB Rev. 3 in the Abaqus 2016
Benchmarks Guide and the ESRD StressCheck NAFEMS guide.

Part of #97.
2026-10-18 08:31:48 -04:00
jyvikoandClaude Opus 5.5 853131e5a1 docs: claiming with /claim, labels and triage; attribute the bots to SolidCraft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 50899d8d5d ci: run the claim and triage bot tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 619b1936d3 github: triage bot, area and type labels, needs-triage, /triage and needs-info
Adapted from SolidCraft's triage bot: PR paths and issue titles map to
FieldCraft's area:* labels, the issue forms give the type, /triage
understands labels with spaces (high, S/M/L, in-progress), and short
bug reports without steps are asked once and closed after 14 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 9fce2f7bab github: claim bot, /claim, /unclaim, PR references and a 14-day sweep
Adapted from SolidCraft's claim bot to FieldCraft's rules: a claim
assigns and labels claimed, only help wanted / good first issue issues
are open to contributors, in progress is the core team's and can't be
claimed, and no visible progress for 14 days frees the issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
jyvikoandClaude Opus 5.5 15f9b7aa6e agents: allow JavaScript for the GitHub bots run by actions/github-script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-18 07:35:09 -04:00
Christian Svensson 6b87dc508d engine: report a held face as the temperature extreme to scripts
The results reply named the fluid's 79.69 °C cell as the maximum while the
app showed the face held at 80 °C. It now names that face, by body and
face as for wall heat, so a script reads what the legend shows.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson 7e3eaa14f2 engine: draw an obstacle face held at a temperature at that temperature
The coloured obstacle surface samples the fluid beside each face, and
the solid cells carry no temperature, so a face held at 80 °C took the
mean of the fluid cells around it, which mixes in the cooler ones further
out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A
face whose thermal condition holds it at a temperature is now drawn at
that value, and the legend's range reaches it (the fluid never quite
does). Adiabatic and flux faces keep showing the fluid beside them.

Part of #157.
2026-10-18 06:02:18 -04:00
Christian Svensson c61a8b9a4d mesh: name the threshold test for what it asserts
The 135-degree vertex is no longer a corner, so the test is named that way.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson c3c764c996 mesh: an angle exactly on the 135-degree threshold is not a corner, whichever way it rounds
A region's corners are its interior angles below 135 degrees, and an
O-grid's 45-degree cuts meet straight sides at exactly that angle. The
computed angle then rounds either way with the last bits of its points,
which differ between platforms' sin and cos and between tessellations:
one ulp in three points of the slot test's arcs, Windows' sin against
glibc's, turned a mapped region into a triangle and the whole blocking
into a different one with a corner scaled Jacobian of 0.375 for 0.707.
Angles within 1e-9 rad of the corner and reflex thresholds now count as
on them, and on a threshold is smooth (neither a corner nor reflex),
which keeps the slot's intended O-grid of 9 regions and 848 quads on
Linux, macOS and Windows. The region classification and the O-grid's
own corner tests share the rule, the slot test pins the blocking, and
its arc helper uses the mesher's portable trig.

Part of #556.
2026-10-18 06:02:13 -04:00
Christian Svensson e9ccf10692 engine: add a small heated flow sample
Demonstrate temperature slices and signed face-average wall heat with cool air passing an isothermal bracket. Register the original CPU-rendered gallery thumbnail and document the coarse transient setup.

Part of #157.
2026-10-17 14:08:50 -04:00
Christian Svensson 1e5ff08c5f engine: show temperature and wall heat in fluid results
Route saved temperature through slices, obstacle surfaces, streamlines and both result views, with availability and model units shared by commands and the UI. Show signed obstacle heat histories per complete face area at the selected frame, leaving unrecorded times uncoloured and retaining the saved slice settings.

Part of #157.
2026-10-17 14:08:50 -04:00
jyvikoandClaude Opus 5.5 8f8124dfa1 release: version 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 13:50:17 -04:00
jyvikoandClaude Opus 5.5 1f2446935f ci: run develop's Linux jobs on the self-hosted runner when SELF_HOSTED_RUNNER is set
Pushes, the schedule and Run workflow go to the runner labelled by the
repository variable SELF_HOSTED_RUNNER (tangency-labs/ci-runner, label
fieldcraft-ci); pull requests always stay on GitHub's runners, so fork
code never reaches it. Unset the variable and every job is back on
ubuntu-24.04. macOS and Windows jobs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 11:32:58 -04:00
jyvikoandClaude Opus 5.5 1daee9d969 docs: ask for pictures in issues and pull requests
The bug template's Evidence asks for a screenshot or recording first,
marked up, and a section for meshes; the feature template gains a
Pictures field; the pull request template and CONTRIBUTING ask for
before/after pictures of any visible change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:57:58 -04:00
Christian Svensson 8cd37e0a68 gpu: take wgpu's backend options from its environment variables
The adapter selection built its wgpu instance with default backend
options, so WGPU_DX12_COMPILER had no effect: on DX12 there was no way to
choose between FXC and DXC when measuring them (#561: FXC takes ~8.5 s
to create the GPU solver's pipelines and runs it at half of Vulkan's
speed, DXC 0.5 s and full speed). The options now come from wgpu's own
environment variables. staticdxc is refused with an error: it needs
wgpu's static-dxc feature, which FieldCraft does not build, and wgpu
would panic on it.

Part of #561.
2026-10-17 10:45:13 -04:00
Christian Svensson 60fd0998f5 lbm-gpu: stream in place with the AA pattern
The collide and stream passes each moved the whole population field
through two buffers (304 bytes per cell update). The AA pattern (Bailey
et al. 2009; Wittmann et al. 2013) keeps one buffer: even steps collide
each cell in place with its directions swapped, odd steps read a cell's
incoming populations at its neighbours and write the outgoing ones where
the next step reads them, so each thread writes the addresses it read
and streaming happens inside the collision (152 bytes). A cell's
coordinates are computed once and neighbours wrap with a compare and an
add. The per-link wall rules gather their sources before a scatter
writes them, so no link overwrites another's post-collision value, and
the momentum exchange is taken in the gather of the step whose forces
are read back.

Options::streaming picks the layout; Auto, what a solve uses, streams
in place, which measured faster on every device tried: 1.5x on an RTX
4090, 1.85x on the GB10 and 1.45x on an M4 Max at periodic 128^3.
Options::profile times each kernel with timestamp queries for the
throughput test, which now runs both layouts. The verification tests
run in both layouts, with two new ones comparing fields, mass, forces
and heat with the CPU after odd and even step counts across submission
boundaries.

Part of #562.
2026-10-17 10:45:09 -04:00
Christian Svensson 74dce59c7d lbm-gpu: sum the Zou-He deviations before adding the weights
The open boundaries summed the tangential and outgoing populations as
deviations from their weights plus the weights (2/3 and 1/6), in single
precision; at low speed the weights round away the deviations' last
digits. A low-speed case through an inlet, an outlet and a slip face
came out 2.26e-5 of the largest speed from the CPU under DXC, against
the 2e-5 the tests allow. The deviations are now summed first and the
total weight, exactly 1, added once: the same Zou-He rule, so results
change only at the round-off level.

Part of #562.
2026-10-17 10:45:09 -04:00
jyvikoandClaude Opus 5.5 c1941ad349 ui: Move Nodes prepares once the form settles and skips large regions
A changed form (Alt + scroll on the radius) is prepared 150 ms after it last
changed, the sphere drawn meanwhile; a region of more than 1000 nodes is not
previewed and moves on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 8a178879ee engine: mesh.node.move's live preview, capped before its costly part
prepare_capped counts the nodes that would follow and stops before the design's
faces and the harmonic solve when they are more than a cap; preview_live
reuses the region's baseline and guards more coarsely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 1dd2afe525 mesh: a node move measures its region in parallel and can reuse its baseline
move_nodes_with takes the region's quality before the move from an earlier run
and a coarser guard bisection, for a live preview; each pass measures the
elements in parallel (fixed blocks). The relaxation's state sizes its elements
(a certified check each) only for the strong mode, which alone uses the size:
on the crank arm's hex20 mesh that was 114 ms of every 130 ms preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 53a370ed01 ui: Move Nodes icon shows the grid and the node's new position over it
The original 2×2 grid in the line colour, the moved node and its four
edges overlaid in the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 ee09ff2fca ui: Move Nodes tool: drag nodes in the view with a ghost and quality colours
With the form open a drag on the mesh grabs the node under the press and
moves it in the screen plane; Alt + scroll sizes the radius. The view draws
the original edges as a ghost under the moved ones, coloured by how each
element's scaled Jacobian changed, and the sphere; the form shows the region's
numbers live. Release runs mesh.node.move (one undo step), Esc cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 711e51074c engine: mesh.node.move moves nodes by a vector or to a point
The grabbed nodes (listed, on B-rep faces, or a sphere's) move; the nodes
within the radius follow by a falloff or harmonically; nodes stay on the
geometry as Relax Nodes moves them; the move is scaled back until no element
inverts. The reply gives the region's scaled Jacobian before and after and the
elements that got worse and better. One undo step. Toolbar entry Move Nodes
(Mesh, Improve) with its own icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
jyvikoandClaude Opus 5.5 e52eda728f mesh: move nodes by hand, with falloff or harmonic followers and an inversion guard
relax::move_weights gives each node its share of a displacement (the grabbed
nodes all of it, the nodes within the radius strength x falloff, or the
harmonic function of the element-edge graph, zero beyond the radius and at
held nodes); relax::move_nodes applies it as the relaxation's constraints let
each node move (faces in UV, edges along, vertices held), scaled back by
bisection until no valid element inverts, and reports each element's scaled
Jacobian before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 10:35:53 -04:00
Christian Svensson 99b0b0cfa4 lbm-gpu: the temperature lattice on the GPU
The D3Q7 scalar lattice steps on the device after the flow's step, in
its own A–B pair of buffers: BGK collision with the velocity the
momentum collision leaves in the moments buffer, pull streaming with
the links' rules read through each group's thermal condition
(anti-bounce-back for a held temperature, bounce-back for an adiabatic
face, a flux added to the bounced population), inlets at their held
temperature and outlets copying their neighbour. The populations are
stored as deviations from w_q T₀ so single precision keeps the digits
of the differences. The heat each link exchanged goes into the fourth
component of the momentum-exchange contributions and the group sums;
the buoyant acceleration −β g θ is read per cell in the collision, the
open boundaries and the moments. A temperature that stops being a
number ends the chunk with Error::Unstable like the flow's checks.

Flow exposes what the backend needs to build the device state (the
thermal settings and populations).

Verification against the CPU on lavapipe: plates held at 20 and 21
(temperatures within 1.4e-5 of the range, the wall heats within 6e-5
and both against k ΔT A / H), a heated channel with a parabolic inlet
carrying its temperature, a flux wall, a held wall and an outlet
(temperature 3e-7, heat 1e-7) and a buoyant cavity at Ra 10³
(velocity 1.3e-6, temperature 6e-7, heat 2.4e-6). The tests now fail
on a shader that does not compile instead of skipping; only a missing
device skips.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson 190f958b23 engine: heat in a fluid step
A fluid domain takes `thermal` settings (the initial temperature, a
thermal condition per box face and per obstacle face, gravity with the
expansion coefficient for buoyancy) in model.fluid_domain.create and
.edit, and model.fluid_domain.thermal_boundary sets one boundary's
condition as model.fluid_domain.boundary does for the flow. A material
a domain with heat uses keeps needing its conductivity and specific
heat when it is replaced.

solve.fluid's summary adds the thermal relaxation time, the fluid's
temperature range and the heat into every wall (final and the mean
over the last quarter of the history); solve.probe gives the
temperature in the cells at points and, with `heat`, the walls' heat
histories. The form, the catalogue and docs/features.md follow.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson 939b64e660 lbm: a temperature lattice on the flow
A D3Q7 passive scalar rides on the D3Q19 flow: it is advected by the
cell velocity, diffuses with the fluid's thermal diffusivity (the
relaxation time follows from k / (rho c)), and feeds back into the
momentum through a per-cell Boussinesq force when the domain has an
expansion coefficient and gravity. Walls and obstacle faces hold a
temperature (anti-bounce-back), a heat flux (added to the bounced
population) or are adiabatic (bounce-back); velocity inlets carry the
held temperature in, pressure outlets copy their neighbour.

The heat crossing each face group is accumulated during streaming and
reported in physical units (W) in the result's heat histories, next to
the force histories; frames gain a temperature field and the stats
report the thermal relaxation time.

Closed-form checks: linear conduction between held plates, a flux wall
against q H / k with the wall's heat balance, an inlet temperature
carried through a plug flow, and a periodic blob that conserves its
total and returns after one lap. The de Vahl Davis cavity (Ra 1e3 and
1e4 against the published Nusselt numbers) is there as an ignored test
because it takes minutes.

The GPU backend declines domains with heat for now and asks for the
CPU; its kernels follow.

Part of #157.
2026-10-17 08:32:30 -04:00
Christian Svensson ea284caf80 model: heat in a fluid domain
A fluid domain may carry thermal settings: the initial temperature,
gravity and the fluid's expansion coefficient for buoyancy, and a thermal
condition (adiabatic, held temperature, heat flux) on each box face and on
named obstacle faces, adiabatic unless told otherwise. The material then
needs an isotropic conductivity and a specific heat. Boundaries are set
one at a time like the flow ones.

Part of #157.
2026-10-17 08:32:30 -04:00
jyvikoandClaude Opus 5.5 7d419b73f4 mesh: the tet report carries the scaled Jacobian; both metrics tested on the same tets (#547)
The dihedral range and mesh.quality's scaled Jacobian were both right:
they are computed by the same function on the same final corners (tet4
and tet10 give identical numbers), but a dihedral range does not bound
the corner scaled Jacobian. A needle has dihedral angles near 60 and 90
degrees and a nearly flat apex corner. Before 417c0c88 sphere-on-plate
at size 4 had dihedrals 36.3-141.6 and 106 needles below SJ 0.2 (min
0.095); now 36.0-137.6 and min SJ 0.351.

The mesher's report now carries min_scaled_jacobian so it cannot read
as all-good while needles remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:09 -04:00
jyvikoandClaude Opus 5.5 e315b2bef7 docs: the plan's apply, the cells mesher and the partition are jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 2729511cb5 ui: mesh.plan.apply, mesh.cells and geometry.partition run as jobs (#545)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 b5f71612af mesh: mesh.plan.apply, mesh.cells and geometry.partition report their stages and pass a cancel through
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
jyvikoandClaude Opus 5.5 b2fcf2c5bc geometry: partition cuts and the projection mesher report progress and stop when cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 08:12:04 -04:00
Christian Svensson 8dce4b14ce engine: the flow test fixture names its backend
`FluidStats` gained `backend` in #535 and the fixture of the flow drawing
tests arrived with #550; merged one after the other, the fixture lacks the
field and `cargo test -p fieldcraft-engine` no longer compiles on develop.

Part of #157.
2026-10-17 08:12:01 -04:00
Erkin Alp Güney 18be60672b verification: activate the two shell benchmark cases (#106)
The Scordelis-Lo roof and the pinched cylinder had their published
references cited but no recipe, because mesh.block's 2D form is planar
and cannot build a curved patch. Both are now active: the mesh is
written node by node (mesh.node.add / mesh.element.add, the route
docs/verification.md allows), the same cylinder patch the solver's own
benchmarks.rs builds in code, with the quarter/eighth symmetry planes,
the rigid diaphragm and the published load.

- scordelis-lo-roof-shell: 16x16 flat quad4 (MITC4) shells, R = 25,
  t = 0.25, self-weight 90 per area; u_z at the free-edge mid-span
  -0.3024 within 2% (ours -0.29901009815961804, 1.12% low).
- pinched-cylinder: 32x32 flat quad4 shells, R = 300, t = 3, a quarter
  of the unit pinch; u under the load -1.8248e-5 within 2% (ours
  -1.803845342860719e-05, 1.15% low).

Each README carries the generator (so the recipe is reproducible), the
refinement study the tolerance reason cites, and how the reference was
cited before. pinched-hemisphere and mh-twisted-beam now say what they
actually wait for: a doubly curved or helical shell mesh written node
by node, not missing shell elements.
2026-10-17 07:25:44 -04:00
Christian Svensson a0c7d6ce9d ui: show fluid results in the viewport with the part, legend and controls
The window built a deformed copy of the model for every results plot,
which drops the geometry surfaces, so after a fluid solve the bracket
vanished and nothing else was drawn.

A fluid step's plot keeps the part as it is and draws the engine's flow
display through it (the same scene as the headless pictures): the
field on a slice, the part coloured by the flow beside it and
streamlines. The legend shows the field with its bands and the cells
of its extremes (Max/Min mark and turn the view to them); the results
panel swaps the deformation rows for the slice's normal and position,
the obstacles' colours, the streamlines and a frame slider, all
running the results.* commands. The status line describes the new
commands.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson f7dcd46b2f engine: draw fluid results as slices, coloured obstacles and streamlines
After solve.fluid the results view had nothing to draw: a fluid step's
fields are per cell, the view only knew nodal fields, and the deformed
display it built dropped the geometry surfaces, so even the part
vanished.

A fluid step now has its own display (`engine::flow`, on render's
flow display data), shared by the headless pictures and the window:
the part drawn as it is, its faces coloured by the field in the fluid
beside them, the field on a slice through the domain and streamlines
seeded on the inlet face upstream of the obstacles, coloured by speed
when the legend shows velocity. It is first shown with velocity
magnitude on a slice through the middle, normal to the horizontal axis
across the flow.

New commands: results.slice {step, field: velocity|pressure, axis:
x|y|z|none, position, frame}, results.surface {step, show} and
results.streamlines {step, show, count, max_steps, seeds | line,
frame}, which replies with what it traced. results.contour takes
velocity and pressure for fluid steps (a new `pressure` field) with
the usual bands and legend clamp, and results.frame walks a fluid
step's frames. Streamlines leave the coming-soon list. Hostile
parameters are refused, and the counts are capped.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson 0d30ddb747 render: slices, surface values and streamlines of cell flow fields
A fluid step's result is a field per cell of a regular grid, not per
node, so the contour machinery had nothing to draw it with and the
viewport showed nothing after a fluid solve.

Add `render::flow`, CPU display data for such fields with no solver
dependency (render stays at L1): an axis-aligned slice through the
cells as triangles carrying cell-centred values (interpolated between
the two layers either side, outer centres moved to the domain faces,
cells inside obstacles left out), the field sampled at a surface
beside the fluid cells for colouring an obstacle, and streamlines
traced through the trilinear velocity field by the midpoint rule,
seeded on a lattice upstream of the obstacles. Seeds, steps and the
total number of points are capped so hostile counts cost a bounded
time. `contour::banded` fans band pieces into scene triangles for the
callers.

Part of #193.
2026-10-17 07:25:04 -04:00
Christian Svensson 1e0c21d088 lbm-gpu: one atomic per workgroup for the chunk's largest speed
Every cell did an atomicMax on the same word; a workgroup reduction first
leaves one per 256 cells. Same result, less contention on a single
address.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson 48cc80bef5 solve.fluid: backend "cpu" (the default) or "gpu"
The CPU stays the default, so every existing result and fixture is
bit-identical; "gpu" steps on the device FIELDCRAFT_GPU picks through the
registered backend, and is an error when the build has none. The desktop
app and the CLI (hence the MCP server) register fieldcraft-lbm-gpu at
start; the summary names what stepped. Hostile values are errors.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson b123f31d47 lbm-gpu: the lattice Boltzmann step on the GPU, checked against the CPU
A new L7 crate: the CPU scheme's collision (TRT with Guo forcing), the
per-link streaming rules (bounce-back with Ladd's term, slip reflection,
open placeholders), the Zou–He boundaries and the momentum exchange per
force group as wgpu compute shaders, stepping the flow fieldcraft_lbm's
Flow prepared, through its Backend trait: populations stay on the device,
a chunk of steps is one submission, and only frames, forces and the
stability check come back. Populations are stored as deviations from
their weights, which keeps single precision honest: the verification
cases (Poiseuille with Λ = 3/16, Couette with wall forces, Taylor–Green,
a sphere's drag, a Zou–He channel) agree with the CPU to 2–5e-6 in
velocity, 1e-6 in density and 5e-5 in force, with those tolerances stated
in the tests. The rules are applied one thread per link rather than in a
per-cell loop, which lavapipe's JIT miscompiles (and which diverges on
real GPUs anyway). Native only for now; the browser registers nothing.

Part of #529.
2026-10-17 07:23:40 -04:00
Christian Svensson 944a54c76b lbm: a Backend trait steps the flow, with the CPU scheme as its reference
run() drives a Backend in chunks of steps (to the next frame, history sample
or progress check) instead of the CPU Flow directly, so a device backend
registered by an application can step the same prepared flow: Flow keeps
building the grid, links and open cells and exposes them read-only, and
implements the trait itself. solve_fluid_on picks the CPU or the registered
device backend; solve_fluid stays the CPU and is unchanged in its results.

Part of #529.
2026-10-17 07:23:40 -04:00
jyvikoandClaude Opus 5.5 417c0c8897 mesh: the tet objective sees needles; no tet below scaled Jacobian 0.3 on the skill's tet parts
The improvement objective was the smallest dihedral sine, which misses
needles: every dihedral angle fine while a corner is nearly flat, so
the corpus meshes had scaled Jacobians down to 0.01-0.07. The objective
is now the smaller of the dihedral sine and the scaled Jacobian at the
worst corner (the measure analysts check, Knupp 2001), weighted so a
Jacobian of 0.3 (the skill's limit) counts as a 30 degree dihedral
angle (the schedule's target): the Jacobian decides only near that
limit. The existing edge removal, multi-face removal, smoothing and
insertion then remove the needles.

Skill tet10 min SJ: sphere-on-plate 0.092 -> 0.335, filleted-bracket
0.021 -> 0.318 (both pass, nothing marked). Default-size tet10:
thin-ribs 0.240, shallow-groove 0.321, rounded-box 0.311.

The strong-relaxation test relaxes below 0.45: nothing is left below
0.3. Literature note in docs/meshing/literature.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:51:20 -04:00
jyvikoandClaude Opus 5.5 d7444edac7 solver: a tie row nearly repeated on a held secondary node is dropped
Where a tie's secondary surface meets a boundary condition or an
axis-aligned symmetry plane (a crosspoint), the secondary node's motion
is already prescribed and its mortar row can only constrain main
degrees of freedom. Its weights on main nodes off the boundary do not
vanish (the dual basis spreads over the node's faces), so after the
substitutions a small but real constraint is left: 1.3e-6 of the
coefficients that cancelled to give it on the tied hex-tet bar with
the needle-aware tet mesh (round-off would be ~1e-14; the geometry is
exact). It was refused as a near repeat, which made the tied bar
depend on how the tet faces fell. It is now dropped (the tie is then
off by that fraction at the crosspoint), after checking it does not
contradict the prescribed values. A near repeat on a secondary degree
of freedom another constraint eliminated (a node secondary in two
ties) is still refused (#389). Dropping every held secondary node's
row instead fails the tie patch test, so only near repeats go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:51:20 -04:00
jyvikoandClaude Opus 5.5 6b4a966e1a Feature graph: test the panel's words and face selection on a real design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 2ffbb9b978 Feature graph panel: features per body in words, click selects their faces, hover highlights them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 e955b67bb7 Feature graph: each feature of a timeline in plain words
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:52 -04:00
jyvikoandClaude Opus 5.5 3977a0b630 deps: zip 9.0.4; ignore the zip 3 extract advisories that come through solidcraft-io
RUSTSEC-2026-0367/0368/0369 are in ZipArchive::extract and
ZipStreamReader::extract. Our zip 9 is updated to the fixed 9.0.4. zip 3
comes only through the pinned solidcraft-io, which never calls extract
(3MF parts are read by name), so the three are ignored with that reason
until SolidCraft moves to a fixed zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-17 00:50:21 -04:00
jyvikoandClaude Opus 5.5 ec8e165fbb engine: guards in the Relax Nodes form without a default (strong mode only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:27 -04:00
jyvikoandClaude Opus 5.5 a2a4f8895e mesh: strong relax keeps a morph unless it inverts, default reach five element lengths
Owner's rule (#438): move the bad nodes, interpolate, keep the morph as long as no
element inverts (guards "valid", the default); the old guards stay as "strict". The
run keeps its best round (highest smallest scaled Jacobian, then fewest elements below
the threshold, then highest mean) and never ends below the start. Default reach five
mean edge lengths of the bad elements (was three); on the Pi 5 base reach 3, 5 and 7
end at the same quality, 5 moving fewer nodes than 7 while leaving none below 0.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:27 -04:00
jyvikoandClaude Opus 5.5 426df4a333 mesh: improvement never leaves a tetrahedron floating; the gate refuses one
Deleting a cap tetrahedron (two boundary faces of one B-rep face) gives each of its
two other neighbours a boundary face; a neighbour that had three was left with every
face on the boundary, joined to the mesh by its vertices alone. On Linux the dome on
a plate (cmd::plan::decompose::tests::a_dome_on_a_plate...) got one and the static
solve found the stiffness singular. The kernel's tessellation and sphere parameters
differ in their last bits between macOS and Linux (SolidCraft, outside this repo);
given the same input the tet mesher gives the same mesh on both (checked by replaying
Linux's surface and spheres on macOS, which reproduced the floating tetrahedron).

The deletion now refuses when a neighbour would float, and the linear validity gate
fails a mesh with a tetrahedron that shares no face with the rest, saying so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:41:22 -04:00
jyvikoandClaude Opus 5.5 2e639336c3 docs: skill results with the plan sized for its chords
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8e5072edf0 testkit: why the sphere's fallback misses the volume limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 f2ea44b62d testkit: skill volume limit 1 %, causes of the parts now passing dropped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 8ad0800aa6 mesh: size a plan so its chords keep the volume within 0.5 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 729a770d6c mesh: divide a block edge that bends with a curved target by its length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 20:13:29 -04:00
jyvikoandClaude Opus 5.5 5db21aba7b ui: geometry.defeature.propose lists the small features with an on/off switch each (#433)
Holes by diameter, fillets by radius, chamfers by distance and bosses by height below
thresholds, or under a geometry.check finding; the reply panel switches each feature
off and on (geometry.defeature) and defeatures the whole proposal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:45 -04:00
jyvikoandClaude Opus 5.5 2456912850 mesh: strong relax objective never ranks an inverted element below a valid one
In the max-min phase a valid element's term q^-16 reached 1e96 as q -> 0+, far above
an invalid element's 2e8, so the descent kept elements inverted rather than pass
through flat: the distorted plate stayed tangled (4 invalid) once its mesh was the
same on every platform. The shape term now continues as its tangent line below
q = 0.3 (finite, steep) and an invalid element costs 1e11, above any valid one.

Adds a test that the plate's mesh, its perturbation and the strong relaxation are
the same bits with 1 and 4 threads and match a fingerprint taken on macOS (CI checks
it on Linux).

Part of #438.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:00 -04:00
jyvikoandClaude Opus 5.5 700a7e3d10 mesh: platform-independent maths (libm) so meshes are bit-identical on macOS and Linux
The system maths library's sin, cos, atan2, log … are not correctly rounded and differ
between platforms: mesh.hex.generate gave the plate of the strong-relax test a different
mesh on Linux than on macOS (nodes from 2809 on), so the test passed on a Mac and failed
in CI. Every transcendental call in fieldcraft-mesh now goes through the libm crate (a
pure-Rust port of musl's), which gives the same bits everywhere.

Part of #438.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 18:30:00 -04:00
jyvikoandClaude Opus 5.5 f297ffca3a engine: geometry.defeature takes features out of the design for meshing (#433)
Suppresses the SolidCraft features named (holes filled, fillets and chamfers back to
their sharp edges, bosses removed) and rebuilds the design; a feature that cannot be
suppressed alone is merged into its neighbour for meshing only. Kept in the model,
replayed on open, undoable; geometry.defeature.delete turns them back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 3f0cae70a2 model: keep defeatures, features of the design taken out for meshing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 e9ef7f9d4d geometry: defeature a design by suppressing its features, virtually when they cannot be
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:42 -04:00
jyvikoandClaude Opus 5.5 9c1c8f75c2 mesh: the tet mesher takes its input in a canonical order (#115)
SolidCraft lists a solid's faces in a different order from run to run
(the rounded box, for one), which reordered the triangle soup and the
face tags with every coordinate the same; welding keeps the first copy
it meets and every later stage visits ids in order, so the tet mesh
changed between runs. CanonicalSoup sorts the triangles by their
coordinates, numbers vertices by first use and face tags by first
appearance, and maps the tags back; mesh.tet.generate and the corpus
use it.

Tests: the washer's tet10 mesh has a fixed fingerprint (identical on
arm64 and x86-64 builds); a reordered, renumbered input gives the same
mesh; heavy: rounded-box and filleted-bracket mesh bit for bit the same
on repeated runs and with 1 and 8 threads.

The bracket oracle cases stay unfrozen: SolidCraft's tessellation
itself differs in the last bits between arm64 and x86-64. The coarse
case moves to 203.6 MPa with the canonical order; its band is 5%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:30 -04:00
jyvikoandClaude Opus 5.5 20281e5959 mesh: portable atan2 and cbrt for the quality measures (#115)
The platform maths libraries' atan2 and cbrt differ in the last bits
between macOS and glibc, so the dihedral angles and the shape measure a
mesh reports did too. The crate's own versions use + - * / and sqrt
only (correctly rounded everywhere) and agree with the platform's to a
few ulps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:43:30 -04:00
jyvikoandClaude Opus 5.5 030799dfc8 ui: draw size controls in the viewport, pick their faces (#435)
Faces and bodies under a size control are tinted, a box drawn as its
edges and a sphere as a translucent ball, each labelled with its name
and size. The Local Size form takes the selected faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 b62be1ada6 engine: mesh.size.* commands, honoured by every mesher (#435)
mesh.size.set adds a size control on faces, bodies, a box or a sphere;
mesh.size.growth sets the growth rate; mesh.size.clear and
mesh.size.list. The tet mesher bakes the controls into its graded field,
the hex meshers, cells and the octree take the graded RegionSize, and
the block projection and mesh plans divide their edges by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 c611080182 mesh: block projection divides its edges by a graded size field (#435)
ProjectOptions::field measures each block edge in units of the field,
so the division counts follow the size controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 4d0790565c mesh: the octree roots at a field's largest size and plans depth to its smallest (#435)
SizeField::range gives a field's smallest and largest size. The octree
starts its root cells at the largest instead of the size at the part's
centre, and refuses before building when the smallest needs more levels
than max_depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 950d86c54e mesh: local sizes in boxes, balls and on surfaces, graded (#435)
RegionSize is the exact graded field the structured meshers query;
the tetrahedral refinement bakes the same local sizes into its graded
background grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 e5ac6d35ce model: size controls and the size field's growth rate (#435)
Named size controls on faces or bodies, in a box or in a sphere, and
the growth rate the field grades at, saved with the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 17:08:27 -04:00
jyvikoandClaude Opus 5.5 85b117ec7c docs: corpus regenerated with the octree's up-front plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 5a4c501524 mesh: octree chooses its levels and root size before building
The thinnest wall (measured to a facing wall) and narrowest patch give the cell size the grid
needs; the requested size refined that deep comes first, a root shrunk so one level fewer is
exact second; beyond the levels or cells allowed meshing fails at once naming the wall, levels and
cells. max_depth is a cap (default 4); rebuilds stay within the planned levels; lattice positions
no longer depend on the lattice depth; the report gives root size, levels, wall, patch and cells
per level before and after closure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 effb5a7a14 mesh: octree feature graph drops creases that separate no patches
A crease inside one face whose ends are not on other features (a tessellation crease across the
crank's r1.5 bore fillets) bounded nothing, so its open ends were corners no region border could
reach and the feature map failed on every build. Patch widths now count bounding curves only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:28 -04:00
jyvikoandClaude Opus 5.5 54e367970f engine: geometry.face.split, a face split along a datum plane (#508)
geometry.face.split {name, face, datum} records a face split; it is
applied after the merges whenever the design is opened or cut again,
follows its datum (which it holds), undoes, and is saved with the
model. geometry.face.split.delete removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:40:18 -04:00
jyvikoandClaude Opus 5.5 7cd3dcfaa0 assets: drop the Transition placeholder icon (fix develop CI)
#519 delivered tet-hex transitions through mesh.cells and removed the
'coming soon' mesh.transition catalogue entry, leaving its icon without an
entry (icons::tests::every_catalogue_entry_has_an_icon_and_every_icon_an_entry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:26:10 -04:00
jyvikoandClaude Opus 5.5 a2e747b9ba mesh: projection and relaxation keep to a merged face's B-rep pieces
A merged face on several surfaces (a sliver absorbed into its neighbour)
had no exact surface, so the projection target fell back to its
triangles: flat curvature probes gave fewer divisions round the boss and
the enclosure base's symmetric mesh dropped from 0.70 to 0.51 once its
absorbs reached the cells. The target now projects a point of such a
face onto the exact surface of the B-rep piece it is on, trimmed to that
piece (Tessellation::with_pieces, Shape::tessellate_pieces); node
relaxation slides on it the same way.

Test from commands: the enclosure base's four slivers absorbed, meshed
whole and by symmetry, the absorbs kept in every cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:37 -04:00
jyvikoandClaude Opus 5.5 09385f069f geometry: a face merge applies to the cells of a partitioned body
A merge on `Base` failed with "no body `Base`" as soon as partitions cut
the body into cells (mesh.plan.apply's symmetry and corner cuts), so the
plan's cells and the body as opened silently lost every absorb and merge.
Each cell now takes the merge where the pieces of its faces touch (named
after the face kept, else merge:<name>[#k]), and the body as opened keeps
its merges whether partitions cut it or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:37 -04:00
jyvikoandClaude Opus 5.5 6c79498005 model: face splits along datum planes (#508)
A datum plane a split uses is in use, as one a partition cuts by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:26 -04:00
jyvikoandClaude Opus 5.5 e8e3fc647c geometry: a face split along a plane for meshing only (#508)
The face's triangles are cut along the plane, split:<name>:a on its
negative side and :b on the other, both on the face's surface, with the
cut as a new edge between them and the face's edges cut where the plane
crosses them. Applied after the merges; the B-rep is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:25:26 -04:00
jyvikoandClaude Opus 5.5 59a02b0172 docs: the meshing skill's results on the corpus and samples (#482)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 a5c71bc5b0 skills: meshing skill fixes from its corpus run (#482)
Plan before geometry.check (its size), mesh.plan.apply's own symmetry and the plan's saved
name, the fallback order, touching bodies, tet aspect ratio, undoing a relaxation that leaves
its region worse, and merging the cells of a split part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 cb4141d0f0 testkit: run the meshing skill on the corpus and samples (cargo xtask skill-results)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:51 -04:00
jyvikoandClaude Opus 5.5 bb3def1cf5 mesh.plan: decomposition proposals for unmapped features
For a body with features the sweep cannot mesh, mesh.plan replies a
decomposition: the cuts that box those features into one cell, the region,
and the commands that make the cuts and mesh the cells with pyramids. A plate
with a joined dome: 5 cuts, 17 hex cells swept together, the dome cell tet,
valid and conforming, end displacement in tension within 0.4% of all-tet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 72ebd5bc7f mesh.cells: tet cells meet hex cells through pyramids
With "transition": "pyramid" (the default; "tie" keeps the old join) a tet
cell is meshed after the hex cells, with a pyramid on every hex face of its
cuts and its tetrahedra on the pyramids' sides: one conforming mesh, no tie.
Where it cannot be built the cell says why (no_pyramids) and is tied. The
apex height is 0.4 of the quad's edge, so pyramids on faces meeting at a
right angle do not cross.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 68e9bcde92 mesh: pyramid transition surface and frozen faces in the tet mesher
transition::pyramid_surface puts a pyramid on every hex interface quad and
builds the tet region's surface closed by the pyramid sides, re-stitching the
neighbouring faces to the hex rim nodes. QualityOptions::frozen_faces keeps
those sides exactly (TetComplex refuses changes that remove or add a frozen
face; their vertices are corners), and apex_points checks the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:57:41 -04:00
jyvikoandClaude Opus 5.5 3da332be77 ui: pyramid colour stands out from the selection pinks (fix develop CI)
The pyramid5 colour added in #517 sat within the selection test's distance of
the hover pink (dark and light). Darker violet in both themes; the headless
palette matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:56:10 -04:00
jyvikoandClaude Opus 5.5 56553909f1 mesh: guarded relax keeps the region's largest edge ratio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 cd6c53adbb ui: mesh.node.relax runs as a job (window stays responsive, Cancel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 4908f5679c mesh: strong relaxation morphs round the bad elements (harmonic interpolation within a reach)
The owner's direction: bad nodes move to better places, the nodes within a reach follow by
the graph Laplacian's harmonic extension, edges held, nothing outside the reach moves.
mesh.node.relax strong mode takes below and reach and reports each round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 6f94346253 mesh: mesh.node.relax mode strong, below and layers; partitioned bodies count as their cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 7f2d4cb6e1 mesh: strong relaxation optimises a whole region toward scaled Jacobian 1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:55:59 -04:00
jyvikoandClaude Opus 5.5 b3a7f12472 mesh: a closed edge's start is no curve end of the swept cross-section (#512)
The extrude/revolve cross-section took both ends of every B-rep edge as curve ends. A closed
edge without a vertex on it (a hole's rim after geometry.faces.merge of its halves) starts
wherever its chain was picked up, the old seam vertex, so that point stayed a curve end and a
node column stayed on the merged boundary. Only open edges' ends (and the B-rep vertices) are
curve ends now; the layout and interval assignment place the rim's nodes.

The hex test runs on a square plate and asserts the seams carry exactly as many nodes as the
same lines turned a quarter round the hole (10 and 10; before 5 and 10): the corner layout
puts arc-midpoint nodes there by symmetry, so 0 is not the right target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 13:39:24 -04:00
jyvikoandClaude Opus 5.5 3a5a8213a1 fem: pyramid5 element (solver, quality, decks, VTK)
A five-node pyramid, the conforming transition between a hexahedron's face
and tetrahedra: Bedrosian's rational shape functions with a collapsed (Duffy)
Gauss rule, a certified Jacobian on the base square with exact volume, a
scaled Jacobian, C3D5 / CPYRAM / LS-DYNA degenerate solid, VTK_PYRAMID and
the binary mesh code 13. A distorted hex8-pyramid5-tet4 transition mesh
passes the patch test and uniaxial tension exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:48:01 -04:00
jyvikoandClaude Opus 5.5 4e6fd59409 engine: geometry.stitch, imported surfaces stitched for meshing (#507)
geometry.stitch {name, surface, tolerance} records a stitch; the
surfaces keep what was read (TaggedSurface::original) and are stitched
again from it whenever a surface is imported, the model opened, or a
stitch added or deleted (geometry.stitch.delete). Opening a model also
warns about merges that no longer apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 12b3128f53 model: stitches of imported surfaces, kept in order (#507)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 8cd7b43c35 geometry: stitch, a surface's gaps under a tolerance closed (#507)
Open points closer than the tolerance become one point and a point on
an open edge near the inside of another splits that edge's triangle;
edges that are not open are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:16 -04:00
jyvikoandClaude Opus 5.5 793fbe3b71 mesh: corner copy keeps the best of several cuts, its corner taken from the whole cell's mesh
The whole cell is meshed with its layout split at each candidate cut and
the sheets there held planar (ProjectOptions::planes); the corner of the
best is added as the low cell's mesh with the whole mesh's face tags,
mirrored, and the middle meshed round it. Accepted only when the corner
and the middle are no worse than the cell meshed whole; else the cell is
meshed whole and corners.skipped says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 db0f0567a0 mesh: the corner copy takes the corner from the whole cell's mesh, sheets at the cuts held planar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 fea61fb180 mesh: a symmetric plan copies one corner onto the other (#474, #491)
With symmetry, when the fundamental cell's two ends along another in-plane
axis are alike (layout planes and surface mirror-symmetric), the cell is
cut midway into the next block sheet and at its image; the low corner is
meshed with its blocks, mirrored onto the high one, and the middle meshed
with both cut faces fixed (#490). A plan cell's cut faces another cell's
mesh covers are taken as given. corners: false opts out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 12:07:04 -04:00
jyvikoandClaude Opus 5.5 58174ba168 geometry.face.absorb: tiny and sliver faces into a neighbour (#506)
A face merge may keep one of its faces' names (Merge::into, model
FaceMerge::into): absorbing a tiny face leaves the neighbour by its own
name, so sets on it still select it. Shape::absorbing_neighbour finds
the face a tiny one shares the longest edges with. geometry.face.absorb
absorbs one face (into a named or the best neighbour) or, from_check,
every small and sliver face the last geometry.check marked; check marks
now carry the small face they found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:13:23 -04:00
jyvikoandClaude Opus 5.5 ce02d263c7 engine: geometry.faces.merge, faces meshed as one (virtual topology, #505)
geometry.faces.merge {name, faces} records a face merge in the model;
it is applied after the partitions whenever the design is opened or cut
again, undoes through the journal and is saved with the model. Meshers,
mesh.plan (features and rings see merge:<name>; a merged face is
prismatic when its B-rep faces are), checks and the viewport see one
face, drawn in the warm colour. geometry.faces.merge.delete removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
jyvikoandClaude Opus 5.5 22d709e604 model: face merges, kept in the order they apply (#505)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
jyvikoandClaude Opus 5.5 b126c2293b geometry: virtual faces, adjacent faces merged for meshing only (#505)
A Merge makes adjacent faces of a body one virtual face in what meshers
take from it (tessellation tags, faces, surfaces, edge and vertex names):
the edges inside it are dropped and the pieces of an edge it leaves are
joined; the B-rep is unchanged. Design::merged applies merges after the
partitions and lists the ones that fail as feature errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 11:09:11 -04:00
dependabot[bot] f38a3be917 deps: bump signal-hook from 0.3.18 to 0.4.5
Bumps [signal-hook](https://github.com/vorner/signal-hook) from 0.3.18 to 0.4.5.
- [Changelog](https://github.com/vorner/signal-hook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vorner/signal-hook/compare/v0.3.18...v0.4.5)

---
updated-dependencies:
- dependency-name: signal-hook
  dependency-version: 0.4.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:37 -04:00
dependabot[bot] cbac4c7e27 deps: bump pollster from 0.4.0 to 1.0.1
Bumps [pollster](https://github.com/zesterer/pollster) from 0.4.0 to 1.0.1.
- [Changelog](https://github.com/zesterer/pollster/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zesterer/pollster/commits)

---
updated-dependencies:
- dependency-name: pollster
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:27 -04:00
dependabot[bot] 38678253fa deps: bump minisign from 0.9.1 to 0.10.0
Bumps [minisign](https://github.com/jedisct1/rust-minisign) from 0.9.1 to 0.10.0.
- [Commits](https://github.com/jedisct1/rust-minisign/compare/0.9.1...0.10.0)

---
updated-dependencies:
- dependency-name: minisign
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:16 -04:00
dependabot[bot] b88a8b7093 deps: bump getrandom from 0.3.4 to 0.4.3
Bumps [getrandom](https://github.com/rust-random/getrandom) from 0.3.4 to 0.4.3.
- [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/getrandom/compare/v0.3.4...v0.4.3)

---
updated-dependencies:
- dependency-name: getrandom
  dependency-version: 0.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:58:06 -04:00
jyvikoandClaude Opus 5.5 e4233ff794 mesh: format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
jyvikoandClaude Opus 5.5 7b7d38fabd mesh: fixed patch vertex matching takes the map entry once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
jyvikoandClaude Opus 5.5 6f07e4c09c mesh: the projection mesher takes fixed boundary patches as given (#490)
A patch of quads already meshed on a face of the body (a neighbour's mesh)
is matched to the block faces on it: block vertices to its nodes (its
singular nodes first, then along its grid lines), block edges along its
grid lines, faces filled quad by quad. Those faces take the patch's
divisions and nodes, held through smoothing. mesh.hex.project takes
"fixed": [face names] and builds the patch from other bodies' meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:55:27 -04:00
dependabot[bot] e887e3a8e5 deps: bump base64 from 0.22.1 to 0.23.1
Bumps [base64](https://github.com/marshallpierce/rust-base64) from 0.22.1 to 0.23.1.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.1)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 10:55:16 -04:00
jyvikoandClaude Opus 5.5 d5fb100182 mesh.plan.apply: name the faces of the meshed bodies left untagged
The reply's untagged_faces lists every face of the plan's bodies no
element face carries; the acceptance checks require it empty, and a
symmetric filleted base checks a set by every face of every cell (#494).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 937e557e5a mesh.mirror: carry each face tag to the image face its reflection covers
The image's face names came from one sample per source face (the first
element face's centre), so a source face whose sampled element face was
off it, or a sliver, mapped wrong and the true image face went untagged.
Each source face now maps to the image faces its own reflection covers,
and each element face to the nearest of those.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 18dbc29357 mesh: tag each projected quad with the face it lies on, slivers included
project_trimmed accepted the exact surface's point up to a fifth of the
nearest triangle's median edge off the face: on a tall fillet piece
(triangles 30 mm long) that let a point on a sibling piece of the same
cylinder count as on it, so every quad round the corner took the first
piece's name. The slack is now the triangle's own chordal gap to the
surface, doubled.

A face narrower than the elements (the sliver a corner boss leaves of the
inner fillet) gets one row of the quads it runs through, from the
neighbour with the most quads left. Tessellation::nearest_face finds the
face nearest a point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:31 -04:00
jyvikoandClaude Opus 5.5 fcc068bce9 docs: the ring-and-pillow layout's papers and the meshing skill's plan step (#455)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:26 -04:00
jyvikoandClaude Opus 5.5 7c3107dec0 mesh: a crank arm's plan takes rings round its bosses and a pillow from a carved grid (#455, #190)
When the grid layout does not fit a swept body (sides at an angle to the axes, root arcs where
an arm meets its bosses), mesh.plan tries the ring-and-pillow layout (cmd/plan/pillow.rs): each
boss is an annulus from its bore or pedal hole to its circle through every level, the arm a grid
carved a margin inside the outline and the bosses, with a pillow layer of blocks to the wall each
boundary face faces; a grid corner facing a boss and the outline gets a corner block reaching the
kink where the outline leaves the boss's circle. The old crank test (frame cuts) becomes the
heavy acceptance test at sizes 2.5, 3 and 4; the moved frame cut keeps a unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:26 -04:00
jyvikoandClaude Opus 5.5 992bc71b6b engine, render: symmetry plane command on any datum plane (#242)
model.boundary_condition.symmetry takes a datum plane of any orientation,
finds the faces on it and reports the plane's unit normal when it is
inclined; the glyph draws a support along that normal. Verified: half a plate
cut on its diagonal with the plane deflects as the half mirrored across it
(6.7e-13), the mirror conforming on the plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 a226b7dcac explicit: symmetry about inclined planes (#242)
At an inclined plane's nodes the velocity along its normal is removed every
increment, from the components no prescribed velocity holds (planes meeting
at a node orthonormalized, so one pass is exact); a prescribed motion off the
plane is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 ac1037e1af solver, decks: symmetry about an inclined plane (#242)
The symmetry condition takes any plane normal ({"normal": [nx, ny, nz]}).
An inclined one holds n·u = 0 and, at shell, beam and reference point
nodes, the rotations about the two in-plane axes: constraint rows the
implicit elimination already handles, its support forces reported as
reactions. Decks: Abaqus *EQUATION, Nastran MPC sets per subcase, LS-DYNA
*BOUNDARY_SPC in a *DEFINE_COORDINATE_SYSTEM along the normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:20 -04:00
jyvikoandClaude Opus 5.5 a079e51f9d repo: move to github.com/tangency-labs/fieldcraft (URLs, updater, Pages)
The updater's release endpoint, docs, README, release notes and the Pages
URL point at the tangency-labs org. App IDs (io.github.jyviko.fieldcraft)
stay unchanged so installed copies keep their identity and settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 10:38:13 -04:00
jyvikoandClaude Opus 5.5 b74d15e44d mesh: relax by face names the listed faces with no mesh (#471)
Faces no element is tagged with are left out and named in the reply
(faces_without_mesh); none tagged is the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 7d269c58f9 ui: Relax Nodes takes picked faces (#471)
Use selection puts the selected design faces in the form as faces, in
place of a sphere; Shift/Ctrl-click picks faces while the form is open,
and every click does once the form lists faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 3ee622f62a mesh: relax nodes by CAD face; params docs match their forms (#471)
mesh.node.relax takes faces: [{body, face}] (every node on those B-rep
faces and the nodes of the elements touching them, sliding on the faces)
and elements in its form; the undocumented-by-form set is dropped.
A schema test checks every top-level key a params doc names is accepted,
which also found mesh.hex.extrude normal, mesh.hex.project max_arc_angle
and mesh.plan.apply name missing from their forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 09:21:21 -04:00
jyvikoandClaude Opus 5.5 4bb2134b47 mesh: lift from the best sweep grid only for a real gain in the smallest scaled Jacobian (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 655a60c138 mesh: test that more smoothing iterations never lower the smallest scaled Jacobian (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 3ee542453e mesh: projection smoother keeps the best sweep grid and lifts from the better start (#489)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:55:09 -04:00
jyvikoandClaude Opus 5.5 78b8fc8d6c engine: one scene builder for the window and headless pictures
engine::view::build (Style, Show, Palette) is the viewport's builder,
moved from ui-egui; ui-egui maps theme::Tokens onto a Palette and calls
it. Palette::LIGHT now equals the light theme's tokens. Test: the app's
scene and engine::view::frame's are identical for the same session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:29:38 -04:00
jyvikoandClaude Opus 5.5 6136770c1c ui: ui.render draws on the window's GPU
The app keeps a capturer on its own device; ui.render draws with the
viewport's pipelines there, on the CPU with the reason otherwise, and
the reply names the renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:21 -04:00
jyvikoandClaude Opus 5.5 b655af305a gpu: Capturer::on_device and Renderer::report
A capturer on a device already open (the app's), and the renderer's
JSON shared by every reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:21 -04:00
jyvikoandClaude Opus 5.5 7f0af28e9c ui: test that the window draws byte-identically to a capture
The window's paint callback (slots, prepare, paint) draws the sample,
its symbols and an overlay into the capture's offscreen pass; the
pixels equal Capturer::rgba's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:17 -04:00
jyvikoandClaude Opus 5.5 7714afbced gpu: Capturer::offscreen draws one pass that a caller records
Capturer::rgba becomes offscreen() (sizes, error scopes, MSAA, depth,
read-back) plus finish() (background and labels), so another painter,
such as the window's paint callback, can draw into the same pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 08:27:17 -04:00
jyvikoandClaude Opus 5.5 eae709b3c0 mesh: a body the user's partitions cut is not meshed by symmetry (#473)
With a user's partition on a symmetric body, applying the whole body's plan cut it again by its
symmetry plane and mirrored one cell, leaving the other half unmeshed ("the mirror images do not
reach cell Base/b/a"). Such a body is its cells: the symmetry is not used and the plan is refused
with the cells named, as the replay of the saved Pi 5 model shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:56:10 -04:00
jyvikoandClaude Opus 5.5 80da2d968b mesh: applying a whole body's plan to a body partitions cut says to plan a cell (#473)
After geometry.partition cuts a body, the body is its cells: a projection plan of the whole body
could not open it and failed with "no body `Base` (open a design first)". It now names the cells
and says to plan and apply a cell (mesh.plan {"body": "Base/a"}) or delete the partitions. Tests
cover a design opened directly and a saved model opened again, partitioned after opening.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:56:10 -04:00
jyvikoandClaude Opus 5.5 c6bc4d2ba0 ci: prove the lavapipe GPU path draws (FIELDCRAFT_REQUIRE_GPU=1)
The drawing tests skipped silently without an adapter. Under
FIELDCRAFT_REQUIRE_GPU=1 they fail instead, and the snapshot and MCP
screenshot tests require a GPU renderer. CI runs them with
FIELDCRAFT_GPU=software on lavapipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:52:10 -04:00
jyvikoandClaude Opus 5.5 b905f1bc89 mesh: a projection plan meshes a symmetric body by its symmetry; partitioned bodies and cells plan (#473)
mesh.plan.apply finds the planes a projection plan's body is its own mirror image across
(through the middle of its extent, square to the sweep's in-plane axes, through no ring: every
triangle middle of its tessellation, a little inside and outside, has its mirror image inside and
outside alike). It cuts the body by them, plans and meshes the cell on their negative side, and
mirrors that mesh across each plane in turn (mesh.mirror, joined node to node on the planes), so
mirror-image regions mesh exactly alike. `symmetry: false` meshes the whole body.

A cell of a partitioned body is planned from the features that built its body, with the rings it
holds; a partitioned body is planned whole as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:26:28 -04:00
jyvikoandClaude Opus 5.5 4ad793ca5e skills: relax around marked elements (relax takes no set)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:19:24 -04:00
jyvikoandClaude Opus 5.5 c160712343 skills: meshing skill follows today's loop (look, symmetry, plan, mark, relax)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 07:19:24 -04:00
jyvikoandClaude Opus 5.5 19c0538dd8 mcp, cli, ui: camera target, fit and fov in screenshots, snapshot --camera and ui.camera; screenshot section (#467)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 1f183e04ea engine: aim a picture's camera at a target (#467)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 4fa6292749 render: fit a camera's distance about its target
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:28:57 -04:00
jyvikoandClaude Opus 5.5 927accce45 mesh: smoothing straightens the face grid lines the compass search leaves zigzagging (#402)
The compass search after the sweeps moves the nodes of the worst elements one at a time, each to
its own best place. Since it lifts every element below 0.7 (#462) it moves many surface nodes,
and a column of them on a face ends at scattered places: the enclosure base's inner walls showed
zigzag element lines in a column next to each corner, full height. A last pass now moves each
surface node back toward the mean of its neighbours on the surface, as far as no element round
it falls below the lower of its own value and the mesh's smallest after the search, so the lines
straighten and the mesh's worst element is no worse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 06:05:54 -04:00
jyvikoandClaude Opus 5.5 4a8d34d619 ui: XY plot in the results panel, drawn by the energy plot's code (#94)
XY Plot opens a section in the results panel: a quantity picker, the
series from results.xy plotted against the step's own axis with the
frame or mode shown marked (a click shows the nearest), nodal fields
read at the probed node, and Export CSV through results.xy. The energy
plot and the XY plot share one line-plot function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 05:31:33 -04:00
jyvikoandClaude Opus 5.5 9a8469cf4b mesh: a projection query searches the face's own triangles (#402)
Projecting a point onto one B-rep face searched one box tree over every triangle of the
boundary, skipping the other faces' triangles: when other faces lie nearer the point than the
target face, it walked most of the tree before reaching a triangle it could use. Smoothing a
projected mesh projects every boundary node many times, onto every face of its target group,
so the Pi 5 enclosure base spent 95 % of its 11 minutes there. Each face (and each face pair's
curve) now has a tree of its own: the same nearest triangle, found among its own boxes only.

Ties between triangles at the same distance went to whichever the tree's walk met first; they
now go to the lowest triangle, so the result no longer depends on how a tree is built. With
that rule the per-face trees and the whole-boundary tree give bitwise the same mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 04:54:14 -04:00
jyvikoandClaude Opus 5.5 cbd60a7985 results: results.xy, a quantity against the load factor, time or mode (#94)
A nodal field at a node per frame (nonlinear static displacements from
load factor 0, explicit frames, heat temperatures) or per mode, the
explicit energies and histories, history nodes, and the frequencies or
buckling load factors; capped at 100000 points with `every`; stale
results refused; `path` writes a two-column CSV. XY Plot is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 04:33:06 -04:00
jyvikoandClaude Opus 5.5 47e92eebb1 mesh: a plan's frame cut on an opening's end face moves one element out of it (#455)
The crank arm's back pocket ends on the planes x = 48 and x = 130, its frame sides. The plan
put its frame cuts there; the kernel cannot cut along a face (#427), so both were skipped and
the cell between the bosses swept the pocket's rounded ends into fans. A frame side on a face
of the body now moves one element out of the opening (away from the rectangle's middle),
leaving a strip one element wide; both cuts are made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:59:29 -04:00
jyvikoandClaude Opus 5.5 bf61ae8406 mesh: smoothing visits nodes in their own order again (#402)
#462 visited nodes by a mirror-invariant key (distance from the centroid). On the cup with a
handle that order sweeps ring by ring round the axis and converges worse in its 100 sweeps: the
refined cup fell to a scaled Jacobian of 0.481 (needs 0.5, #440). In the lattice's own order
it holds, and the order gained the enclosure base little (its corners differ by 0.02 either
way), so it goes; the compass search's lift to 0.7 stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:42:47 -04:00
jyvikoandClaude Opus 5.5 4d6d69ee6d mesh: smoothing lifts elements up to 0.7 and visits mirror twins alike (#402)
The compass search after the sweeps only lifted elements below 0.5 (the quality floor), so a
mesh whose worst elements sat just above it stayed there: every corner of an enclosure base
stopped at a scaled Jacobian of 0.50-0.52 where its walls meet the corner bosses. It now lifts
every element below 0.7, never lowering another below the lower of its own value and 0.5.

The sweeps and the search are Gauss-Seidel, so the node order shaped the result and mirror
image corners smoothed differently. Nodes are now visited by a key that a mirror or a quarter
turn about the nodes' centroid keeps (distance from it, then the sorted offsets along the
axes). What spread is left between mirrored corners (0.686 vs 0.702 on the test base) is most likely the projection onto the body's
tessellation, which the kernel does not make mirror symmetric (not yet proven).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 03:05:43 -04:00
jyvikoandClaude Opus 5.5 e79ba866a1 engine: draw geometry.check findings as viewport markers
geometry.check keeps its findings on the session (not an edit: not journalled,
saved or undone) while the checked design stays open; the picture and the app's
viewport draw the faces of small, sliver and near faces in the marker colour,
short edges as thick lines and a dot at each finding over everything. `mark:
false` or a clean check clears them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:47:14 -04:00
jyvikoandClaude Opus 5.5 f8b92fcd89 ui: element faces and edges as element:side lists
A Sides field kind: element faces or edges typed as element:side with
ranges (1-20:3, 25:1), no row cap, and Use selection for picked faces.
Set and mesh-tag forms use it instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:30:24 -04:00
jyvikoandClaude Opus 5.5 9b705f5334 ui: fluid boundary face and obstacle faces as typed fields
The boundary's 'at' is a box face or an obstacle body and face
(NameOr); the domain's obstacle faces are rows of a face and its wall
condition (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 02:14:58 -04:00
jyvikoandClaude Opus 5.5 98e96921e6 ui: mirror plane and images as typed fields
Two field kinds: NameOr (a name as a bare string, or an object of fields)
and RefText (a text per item of a collection). The mirror's plane is a
datum plane or a point and normal; its images a body name per body
(#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:51:04 -04:00
jyvikoandClaude Opus 5.5 d17cf414f7 ui: block divisions and bias as two or three typed fields
A Tuple field kind: min_len to labels.len() positive numbers, whole when
asked, trailing blanks left out. The block's divisions and bias use it
instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:42:51 -04:00
jyvikoandClaude Opus 5.5 e4d1f6c7da ui: one number or one per axis for conductivity, expansion and point mass
A PerAxis field kind: one value for every direction, or Per axis ticked
for one per axis. Thermal conductivity and expansion and the point mass
use it instead of a JSON box (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:38:26 -04:00
jyvikoandClaude Opus 5.5 b18195ff89 ui: table rows on one line under column headers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:34:38 -04:00
jyvikoandClaude Opus 5.5 61f0d42ec4 ui: nonlinear load path as a table of fraction and factor
Kind::Points gains its columns, so a list of number rows other than
[x, y, z] gets the same typed editor; the load path uses it (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:34:38 -04:00
jyvikoandClaude Opus 5.5 1c18a463e7 app: the windows-sys features WriteFile and MiniDumpWriteDump need (#299)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 af9b4bb222 app: startup steps in the crash journal, and a Windows exception filter (#299)
egui-wgpu's startup hook and the app's own steps (window, app setup, viewport
pipelines, first frame, running) go into the journal. On Windows,
native_crash.rs (adapted from SolidCraft, the only module allowed unsafe code;
the app crate now denies instead of forbids it) writes the exception code,
faulting module and offset and a minidump. A nightly/on-demand CI job runs the
app's tests on Windows, including a child that faults on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 ed25de6521 engine: crash reports name the startup step, the renderer and a native crash (#299)
The journal keeps the startup step the app is in (phase) and the renderer
(set_environment) across rebases; an unclean exit before the first frame is
reported "during startup (<step>, N s after launch)". native_paths() is where
a native exception filter writes what it caught and a minidump; the next
start puts the exception code, module+offset and thread into the report and
moves the minidump into its folder. Adapted from SolidCraft's crash.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 01:11:58 -04:00
jyvikoandClaude Opus 5.5 ead489592c ui: arc-length displacement limit as typed fields
The step form's displacement limit was a raw JSON box; it is now a group
of node, direction and displacement fields (#449).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:50:12 -04:00
jyvikoandClaude Opus 5.5 7a5363e07b app: leave SIGHUP alone so nohup keeps the app running (#248 follow-up)
#445 also closed the window on SIGHUP, which overrides the SIG_IGN that
`nohup fieldcraft &` sets so the app outlives its terminal. A safe API cannot
ask whether a signal was ignored at launch, so SIGHUP keeps its default.
SIGTERM and SIGINT still close the window the normal way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:47:35 -04:00
jyvikoandClaude Opus 5.5 9a30c7d9ed mesh: the one-to-one sweep names a body that ends at several levels (#444)
The crank ends at 8, 16, 20 and 22 mm, so no one-to-one sweep takes it.
The sweep met one of those caps as a linking face and reported "linking
face has no top side". A plane parallel to the source that only part of a
ring reaches is now named as what it is, with extrude pointed to. The arm's
profile alone sweeps as well as extrude does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:33:55 -04:00
jyvikoandClaude Opus 5.5 0b7dda95f7 mesh: an enclosure's corner bosses are ringed where its walls join them (#402)
A boss the body's walls run into had its circle crossed by the walls' planes, so the projection
layout refused it and the plan fell back to swept cells, fanning round the boss and its hole.
Such a boss is now framed by the square round its whole circle, subdivided by the planes that
cross it; every square inside (the hole's, the core) is divided alike, so the ring's blocks meet
the walls node to node. On the walls' side the frame stops at their inner plane and its corner
projects onto the middle of the inner fillet, which the ring takes in (a boss just touching the
fillet included: no sliver). Cylinders a fillet's history names but that are a ring's circle are
the ring's faces. Rings are built per patch of grid cells, the one-cell O-grid being the
simplest patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:29:24 -04:00
jyvikoandClaude Opus 5.5 02b408c499 app: a termination signal closes the window the normal way, not as a crash (#248)
SIGTERM, SIGINT and SIGHUP ended the process on the spot: no last autosave,
and the crash journal left behind became an unclean_exit report at the next
start. The report on #248's machine ("ended without a clean exit between
commands (last: control ui.capture)") has that exact signature and is
reproduced by kill -TERM after a capture; 500 captures of the solved
crank-mirror (50 app runs), 30 CLI snapshots and 250 MCP headless
screenshots exited cleanly. The first signal now asks the window to close;
a second ends the process with the signal's default action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:24:26 -04:00
jyvikoandClaude Opus 5.5 3d38753cad geometry: a partition the kernel can only nudge off its plane is refused (#427)
The kernel's last-resort boolean retry moves the tool 1.3 µm and turns it
2 µrad. For a partition that puts the cut beside the face it was meant to
run along and leaves a cell bounded by slivers (the open boundary at x = 130
on the crank half). halves() now refuses a result-altering retry and names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 00:20:02 -04:00
jyvikoandClaude Opus 5.5 1af87f1f98 mesh: a disk's O-grid follows the region's elongation (#198)
A smooth loop (straight sides running tangent into arcs, as a slot or a
rounded pocket) is meshed as a disk. Its middle block was a square halfway
to the loop's quarter points, which on the crank's 6:1 pocket floor
squashed elements to a scaled Jacobian of 0.16. The block is now a
rectangle on the region's principal axes, inset by the same distance all
round (the old square on a circle), its corners paired with the loop nodes
nearest their diagonals, its sides spaced as the nodes they pair with, and
the O-grid is smoothed (untangled first) before the validity check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:55:02 -04:00
jyvikoandClaude Opus 5.5 e9801e4528 ui: draw the marked elements in the error colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 75b8c7e26e engine: mesh.quality.mark marks the elements below quality limits as a set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 33d69fc53d mesh: list the elements that fail quality limits, worst first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:32:47 -04:00
jyvikoandClaude Opus 5.5 44de124d9d engine: geometry.check reports what is small against the element size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:17:39 -04:00
jyvikoandClaude Opus 5.5 5cee94f947 geometry: check bodies for short edges, small and sliver faces, near faces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:17:39 -04:00
jyvikoandClaude Opus 5.5 06c4a8d73d mesh: projection divisions from the mean edge length of each class, thin-layer tests (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 23:09:50 -04:00
jyvikoandClaude Opus 5.5 f9a0282acc engine: results.export.vtu writes a solved step for ParaView
Static, frequency (per mode), nonlinear and explicit (per frame, with a
.pvd collection) and heat results; fluid steps are refused. Wedges keep
the model's node order: VTK 9's wedge has the same orientation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:55:12 -04:00
jyvikoandClaude Opus 5.5 439dfc678f io: write VTK XML unstructured grids and PVD collections
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:55:12 -04:00
jyvikoandClaude Opus 5.5 68016f69f7 engine: the crank half y > 0 divides where its mirror does (#283)
The kernel failure is reported as such since #381; the failure itself came
from split_by_plane's oversized boxes, which #384 replaced with body-sized
ones. The regression test now requires the cut (x = 110, 148, reversed
normal, the diagonal plane) from commands, with the cells' volumes adding up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:45:27 -04:00
jyvikoandClaude Opus 5.5 92d49debec mesh: a screw hole's ring takes in the fillet of its rounded corner; frames centred on the hole (#402)
The projection layout gave every fillet a blend layer along the sides it runs between, so a
screw hole close to a rounded corner sat inside the layer and the plan fell back to frame cuts
and swept cells, which fan into the hole. A corner fillet that lies inside the square from the
hole to the two sides is now taken in by the hole's O-grid ring: the frame reaches both sides,
its outer corner projects onto the middle of the fillet, and no layer is needed. An O-grid's
frame is centred on its hole (a near side is the frame, the far side as far), and the plan
reports the layout's frames and drops the frame-cut notes when it does not cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:43:02 -04:00
jyvikoandClaude Opus 5.5 1d71a9f8af engine: mesh.export.stl writes the outer surface or a set's faces
Binary by default, ASCII on request; in the Mesh tab's Verify panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:34:02 -04:00
jyvikoandClaude Opus 5.5 87710a83ab io: write element faces as binary and ASCII STL
Model::skin_faces gives the mesh's outer skin: the exterior faces of
the solids and the top side of every shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 22:34:02 -04:00
jyvikoandClaude Opus 5.5 eecdcb5b96 mesh: projection divisions follow the curvature of curved targets, max_arc_angle (#418, #402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 464417d84d mesh: test that a plane is trimmed to its face (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 c107ea8105 mesh: a point of a plane past its face's triangles is off the face (#418)
project_trimmed accepted the analytic projection within a fifth of the nearest
triangle's size; a plane face's triangles can span the whole face, so nodes in a
3 mm fillet landed on the untrimmed box planes (mesh volume = the unfilleted box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 21:53:38 -04:00
jyvikoandClaude Opus 5.5 390ddcd6cc cli, mcp: snapshot --section and --field; headless MCP screenshots on the GPU
--section x|y|z[=VALUE] hides the elements on the camera's side of a plane
(at the mesh's middle by default) so the element faces inside show;
--field contours a nodal field of the solved step (results.contour). The
MCP server's headless screenshot draws with the viewport's pipeline on the
shared device and its reply, like snapshot's, names the renderer.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:50:40 -04:00
jyvikoandClaude Opus 5.5 c9163e98ad gpu: captures draw name labels; a shared capturer and a PNG with the CPU as last tier
render::draw_labels draws a scene's labels over finished pixels in the CPU
rasterizer's font. capture::shared opens the process's device once by the
FIELDCRAFT_GPU policy; capture::png draws on it or on the CPU and reports
which (Renderer).

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:50:40 -04:00
jyvikoandClaude Opus 5.5 fee978c7e4 cli: snapshot draws a script's model to a PNG with no window
fieldcraft-cli snapshot script.json --out shot.png [--view] [--size WxH]
draws the engine's framed scene (engine::view::frame, shared with the CPU
render_png) with the viewport's GPU pipelines offscreen: hardware GPU, then
a software one, then the CPU rasterizer (FIELDCRAFT_GPU=auto|hardware|
software|cpu). CI installs lavapipe so the GPU path runs there.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:19:00 -04:00
jyvikoandClaude Opus 5.5 248e74ca9e gpu: the viewport's pipelines move to a crate of their own (fieldcraft-gpu)
ui-egui keeps only the egui_wgpu paint callback; the shaders, batching and
pipelines are fieldcraft-gpu's view module, so an offscreen picture can draw
with the window's pipelines.

Refs #422

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 20:19:00 -04:00
jyvikoandClaude Opus 5.5 575ae10dbf mesh: tests pin projection smoothing's edge ratio and regular grids (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 58d3b351ff mesh: projection smoothing keeps a result that holds the grid's quality (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 5306450413 mesh: projection smoothing guards edge ratio and never leaves the grid worse (#418)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 19:39:01 -04:00
jyvikoandClaude Opus 5.5 d29334a7a7 mesh: mesh.plan.apply builds rings and fillet/chamfer blend layers as one projected block layout (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 18:59:02 -04:00
jyvikoandClaude Opus 5.5 361ac2e7b3 ui: an icon for Extrude to Surface (mesh.hex.extrude)
develop CI failed every_catalogue_entry_has_an_icon_and_every_icon_an_entry since #414.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 17:39:22 -04:00
jyvikoandClaude Opus 5.5 febd40fadc mesh: split a projection block through its whole sheet; name twisted elements' block junction (#399)
Topology::split cuts a block at given fractions and every block chained to it
through parallel edges, orienting the fractions through the chain, so the
topology stays conforming. A certified-invalid element is reported with its
block, cell, centre and the block vertex and blocks meeting there; the mesh is
never emitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:57:05 -04:00
jyvikoandClaude Opus 5.5 4dadaa50aa ci: docs/releasing.md: release jobs run in parallel; only the draft waits for verify
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:52:48 -04:00
jyvikoandClaude Opus 5.5 e468133f9a ci: package releases in parallel with the verify jobs; only the draft job waits for both
The release job (the only one that signs SHA256SUMS.txt or touches a GitHub Release) now needs
every verify and packaging job; packaging no longer waits for verify. Verify jobs restore
develop's CI caches through shared keys (same CARGO_* env as ci.yml); saving stays main-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:52:48 -04:00
jyvikoandClaude Opus 5.5 c82c13019a docs: regenerate features.md after the rebase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 d703cdde0b docs: extrude up to a surface in the literature, MCP and meshing skill (#400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 c077131285 ui: Extrude to Surface form picks the source face, then the target surface (#400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 4ac31d3988 engine: mesh.hex.extrude, hex layers up to a target surface (#400)
The section comes from a set (a 2D mesh or element faces), the mesh already
on the source faces (tagged, or a neighbouring body's boundary lying on
them, whose nodes are shared so the meshes conform), or a fresh quad mesh
of one planar face. Target and side faces project through the tet mesher's
B-rep projection. Replaces the body's mesh like the other meshers, tags the
body's own source, target and side faces, and is on the MCP mesh tool as
hex_extrude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 600b2ff633 mesh: hex extrusion of a quad section up to a target surface (#400)
Each source node follows its own path (a direction or the section normal)
to the target, found by Newton on the target's tangent plane; layers are
graded by a bias; side faces, when given, hold the boundary nodes of every
layer (alternating projections at the target), and interior nodes take the
boundary-residual correction. Source nodes are kept, so a section taken from
a neighbouring mesh conforms with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:44:40 -04:00
jyvikoandClaude Opus 5.5 d6b336538a docs: start hex meshing from the provenance graph (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 efe90ec50e mesh: geometry.features, mesh.plan and mesh.plan.apply from the feature graph (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 8dd7446569 model: mesh plans, saved definitions with templates, rings, cuts and copies (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 4cfb6dd3de geometry: the replayed feature graph per body (#402); a partition may list a cut body for its cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:38:51 -04:00
jyvikoandClaude Opus 5.5 d3ac6395bb engine: Projection Blocking form takes levels and merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:37:07 -04:00
jyvikoandClaude Opus 5.5 62fc00755a engine: a form for Projection Blocking (mesh.hex.project)
every_command_has_a_form failed on develop since #399.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:37:07 -04:00
jyvikoandClaude Opus 5.5 d781d6e427 engine: mesh.hex.project command, MCP, docs and meshing skill (#399)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:05:38 -04:00
jyvikoandClaude Opus 5.5 6efad8feeb mesh: projection blocking — block topology, projection onto the B-rep, elliptic smoothing (#399)
A block topology (carved grid, butterfly and seven-block sphere templates,
O-grid, extrude, connect) projected onto a body: outer block faces onto their
smooth face groups, edges onto the curve of two targets and vertices onto the
point of three by tangent-plane iteration, nodes by transfinite interpolation,
then Winslow smoothing with the boundary on the geometry and a compass search
on the worst elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:05:38 -04:00
jyvikoandClaude Opus 5.5 8092c39892 mesh.cells: a tie inside one design body says why (#396)
Every tied interface between cells of one design body carries `why`: the tet cell (and why
auto fell back), the reason the body's cells did not sweep together, or the cell meshed alone
by its method; so an agent knows which cut or method to change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:59:01 -04:00
jyvikoandClaude Opus 5.5 4610876384 mesh: square corners round rectangular openings; no sliver or unassignable blocks (#397)
The swept cross-section's decomposition:
- An opening with concave corners gets a layout that continues its sides to the outer loop
  (side strips and corner blocks, every corner near 90°). The thin air ring round the Pi
  enclosure's square pedestal was left to the free mesher, which put a node 1e-5 from the
  opening's corner: edge ratio 341,515.
- A piece's score is capped by its quadrilaterals' aspect against their longest edge or the
  target size, so thin strips and pieces much smaller than the elements no longer score as
  square blocks; the free mesh is scored alike for "do no harm".
- A disk is not cut into wedges round its centre (its O-grid is the scheme).
- Cuts whose pieces cannot be given division counts together (mapped pieces round a hole that
  chain back to a longer side) are refused, instead of failing the whole cross-section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:59:01 -04:00
Preetinder Kalsi 2be6aff2f9 Bundle the macOS CLI inside FieldCraft.app 2026-10-15 15:43:32 -04:00
Preetinder Kalsi 991dd354cb solver: share Cholesky and LDLT triangular solves 2026-10-15 15:43:15 -04:00
jyvikoandClaude Opus 5.5 c012a9e442 ui: collapse the Relax-around-worst-element condition (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:22:18 -04:00
jyvikoandClaude Opus 5.5 5a0605741b release: version 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:13:31 -04:00
jyvikoandClaude Opus 5.5 ef77deff5f build(deps): finish the zip 9 port and lock it
The 3MF reader's name_for_index now returns a Result in zip 9, and the
geometry test writer still wrapped last_modified_time in Some. Cargo.lock
gains zip 9.0.3 beside SolidCraft's zip 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 15:08:13 -04:00
Skywalkingzulu1 4ffce253f2 fix(io): adapt to zip 9 API
- last_modified_time takes DateTime directly, drop the Some() wrapper
- file_names() returns Result<Cow<str>, ZipError>: propagate errors, compare
  the Cow<str>, and push the resolved name into seen
- test helper: ZipFile::name() now returns Result, unwrap it
- update the Cargo.toml comment explaining why we moved ahead of
  SolidCraft (still on zip 3)
- update Cargo.lock to zip 9.0.3
2026-10-15 15:08:13 -04:00
Skywalkingzulu1 8e28283dad build(deps): upgrade zip 3 → 9
Bump the zip crate from 3 to 9 in the workspace Cargo.toml and
adapt to the API change in 2.0+: last_modified_time now takes
Option<DateTime> instead of DateTime directly.

- crates/io/src/lib.rs: wrap DateTime::default() in Some(...)
- crates/geometry/src/tagged_mesh.rs: same fix in the 3MF writer

Closes #134
2026-10-15 15:08:13 -04:00
jyvikoandClaude Opus 5.5 a6f1a6dbeb ui: Relax around worst element at the top of Mesh Quality; plain preview text (#398)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 14:40:00 -04:00
jyvikoandClaude Opus 5.5 87af68f550 docs: node relaxation in features, MCP guide, control protocol, literature and the meshing skill (#398)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 14:40:00 -04:00
jyvikoandClaude Opus 5.5 94fdbf0fca engine, ui: mesh.node.relax and relaxation fields, Relax Nodes tool with sphere and preview (#398)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 14:40:00 -04:00
jyvikoandClaude Opus 5.5 75f9e5ae6e mesh: node relaxation with UV surface slides and relaxation fields (#398)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 14:40:00 -04:00
Preetinder Kalsi 2030a81327 Share static equilibrium core with buckling preload 2026-10-15 14:27:01 -04:00
Preetinder Kalsi 4c42a708f7 Allow explicit solve factor caps up to two billion entries 2026-10-15 14:00:56 -04:00
Preetinder Kalsi a530a0d914 Report factor value bytes and preserve overflow-safe reduction limits 2026-10-15 14:00:56 -04:00
jyvikoandClaude Opus 5.5 c7d0a82079 geometry: cuts stay on their plane; risky cuts are named (#384)
A partition intersects the body with a box on each side of the plane as
large as the body itself. SolidCraft's split_by_plane uses boxes several
times larger, and its boolean then put the cut up to 1.8 um off the plane
on the enclosure's air, so the cut faces were not recognized and the cells
did not close. A plane along a face of a body it cuts, or a cell whose
boundary does not tessellate into one a mesher takes (a cut through a
hole's axis), is a warning naming the partition, the body and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 09323ddb45 geometry: meshes go with the bodies a partition change removes (#385)
Deleting, suppressing or editing a partition (or its datum), or cutting a
meshed body, removes the meshes of the bodies that are gone, reported as
replaced, in the same undo step. model.check lists meshes of partition
cells the open design no longer has (a model saved before the cut
changed), and mesh.clear removes them, or the meshes of named bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 c6eeac35c1 solver: a node in several ties gets one constraint; ties both ways are an error (#389)
A node on the secondary surfaces of two ties (a chip's underside and its
sides, a cut face and an outer face) got one constraint per tie. When the
main nodes of one follow the other's main surface, the second constraint is
nearly the first again, and eliminating it divided by round-off: the Pi 5
heat model put 21.9 W more in than its 8 W. Such a node now follows the
main faces of all its ties together (balanced to 5e-11 of the power), a
node following a node that follows it back (a tie each way on one
interface) is an error naming both ties, and a tie row that still nearly
repeats the constraints before it is an error naming them.

mesh.cells ties one way by a fixed order of the cells (hex main to tet)
and makes one tie per tied cell, tie_<cell>, replacing the ties an earlier
run made, so its own ties never hold a node twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 755576de8a mesh.cells: sweep the cells of a body together, conforming with no seeds (#288)
hex_mesh_swept_cells lays out one cross-section for several cells: every
cell's walls (its cut faces included) are imprinted together, every cell's
levels are levels of all, one interval assignment and one set of layers,
and each region of each level interval is filled by the cell it lies in.
Cells meet node for node across every cut, T-junctions included.

mesh.cells sweeps the cells of each design body together first and meshes
cell by cell only what does not sweep together (saying why); the reply
gives every cell's volume, scaled Jacobian and edge ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 c3a1712069 mesh: interval assignment repairs parity round a point with coupled curves too
Four triangles round a point (a square cut along both diagonals) share their
free half-diagonals in pairs, so those alone cannot make every loop even when
the square's perimeter is odd; the parity repair kept raising them until the
step cap. A loop that keeps coming back now lets its coupled curves move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 92c3588099 geometry: a partition's pieces that do not touch become separate cells (#388)
split_by_plane gives one body per side, which may hold several shells when
the side falls apart; every lump is now its own cell (B/a1, B/a2), at every
cut, as the partition docs promise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:43:45 -04:00
jyvikoandClaude Opus 5.5 df7f7f0769 docs: what CI runs when (affected crates on develop, everything nightly and at release)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:31:40 -04:00
jyvikoandClaude Opus 5.5 1e88839575 ci: release verifies the full gate, heavy tests and oracle (Linux, macOS) before packaging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:31:40 -04:00
jyvikoandClaude Opus 5.5 b90554561f ci: test the crates a change affects since the last green run; heavy and oracle only when it reaches them
A plan job picks the base (the newest develop commit whose push run
passed and that HEAD contains; a pull request's merge base; none for
nightly and manual runs, which run everything) and runs cargo xtask
affected. The ci job runs `cargo xtask ci --affected BASE`; heavy and
oracle run only when the change reaches the solvers, the oracle or
everything. Cancelling superseded push runs stays: the tip's run covers
everything since the last green one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:31:40 -04:00
jyvikoandClaude Opus 5.5 b3098f8739 xtask: affected --base REV and ci --affected REV test only what a change reaches
`cargo xtask affected` maps the changed files to workspace packages (by
directory from cargo metadata; files outside every package to the
packages whose sources name their top-level directory), adds every
reverse dependency, and flags everything (manifest, lock file,
toolchain, xtask, workflows), solver (the oracle's package or a package
with #[ignore]d heavy tests) and wasm (a crate the wasm check builds).
`cargo xtask ci --affected REV` runs the quick whole-tree gates, then
clippy and the tests of the affected packages, and the wasm check only
when flagged. Without the flag the gate is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:31:40 -04:00
jyvikoandClaude Opus 5.5 21072a23de xtask: drop the licences --check gate; THIRD-PARTY-LICENSES.txt is a release artifact
The file is no longer committed: dependency pull requests from forks failed CI only because
it was out of date. The Dependabot workflow that regenerated it is gone. licences now fails
when a shipped crate names no licence and carries no licence file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:24:45 -04:00
jyvikoandClaude Opus 5.5 840b4b7dc5 ci: generate THIRD-PARTY-LICENSES.txt in every release job and attach it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:24:45 -04:00
jyvikoandClaude Opus 5.5 e85300234f ci: check dependency licences with cargo-deny on every pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 13:24:45 -04:00
Preetinder Kalsi e3d42d8be1 build(deps): adapt 3MF reader to quick-xml 0.42 2026-10-15 13:12:49 -04:00
jyvikoandClaude Opus 5.5 f220c7108b solver: a job cancelled before it starts stops before any setup work
The cancel check came only at the factorization, so on a busy CI runner a
pre-cancelled frequency solve still spent 17 s on setup (cancel_tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 12:44:16 -04:00
Preetinder Kalsi 3e997dab43 Make empty text fields visible in the light theme 2026-10-15 12:20:33 -04:00
jyvikoandClaude Opus 5.5 29667e63e8 skills: agentic hex meshing (fieldcraft-meshing)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 12:06:55 -04:00
jyvikoandClaude Opus 5.5 7a9cb2cb22 ci: release secrets from the main-only release environment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 12:01:16 -04:00
jyvikoandClaude Opus 5.5 1dc4c27dc1 docs: ROADMAP lists what landed for 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 0a0bac2bf2 mcp: the solve tool runs nonlinear and buckling steps too, and its docs list heat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 fe1fc30be5 docs: regenerate the gallery captures, Start-card thumbnails and setup-symbols picture for 0.6.0
Window captures (light theme, 1400 x 860) of every sample solved on the current UI; the
thumbnails that were viewport crops are now clean ui.render images like the others; the drop
test's figure at 0.1 ms reads 129 MPa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 cc83e01c57 engine: New Step's description lists the buckle, heat transfer and coupled procedures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 4a5d897782 engine: a free body in explicit dynamics needs no boundary condition in the setup checklist
The drop-test and impact samples have no supports, by design, yet the browser marked Loads &
BCs as the next stage with an Add boundary condition row even after they were solved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 c983987c64 results: Export CSV takes every parameter its description names (set, at, mode, frame, about)
results.export.csv's form listed only nodes and sets, so the parameter check refused the
probe's set, at, mode and frame and the reactions' about point that the command documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:53:52 -04:00
jyvikoandClaude Opus 5.5 05dc20a70b ui: preview Merge Nodes and select what Check Duplicates finds (#234)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 198ead4015 mesh: node tools commands: merge, duplicate check, renumber (#234)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 57f7bd3d7e mesh: merge plans, duplicate and crack checks, reverse Cuthill-McKee order (#234)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 1a91bb324d mesh: renumber nodes and elements in the model, every reference following (#234)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 b5e54cb802 mesh: regression test for hex20 on the crank with tangent root arcs (#197)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 3385209c6f mesh: carry a biased seed's spacing to the edges tied to it (#257)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 d23c2efaec mesh: report a kernel failure to partition as the kernel's, not a plane that misses (#283)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 22f2e0c9cc mesh: keep tet improvement insertions apart from existing vertices (#311)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:11:14 -04:00
jyvikoandClaude Opus 5.5 1f85a8caa3 ui: typed form fields for label lists, point lists, multi-choices and number-or-auto (#169)
Four field kinds in the schema, each checked and described in JSON Schema: labels (nodes or
elements, typed as 1-5, 8 with Use selection taking the viewport's picks), points (rows of
x, y, z, added and removed), multi (checkboxes: coupling DOFs, step output) and number or
auto (the deformed scale). Node and element lists, probe and Delaunay points and block
corners use them; the remaining JSON fields hold geometry-specific shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 c0cbcadf3d ui: Mesh Quality shows counts, a histogram per metric and the worst elements (#113)
The context panel reads mesh.quality's reply as the counts (an area only for a surface mesh),
each metric's min, mean and max over its histogram (the bad end warm), and its worst element: a
click selects and frames it. Invalid and undetermined elements have a button that selects
and frames them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 d1557a87fe results: export probe and reaction results to CSV (results.export.csv, #92)
results.export.csv runs solve.probe or results.reactions and writes their reply as an RFC 4180
CSV: a row per node (node, x, y, z and the quantities the step has, columns it does not have
left out) or per set with the total and the applied load; numbers in shortest round-trip form,
so they read back bit for bit. A stale result is refused. The results panel has Export CSV…
beside Reaction sums and under the probed node: the save picker, or the form without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 5112855b83 results: clamp the contour legend's range; values above it in an over colour (#191)
results.contour takes range {min?, max?} (or "auto"), kept until the field changes: a
singular peak no longer squeezes the field into the bottom bands. Values above a clamped
maximum go to an extra band drawn in the over colour, split exactly at the clamp; the legend
shows that band up to the true maximum and says it is clamped. The results panel has Legend
max/min (Auto or a value), the MCP show_results tool a range argument, and headless pictures
follow the clamp too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 441f03d093 ui: Help › Crash Reports lists the saved reports to report, copy or delete (#300)
diagnostics.crash_reports.delete deletes one report or all. The window lists each report
(when, what, how many times) with Report…, Copy Path and Delete, and Delete All; ui.prompt
answers it over the control channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 4711f9474a engine: the I-beam cantilever sample (ibeam, #143)
The README video's model as a sample: the corpus cantilever-i-beam design opened as geometry,
swept all-hex, clamped at F2:start, 1 kN on F2:top through a kinematic coupling; static step
Load. It has a Start-card thumbnail and a gallery capture; a test checks its tip deflection
against beam theory with shear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 0625125ed7 ui: File › New starts an empty model (file.new, #144); an unknown sample name opens the samples (#200)
file.new replaces the session with an empty one: no design, mesh, definitions, results or
journal, nothing to save, the undo history empty. The File menu and Cmd/Ctrl+N run it, asking
first over unsaved changes. A sample name the app does not know (a typo, an old ?sample= link)
opens the samples to choose from, and the status names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 92b6225a72 mcp: the probe tool takes at, points whose nearest nodes are probed (#105)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00
jyvikoandClaude Opus 5.5 5aace9b5b3 jobs: Cancel and job.cancel interrupt the running command (#371)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:39:40 -04:00
jyvikoandClaude Opus 5.5 5fcbee028e solvers, meshers: stop with Cancelled at each loop's natural points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:39:40 -04:00
jyvikoandClaude Opus 5.5 a2d646a75e model: a cancel flag on Progress and progress::check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:39:40 -04:00
jyvikoandClaude Opus 5.5 3363181a01 docs: attribute the void-shell test designs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:20:08 -04:00
jyvikoandClaude Opus 5.5 84f42f6f8a ui: Suppress/Unsuppress on datums and partitions, greyed in the browser (#291)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 5189554a6a model: suppress datums and partitions: kept, but left out of the model (#291)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 dcf03e3378 ui: Rename… in a definition's item menu; the selection follows the new name (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 a0ae8b2111 model: rename a definition and every reference to it (model.rename, #290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 587cbc3b01 ui: seed ticks show where the mesh has another number of divisions (#342, #371)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 980a50c1d9 ui: the Partition form highlights the bodies it would cut (#347, #371)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 3a023f3e25 ui: selecting the Mesh node shows the commands and parameters that made the mesh (#343, #371)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 f9c1c3cb24 ui: the selection filter follows what the selection holds (#341, #371)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 2908a77850 ui: every sample has a thumbnail on the Start card and a gallery capture (#294)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 bbf2dba092 render: translucent faces in both renderers; datum planes get a light fill (#302)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 89dfc62c2f render: perspective hides edges behind faces in CPU renders (#282)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 1f36693c8f ui: one symbol-unit helper for the window, ui.render and headless renders (#285)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 b377c882d6 ui: the sampled-symbols note wraps short of the navigation bar (#206)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 3458117357 ui: the command search runs a command typed with its parameters (#202)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:17:28 -04:00
jyvikoandClaude Opus 5.5 750f5f5a61 model: geometry names up to 64 KiB everywhere, so long persistent face names mesh
Persistent names grow with a design's split history; opening accepted them but mesh tags, sets
and loads capped geometry names at 256 characters. One cap (64 KiB, against hostile input)
now holds for geometry references, partitions and saved views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 10:09:04 -04:00
jyvikoandClaude Opus 5.5 7a078b1c04 ui: results show units, clean numbers, extremes, reaction sums and a solve summary
One formatter (four significant digits, thousands separators, round-off as
0) and the model's units through every results readout:
- legend title with its unit, a mode shape normalised; Max/Min with units,
  clickable to show and probe the node; markers at both in the viewport
  (#321, #330)
- the deformation scale field shows the scale Deformed would use, never 0,
  in the shared format; true scale named (#325, #326)
- setup symbols off over results by default (Show › Loads & BCs), and then
  only the shown step's (#327)
- reaction sums in the results panel however they are run: per set and in
  all, force and moment with units, applied beside reacted (#328)
- the probe: U [mm], |U|, Mises, stresses and principals with units, the
  label kept inside the viewport, rows in the results panel (#329)
- hiding the mesh over a plot hides the element edges, not the contours
  (#317); a hidden body's contours go with it, the range follows what is
  shown (#365)
- the solve summary: peaks with units, n/a rows left out, counts grouped,
  internals under Details (#321, #319)
- the energies plot: axes with units, a cursor at the frame (click or drag
  to move it), hover to read (#331)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:36:54 -04:00
jyvikoandClaude Opus 5.5 91e8467e4f engine, render: hidden bodies hide their mesh and results; solve totals without round-off
- DisplayMesh::from_model_hiding leaves out a hidden body's elements (and the
  nodes only they use); Session::hidden_elements names them, so the viewport,
  probing and headless pictures skip hidden bodies (#365).
- A solve summary's applied and reaction totals set the round-off of
  cancelling sums to 0 (#319).
- results::principal for the probe's principal stresses (#329).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:36:54 -04:00
jyvikoandClaude Opus 5.5 0966dfa2a6 engine: result units, per-step staleness, step symbols, true-scale nonlinear results
- Quantity names the unit of every number the results show from the model's
  unit system (mm, MPa, N, N·mm, mJ, mW, °C or K); Field::title puts it in the
  legend title, and calls a mode shape normalised (#321).
- A solved step's result stays current through edits that leave what it reads
  alone (Model::step_inputs: other steps and what only they use), and comes
  back current on undo (#318).
- Session::step_glyphs: the symbols of what a solved step used, for drawing
  over its results; no load arrows on a frequency step's modes (#327).
- Nonlinear static steps open deformed at true scale like explicit ones, and
  their automatic scale is 1 (auto_scale, shared by the viewport and the
  headless pictures) (#326).
- results.reactions with an empty sets list sums the step's boundary-condition
  sets, as its form says (#328).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:36:54 -04:00
jyvikoandClaude Opus 5.5 a09e3c856b ui: pick vertices, points and cells from the viewport
- Select: Vertices picks the design's B-rep vertices (meshed or not), and
  a selected vertex is marked (#346).
- Use selection on Datum Point (vertex, edge, face, or a hole's centre)
  and Reference Point (geometry.centre: a hole's centre, a vertex, nodes)
  (#346, #351).
- Partitioned Mesh: Use selection takes the picked bodies as cells; the
  cell list has All, None and Invert; hovering a cell's row (or its method
  row) highlights the cell in the viewport (#344).
- Rigid Wall's form takes its set from the selection; the Interactions
  branch lists the explicit steps' rigid walls, each opening its step
  (#350).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00
jyvikoandClaude Opus 5.5 1372094f8a engine: pick-driven points, cells' methods per cell, rigid walls as interactions
- geometry.bodies lists each body's vertices; geometry.datum.point by
  vertex takes the design's vertex names (labelled Vertex, not Mesh
  vertex) (#346).
- geometry.centre: the point for what is picked: a vertex, nodes, a
  hole's centre (cylindrical faces or circular edges), else a centroid
  (#351).
- mesh.cells' form: the cells first, and a method drop-down per cell
  (schema kind RefChoices) instead of JSON (#344).
- interaction.rigid_wall(.delete): rigid walls of an explicit step from
  the Interactions tab, no longer coming soon (#350).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00
jyvikoandClaude Opus 5.5 e8e0b92d87 geometry: persistent names of B-rep vertices (#346)
A vertex is named after the faces that meet there, sorted and joined by |
as edges are, numbered ~k where several touch the same faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00
jyvikoandClaude Opus 5.5 25dfbcce42 geometry: number the pieces of a split body by position (#349)
SolidCraft names the pieces of a split body by stacking suffixes
(Bracket (1) (1)' (1)). On opening a design, the pieces of one body are
named <body>.1 … <body>.N, ordered by centroid, so the tree, tooltips,
forms and picks read as one part with numbered cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00
jyvikoandClaude Opus 5.5 d78a4c2e52 ui: a selected design edge is drawn over the mesh too (#338)
Meshes that do not tag their element edges with the design's edge names
(tet, hex, blocks) left a selected edge with no highlight while the mesh
was shown. The edge is now marked along its own polyline, which the scene
already draws over the mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:27:39 -04:00
jyvikoandClaude Opus 5.5 8ba59701b4 geometry: tests open a closed hollow box, a post on its void floor and an open tray (#364)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:23:40 -04:00
jyvikoandClaude Opus 5.5 358ed2f54f geometry: a feature that fails to rebuild names the bodies it changes, and opening warns
The design file stores no measure to compare with, so the rebuild's own
failures are the guard: each failed feature lists the bodies it names (all of
them when it names none), and geometry.open replies with a warning per failure,
which the window shows with the open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:23:40 -04:00
jyvikoandClaude Opus 5.5 ecd78b9ee6 geometry: rebuild designs with SolidCraft b1695873, whose joins onto a void shell's floor succeed
FieldCraft pinned SolidCraft a629236, which predates #1244: a cylinder joined
onto the floor of a closed hollow body (a coplanar touching face) failed to
rebuild there, and the design opened without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:23:40 -04:00
jyvikoandClaude Opus 5.5 4ee855bb27 ui: jobs with progress, refusals listed, datum preview, form filters and defaults
- A solve or a mesher runs as a job with one progress mechanism: the overlay
  shows the step, stage, count and elapsed time with a bar when bounded, OK
  waits while it runs, and the status sums it up (elements, smallest angle,
  time) (#322, #357).
- Over the control channel such a command is a job too: answered when it
  ends however long it runs, or at once with wait: false, then job.status,
  job.wait, job.cancel; ui.inspect gives job; MCP waits for it (#367).
- A command that cannot run yet lists what it misses in the context panel,
  each with a button to the command that provides it (#323).
- A cancelled or refused solve stays in its workspace (#324, test).
- A mesher form starts from the parameters of the mesh it would replace (#343).
- Seed Edges and edge/vertex/face datum points switch the selection filter
  while open and restore it (#341).
- Datum forms draw the datum live, the Partition form shows its datum, and
  new datums and partitions get the next free name (#347).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 3eaca26246 engine: a mesh made before its seeds changed is out of date; a remesh names a type change
The setup checklist marks the mesh Outdated (a warm ⚠ in the browser, with
what changed and where to remesh) when seeds, partitions or datum planes
changed after the last mesher ran, and model.check warns (#342). Replacing
a mesh asks with the element type change it makes, such as tet10 → tet4 (#343).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 464a4d50c1 solver: heat flow through each heat load, and the imbalance (#362)
solve.heat's reply and the results report give heat_flows: each convection,
flux and source load's heat into the body, the prescribed temperatures'
total and their sum; the results panel lists them for a heat step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 0e0d8bd114 model: progress of long commands, reported by the solvers and the tet mesher
fieldcraft_model::progress: a host installs a Progress on the worker thread
and reads its snapshot from another; the engine names the step being solved,
the nonlinear solver its load factor, the explicit solver its time, the
fluid solver its steps, transient heat its increments and the tet mesher its
stage and pass. Without one installed every report is a no-op (#322, #357).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 a7bb67c452 ui: forms show defaults, JSON hints and whole vectors; spaces in names become _
A checkbox whose key is unset shows the field's default (#353). An empty JSON
field shows its format hint (#340). Vector components share a line only when
each fits a value such as -12000, else take a line each (#339). A space typed
in a definition name becomes _ and the form says why (#320).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 c2ad804fed ui: Mesh Block under Structured Hex, Mirror Mesh its own Edit entry (#348)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 7dd4043afe engine: refuse unknown command parameters, suggest a valid name, document heat loads and thermal materials
Every command with a schema now refuses a top-level key it does not take,
naming the key and the accepted ones (#316). A bad definition name's error
suggests the nearest valid one (#320). The load and material params docs list
the heat loads and the thermal block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:14:34 -04:00
jyvikoandClaude Opus 5.5 35ee88aa18 ui: ties listed under Interactions, selected, edited and deleted like the others (#366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 47bdbf981d ui: a new document clears the viewport's picks; opening a sample says so
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 b89efb55c1 render, ui: prescribed motion and thermal conditions get their own symbols and tree icons (#354, #355)
A non-zero prescribed displacement is an arrow leaving the nodes along the
motion (its held degrees of freedom stay supports); a prescribed temperature
is a thermometer, convection a wave off the faces, a heat flux an arrow onto
them. In the tree, thermal conditions carry the heat icon and a moving
boundary condition a motion arrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 27af5e19b7 ui: the Bodies list by name with sizes, faces and edges; journal deletions and edits badged, tooltips in words (#345, #359)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 de8cdd0b03 ui: a selected set is highlighted in the viewport; seeds are selected, described, edited and deleted from the browser (#336, #352)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 a53c79cb55 ui: properties and status lines in words, current card on top, Use selection makes its set on OK, marks on the left (#335, #333, #360, #337, #315, #361)
- describe.rs: a definition's fields in its form's labels and units (sets by
  members and what they resolve to), for the context panel.
- reply::done: status lines that say what changed ("Showing Load deformed",
  "Mode 2: 1637 Hz", "Added material Al").
- The context panel puts the form and the selection above reports; opening
  another document clears both; what a form makes is selected.
- Use selection reuses a set with the same members, otherwise names one made
  on OK; Cancel leaves the model unchanged. The Target's set row is labelled.
- The browser's state marks sit in one column at its left edge; a right click
  selects the row its menu acts on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 b65f1696b2 ui: name a sample's document after it until saved (#334); "Solve cancelled"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 8819ff787c engine: a prescribed displacement or temperature satisfies the loads stage (#356)
The setup checklist asked for a load on rubber-seal, driven only by a
prescribed displacement, while model.check called it ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:55:25 -04:00
jyvikoandClaude Opus 5.5 febcffefd6 verification: series-resistance heat flow across a tie (#358)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:27:01 -04:00
jyvikoandClaude Opus 5.5 836513ddfe solver: heat transfer across ties (#358)
Ties constrain temperatures with the displacement tie's mortar weights in
steady and transient heat and in coupled temperature-displacement steps; the
heat solver eliminates them with the prescribed temperatures through
constraints::Reduction. refuse_ties is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:27:01 -04:00
jyvikoandClaude Opus 5.5 0ec1882697 solver: tie weights and constraint reduction independent of the structural mesh (#358)
The tie's mortar rows take any node numbering and degree-of-freedom kinds, and
Reduction takes a DofLayout, so heat transfer can reuse both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 08:27:01 -04:00
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
495 changed files with 75405 additions and 16796 deletions

No files matched your search

+11
View File
@@ -1,2 +1,13 @@
# Every change to FieldCraft needs the maintainer's review before it merges.
* @jyviko
# Release and supply-chain paths, named explicitly so they stay owner-reviewed if the rule above
# is ever relaxed: workflows and secrets use, packaging and signing, the updater, dependencies,
# licences.
/.github/ @jyviko
/packaging/ @jyviko
/crates/update/ @jyviko
/Cargo.toml @jyviko
/Cargo.lock @jyviko
/ATTRIBUTION.md @jyviko
/deny.toml @jyviko
+3 -2
View File
@@ -24,7 +24,8 @@ body:
id: evidence
attributes:
label: Evidence
description: Screenshots, log output, numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
description: |
Pictures first: for anything you can see (the view, a mesh, a panel, a plot), drag a screenshot or a short screen recording in here, and mark the problem on it. For a mesh, a section through it (`fieldcraft-cli snapshot … --section`) shows what the outside hides. Then log output and numbers. For a crash, attach the crash report (`fieldcraft-cli crashes`, then `crashes show <id>`).
- type: textarea
id: expected
attributes:
@@ -41,6 +42,6 @@ body:
id: version
attributes:
label: Version and platform
placeholder: "v0.5.8 (or commit), macOS 15 arm64"
placeholder: "v0.6.0 (or commit), macOS 15 arm64"
validations:
required: true
+5
View File
@@ -21,6 +21,11 @@ body:
attributes:
label: Expected behaviour
description: What FieldCraft should do, as precisely as you can.
- type: textarea
id: pictures
attributes:
label: Pictures
description: A screenshot of where it would go, a sketch, or a picture of the result you are after. Drag images in here.
- type: textarea
id: reference
attributes:
+4
View File
@@ -2,10 +2,14 @@ Closes #
## What and why
## Before and after
<!-- For any visible change (the view, meshes, panels, results, icons): a picture before and after. Drag them in here. -->
## How it was verified
<!-- Tests added or changed; oracle cases (tests/oracle) for solver and meshing results, with their cited references. -->
## Checklist
- [ ] The issue was assigned to me before I started (see CONTRIBUTING.md)
- [ ] `cargo fmt --all`, `cargo clippy -p <crates I touched> --all-targets -- -D warnings` and their tests pass
- [ ] Before/after pictures for any visible change
- [ ] No code, text or data from GPL-family projects or commercial CAE products
+7 -4
View File
@@ -1,13 +1,14 @@
FieldCraft is open-source CAE pre-processing in Rust: it turns geometry into analysis-ready
finite element models, writes Abaqus, Nastran and LS-DYNA decks, and solves linear static and
modal problems with its own solver. AI agents can drive it over MCP (`fieldcraft-cli mcp`).
[README](https://github.com/jyviko/fieldcraft#readme) ·
[roadmap](https://github.com/jyviko/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/jyviko/fieldcraft/issues)
[README](https://github.com/tangency-labs/fieldcraft#readme) ·
[roadmap](https://github.com/tangency-labs/fieldcraft/blob/main/ROADMAP.md) ·
[issues](https://github.com/tangency-labs/fieldcraft/issues)
**Which file?**
- **macOS 11+** (Apple silicon and Intel): open `fieldcraft-{version}-macos-universal.dmg` and
drag `FieldCraft.app` onto Applications. The command-line tool is in
drag `FieldCraft.app` onto Applications. The command-line tool is included at
`/Applications/FieldCraft.app/Contents/MacOS/fieldcraft-cli` and also available separately in
`fieldcraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64, glibc 2.35+: Ubuntu 22.04, Debian 12, Fedora 36 or newer):
`fieldcraft-{version}-linux-x86_64.AppImage` (`chmod +x`, then run it), or unpack
@@ -21,6 +22,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**.
+135 -23
View File
@@ -6,8 +6,17 @@ name: CI
# Pull requests from forks (outside contributors) run the full gate, once the maintainer approves
# the run, so the review sees it; so do Dependabot's (.github/dependabot.yml), which nobody tests
# locally. The heavy job stays off pull requests.
#
# What runs when (docs/releasing.md, "What CI runs when"): the `plan` job picks a base, the last
# `develop` commit whose CI run passed (a pull request: its merge base; nightly and manual runs:
# none), and `cargo xtask affected` maps the change since it to crates. `cargo xtask ci --affected`
# then lints and tests those crates and their dependents only; the heavy and oracle jobs run when
# the change reaches the solvers, the oracle or everything (Cargo.toml/Cargo.lock/xtask/workflows).
# Nightly and manual runs, and every release (release.yml), run all of it.
on:
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '**'
- '!**/*.md'
@@ -26,7 +35,7 @@ on:
- 'docs/features.md'
- 'docs/oracle.md'
schedule:
# Nightly, on the default branch (develop): the heavy tests.
# Nightly, on the default branch (develop): the whole gate, the heavy tests and the oracle.
- cron: '23 3 * * *'
# Weekly: the oracle on macOS too, so results that differ by platform show up in CI (the
# fixtures are often blessed on a Mac; #115).
@@ -37,7 +46,8 @@ permissions:
contents: read
# A newer merge cancels the run still going for the one before it, so a burst of merges is tested
# once, at the tip. Nightly and manual runs are grouped apart and never cancelled by a merge.
# once, at the tip; nothing is missed, since the tip's run tests everything since the last green
# run. Nightly and manual runs are grouped apart and never cancelled by a merge.
concurrency:
group: ci-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
@@ -46,56 +56,141 @@ env:
CARGO_TERM_COLOR: always
jobs:
plan:
name: Plan (affected crates)
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 15
permissions:
contents: read
# To find the last green run on develop.
actions: read
outputs:
base: ${{ steps.base.outputs.base }}
everything: ${{ steps.affected.outputs.everything }}
solver: ${{ steps.affected.outputs.solver }}
wasm: ${{ steps.affected.outputs.wasm }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: plan
save-if: ${{ github.ref == 'refs/heads/develop' }}
- name: Pick the base
id: base
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.base_ref }}
run: |
base=""
case "$EVENT" in
push)
# The newest develop commit whose push run passed and that this commit contains.
# Runs that were cancelled or failed are skipped, so their changes are tested here.
for sha in $(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?branch=develop&event=push&status=success&per_page=100" --jq '.workflow_runs[].head_sha'); do
if [ "$sha" != "$GITHUB_SHA" ] && git merge-base --is-ancestor "$sha" HEAD; then
base="$sha"
break
fi
done
;;
pull_request)
base="$(git merge-base "origin/$PR_BASE" HEAD || true)"
;;
esac
# Nightly and manual runs, or no green run to start from: everything.
echo "base=$base" >>"$GITHUB_OUTPUT"
echo "### Base: \`${base:-none (everything)}\`" >>"$GITHUB_STEP_SUMMARY"
- name: cargo xtask affected
id: affected
env:
BASE: ${{ steps.base.outputs.base }}
run: |
if [ -n "$BASE" ]; then
cargo xtask affected --base "$BASE" --github-output | tee -a "$GITHUB_STEP_SUMMARY"
else
printf 'everything=true\nsolver=true\nwasm=true\n' >>"$GITHUB_OUTPUT"
fi
bots:
name: Claim and triage bot tests
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
# scripts/claim and scripts/triage run in .github/workflows/claim.yml and triage.yml through
# actions/github-script; their tests use only node:test against a fake client.
- run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
ci:
name: cargo xtask ci
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
needs: plan
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
# Shared keys: release.yml's verify jobs restore these caches (they save only on main).
shared-key: ci
save-if: ${{ github.ref == 'refs/heads/develop' }}
# A dependency update changes THIRD-PARTY-LICENSES.txt, which Dependabot cannot regenerate:
# regenerate it here so the gate checks everything else, and dependabot-licences.yml commits
# it to the pull request.
- name: Regenerate THIRD-PARTY-LICENSES.txt (Dependabot)
if: github.event.pull_request.user.login == 'dependabot[bot]'
run: cargo xtask licences
- run: cargo xtask ci
- name: Software GPU (lavapipe), so headless snapshots run the GPU pipeline
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: cargo xtask ci (the affected crates, or everything)
env:
BASE: ${{ needs.plan.outputs.base }}
# The drawing tests run on lavapipe and fail, instead of skipping, without it.
FIELDCRAFT_GPU: software
FIELDCRAFT_REQUIRE_GPU: "1"
run: |
if [ -n "$BASE" ]; then cargo xtask ci --affected "$BASE"; else cargo xtask ci; fi
heavy:
# The heavy verification and corpus tests (#[ignore]d) and the doc tests.
# The heavy verification and corpus tests (#[ignore]d) and the doc tests, when the change
# reaches them.
name: cargo xtask test --heavy
if: github.event.schedule != '41 4 * * 0' && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
needs: plan
if: github.event_name != 'pull_request' && (needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true')
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: heavy
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask test --heavy
oracle:
# Verification and validation (docs/verification.md): every case against its reference and
# its fixture, and docs/oracle.md must be the table the cases produce. Cases marked `slow`
# run here too while the whole set stays within a few minutes; `--quick` skips them.
# run here too while the whole set stays within a few minutes; `--quick` skips them. It runs
# when the change reaches the solvers or the oracle's package.
name: cargo xtask oracle
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
needs: plan
if: needs.plan.outputs.solver == 'true' || needs.plan.outputs.everything == 'true'
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
shared-key: oracle
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
@@ -113,20 +208,37 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle-macos
shared-key: oracle-macos
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
windows:
# The desktop app's tests on Windows, nightly and on demand: the Windows-only code
# (apps/fieldcraft/src/native_crash.rs, the exception filter) builds and a child process that
# faults on purpose leaves its native crash report (#299). Releases build Windows too.
name: Windows app tests
if: github.event.schedule == '23 3 * * *' || github.event_name == 'workflow_dispatch'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: windows
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo test --locked --profile ci -p fieldcraft
advisories:
name: Dependency advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
runs-on: ubuntu-24.04
name: Dependency licences, advisories and sources
if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || (!github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')))
runs-on: ${{ github.event_name != 'pull_request' && vars.SELF_HOSTED_RUNNER || 'ubuntu-24.04' }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.20.2 --locked
- run: cargo deny --locked check --warn unmaintained advisories bans sources
- run: cargo deny --locked check --warn unmaintained advisories bans licenses sources
+53
View File
@@ -0,0 +1,53 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Before you start: claim an issue"), and a
# daily sweep that frees claims with no visible progress for 14 days. A pull request that
# references an issue (`Closes #N`, `<summary> (#N)`) claims it too. The logic is in
# scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
-40
View File
@@ -1,40 +0,0 @@
# Dependabot's Cargo updates change THIRD-PARTY-LICENSES.txt (`cargo xtask licences`), which
# Dependabot cannot regenerate: this commits the regenerated file to its pull request. CI's own
# run on the pull request regenerates it too, so its result covers everything else.
name: Dependabot licences
on:
pull_request:
branches: [develop]
paths: [Cargo.lock]
permissions:
contents: write
concurrency:
group: dependabot-licences-${{ github.head_ref }}
cancel-in-progress: true
jobs:
licences:
if: github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.head_ref }}
- uses: dtolnay/rust-toolchain@stable
- run: cargo xtask licences
- name: Commit the regenerated file
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt; then
echo "THIRD-PARTY-LICENSES.txt is up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# [skip ci]: the pull request's CI run already regenerated this file and tested the
# tree with it; a bot-authored commit would otherwise wait for a maintainer's approval.
git commit -m "deps: regenerate THIRD-PARTY-LICENSES.txt [skip ci]" -- THIRD-PARTY-LICENSES.txt
git push origin "HEAD:${GITHUB_HEAD_REF}"
+3
View File
@@ -9,6 +9,8 @@ on:
branches: [develop]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
# A draft starts no run; marking it ready for review does.
types: [opened, synchronize, reopened, ready_for_review]
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
@@ -22,6 +24,7 @@ concurrency:
jobs:
lint:
name: Packaging lint
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
+9 -1
View File
@@ -1,4 +1,4 @@
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://jyviko.github.io/fieldcraft/
# The web app (apps/fieldcraft-web, docs/web.md) on GitHub Pages: https://tangency-labs.github.io/fieldcraft/
# After SolidCraft's pages.yml (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `main` (a release; main holds released code only) builds the app with Trunk (release,
@@ -33,6 +33,10 @@ env:
jobs:
build:
# POSTHOG_KEY lives in the `release` environment (main only, docs/releasing.md). The deploy job
# already uses github-pages and a job has one environment, so the build job reads the key: on
# main it runs in `release`, elsewhere in no environment and with an empty key.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
@@ -53,6 +57,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: |
+139 -9
View File
@@ -3,10 +3,12 @@
#
# 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.
# pre-release. Every package, and the release itself, carries THIRD-PARTY-LICENSES.txt, generated
# here by `cargo xtask licences` (it is not committed). A person verifies the draft and publishes
# it; this workflow never publishes, and pushes to develop or tags never start it.
#
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
# Linux x86_64 (glibc 2.35+) .tar.gz, .AppImage, .deb and .rpm packaging/linux
@@ -15,7 +17,14 @@
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# Dry run: `gh workflow run release.yml --ref <branch>` builds everything and uploads the
# The verify jobs run everything CI can: the full `cargo xtask ci`, `cargo xtask test --heavy` and
# `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's CI tests only what each change
# affects (ci.yml), so this is where a release proves all of it. The packaging jobs build in
# parallel with them and only upload workflow artifacts; the `release` job, the only one that
# signs SHA256SUMS.txt or touches a GitHub Release, needs every verify and packaging job, so a
# failing verify means no draft and no signature.
#
# Dry run: `gh workflow run release.yml --ref <branch>` verifies and builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
# the draft.
name: Release
@@ -39,8 +48,10 @@ concurrency:
cancel-in-progress: false
env:
# The same CARGO_* environment as ci.yml: rust-cache hashes it into the cache key, so a
# difference would stop the verify jobs restoring develop's caches. (rust-cache already sets
# CARGO_INCREMENTAL=0 for every job.)
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
@@ -69,9 +80,68 @@ jobs:
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The full gate, the heavy tests and the oracle on Linux and macOS: the `release` job needs them,
# so a release never drafts or signs a commit that fails any of them. Each restores the cache of
# its ci.yml counterpart on develop (same shared-key), so it starts warm.
verify-ci:
name: Verify (cargo xtask ci)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ci
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask ci
verify-heavy:
name: Verify (cargo xtask test --heavy)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: heavy
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask test --heavy
verify-oracle:
name: Verify (cargo xtask oracle, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
cache: oracle
- os: macos-14
cache: oracle-macos
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ${{ matrix.cache }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
macos:
name: macOS universal
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: macos-14
timeout-minutes: 120
outputs:
@@ -88,6 +158,8 @@ jobs:
key: release-macos-universal
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -97,6 +169,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 }}
@@ -115,6 +189,9 @@ jobs:
name: Linux x86_64
needs: version
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
@@ -134,11 +211,16 @@ jobs:
key: release-linux-x86_64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- name: Install cargo-deb and cargo-generate-rpm
run: |
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: |
@@ -195,6 +277,9 @@ jobs:
windows:
name: Windows x64
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: windows-latest
timeout-minutes: 90
env:
@@ -209,8 +294,13 @@ jobs:
key: release-windows-x64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- 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:
@@ -220,7 +310,11 @@ jobs:
release:
name: Draft release (or dry-run artifacts)
needs: [version, macos, linux, windows]
# The only job that publishes anything: it waits for every verify and packaging job.
needs: [version, verify-ci, verify-heavy, verify-oracle, macos, linux, windows]
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: ubuntu-24.04
permissions:
contents: write
@@ -237,22 +331,58 @@ jobs:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Check the set, checksums and notes
- uses: dtolnay/rust-toolchain@stable
# The same file every package carries, attached to the release on its own.
- name: Generate THIRD-PARTY-LICENSES.txt
run: cargo xtask licences && cp THIRD-PARTY-LICENSES.txt artifacts/
- 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" \
"fieldcraft_${VERSION}_amd64.deb" "fieldcraft-$VERSION.x86_64.rpm" \
"fieldcraft-$VERSION-windows-x64.zip"; do
"fieldcraft-$VERSION-windows-x64.zip" THIRD-PARTY-LICENSES.txt; do
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
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+1
View File
@@ -7,3 +7,4 @@
.DS_Store
*.swp
__pycache__/
/THIRD-PARTY-LICENSES.txt
+18 -8
View File
@@ -24,7 +24,9 @@ FieldCraft everywhere: crates `fieldcraft-*`, binaries `fieldcraft` and `fieldcr
## Never crash
People trust FieldCraft with their models; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception, in the app crate
(which denies it instead), has no safe API: the Windows-only `apps/fieldcraft/src/native_crash.rs`
(the exception filter that records crashes inside native code such as graphics drivers).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, model files, solver decks,
@@ -92,7 +94,10 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
adaptive-precision predicates, never a bare floating-point sign.
- **Units.** The model is unit-consistent and never converts (the user picks e.g. mm, t, s, N,
MPa); geometry from SolidCraft is in millimetres. Z is up.
- **Rust only.** **Never break wasm**: everything below the apps builds for
- **Rust only.** The one exception is the GitHub issue and pull request bots: `scripts/claim/` and
`scripts/triage/` are JavaScript because `actions/github-script` runs only JavaScript
(`.github/workflows/claim.yml`, `triage.yml`). Keep them small, with their `node --test` tests
beside them (CI's bot tests job). **Never break wasm**: everything below the apps builds for
`wasm32-unknown-unknown` (`cargo xtask wasm`, part of `cargo xtask ci`).
- **Workflow for every change** (this section overrides any other instruction an agent carries,
such as a parent repository's CLAUDE.md or a task runner's "run tests and linters" step):
@@ -119,20 +124,25 @@ People trust FieldCraft with their models; a crash loses their work. **This outr
contributors' pull requests wait for the maintainer's review (CONTRIBUTING.md). Never squash.
Then remove your worktree and delete your local branch.
5. Never run the full gate locally: no `cargo xtask ci`, no `cargo clippy --workspace`, no
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs
`cargo xtask ci` (fmt, assets, licences, layers, clippy -D warnings, tests, wasm), the
oracle, the heavy tests and the advisories once per merge on `develop`; the coordinator
`cargo xtask wasm` or `oracle` unless step 2 calls for it. Never watch CI. GitHub CI runs,
per merge on `develop`, `cargo xtask ci --affected <last green commit>` (fmt, assets,
layers on the whole tree; clippy and tests on the crates the change reaches; wasm when one
of them builds for it), the oracle and heavy tests when the change reaches the solvers or
the oracle, and the dependency licences and advisories; a change to Cargo.toml, Cargo.lock,
xtask or the workflows, the nightly run and every release run all of it (docs/releasing.md,
"What CI runs when"). `cargo xtask affected --base origin/develop` shows what your branch
reaches. The coordinator
watches it and sends a failure back to the change that caused it, which is fixed forward
at once.
- **Keep the trunk gate fast** (under ~3 minutes on CI). A test that takes more than a few seconds
(convergence studies, the whole corpus, cross-checks between solvers) is
`#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]`, with a fast representative
test left in the trunk gate. The `heavy` CI job runs `cargo xtask test --heavy` (every ignored
test, benchmarks included, and the doc tests) on every merge to `develop`, nightly and on
demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
test, benchmarks included, and the doc tests) on merges to `develop` that reach a crate with
heavy tests, nightly, before every release and on demand (`gh workflow run CI --ref <branch>`). Hostile-input tests run commands on sessions with small
work caps (`Session::limits`) so refusals are instant; never lower the production caps for a
test.
- **Publishing**: `origin` is github.com/jyviko/fieldcraft. Changes land through pull requests
- **Publishing**: `origin` is github.com/tangency-labs/fieldcraft. Changes land through pull requests
as the workflow above describes; the coordinator reads every merged diff and sends findings
back as fix-forward work. Never force-push `develop` or `main`; `main` holds released code and moves only by
fast-forward at a release (docs/releasing.md). Never commit `plan/`, `.agent-files/`,
+45 -2
View File
@@ -24,12 +24,35 @@ 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 |
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/ibeam.png | FieldCraft contributors | Original: the solved ibeam sample rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the ibeam sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/heated-flow.png | FieldCraft contributors | Original: the heated-flow sample's temperature slice rendered directly at 320 × 213 by `fieldcraft-cli snapshot` with the CPU renderer; geometry is the original upright-bracket recipe | MIT OR Apache-2.0 |
| docs/media/examples/parts.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the parts sample (meshes only, nothing to solve) (docs/examples.md) | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/flow-past.png | FieldCraft contributors | Original: the flow-past sample as docs/media/examples/flow-past.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the flow-past sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/cantilever.png | FieldCraft contributors | Original: the cantilever sample as docs/media/examples/cantilever.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the cantilever sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/impact.png | FieldCraft contributors | Original: the impact sample as docs/media/examples/impact.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the impact sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/parts.png | FieldCraft contributors | Original: the parts sample as docs/media/examples/parts.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the parts sample's thumbnail on the Start card | MIT OR Apache-2.0 |
| crates/ui-egui/thumbnails/bracket-hex.png | FieldCraft contributors | Original: the bracket-hex sample as docs/media/examples/bracket-hex.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 crank-arm sample as docs/media/examples/crank-arm.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 crank-mirror sample as docs/media/examples/crank-mirror.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 drop-test sample as docs/media/examples/drop-test.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 heated-bracket sample as docs/media/examples/heated-bracket.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 rubber-seal sample as docs/media/examples/rubber-seal.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the 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 |
| crates/engine/tests/designs/pi5-enclosure.json | FieldCraft contributors | Original SolidCraft recipe (solid.* commands) of an enclosure for a single-board computer, for thermal analysis: base, lid, heat sink, board, chips, connectors and the air inside | MIT OR Apache-2.0 |
| crates/geometry/tests/designs/hollow-box.solidcraft, crates/geometry/tests/designs/hollow-box-post.solidcraft, crates/geometry/tests/designs/open-tray-post.solidcraft | FieldCraft contributors | Built with `solidcraft-cli run` from the recipes beside them (crates/geometry/tests/designs/*.json) | 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 |
@@ -37,11 +60,19 @@ is committed and listed here with its exports.
| assets/icons/file.save_as.svg, assets/icons/file.save_as.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.save_as.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| 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.check.svg, assets/icons/geometry.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.check.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 |
| assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.defeature.svg, assets/icons/geometry.defeature.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.defeature.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -49,6 +80,8 @@ is committed and listed here with its exports.
| assets/icons/interaction.contact.general.svg, assets/icons/interaction.contact.general.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.general.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.contact.pair.svg, assets/icons/interaction.contact.pair.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.contact.pair.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.fsi.svg, assets/icons/interaction.fsi.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.block.svg, assets/icons/mesh.block.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.block.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.mirror.svg, assets/icons/mesh.mirror.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.mirror.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/model.mass.create.svg, assets/icons/model.mass.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.mass.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.rigid_wall.svg, assets/icons/interaction.rigid_wall.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.rigid_wall.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/model.spring.create.svg, assets/icons/model.spring.create.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.spring.create.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -65,19 +98,26 @@ is committed and listed here with its exports.
| assets/icons/mesh.layers.svg, assets/icons/mesh.layers.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.layers.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.add.svg, assets/icons/mesh.node.add.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.add.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.merge.svg, assets/icons/mesh.node.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.relax.svg, assets/icons/mesh.node.relax.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.relax.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.node.move.svg, assets/icons/mesh.node.move.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.node.move.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.mark.svg, assets/icons/mesh.quality.mark.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.mark.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.size.set.svg, assets/icons/mesh.size.set.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.set.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.tet.generate.svg, assets/icons/mesh.tet.generate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tet.generate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.hex.extrude.svg, assets/icons/mesh.hex.extrude.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.hex.extrude.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.plan.svg, assets/icons/mesh.plan.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.plan.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.planar.svg, assets/icons/mesh.planar.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.planar.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.transition.svg, assets/icons/mesh.transition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.transition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.untangle.svg, assets/icons/mesh.untangle.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.untangle.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.verify.svg, assets/icons/mesh.verify.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.verify.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/model.activity.svg, assets/icons/model.activity.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/model.activity.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -141,6 +181,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 |
@@ -172,11 +213,12 @@ is committed and listed here with its exports.
| clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 |
| crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 |
| crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
| crates/gpu/src/view.rs (viewport pipelines and shaders), crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 |
| crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 |
| 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, startup steps, native crash reports, report folders and limits, replay), apps/fieldcraft/src/native_crash.rs (Windows exception filter and minidump), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft/src/native_crash.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 |
@@ -190,6 +232,7 @@ is committed and listed here with its exports.
| .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 |
| .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 |
| .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 |
| .github/workflows/claim.yml, .github/workflows/triage.yml, scripts/claim/, scripts/triage/ (the claim and triage bots and their tests) | SolidCraft `.github/workflows/claim.yml`, `triage.yml`, `scripts/claim/`, `scripts/triage/` | MIT OR Apache-2.0 |
## Test oracles
+65 -22
View File
@@ -15,7 +15,7 @@ needed most, and how to claim work. The rules every change follows, by a person
You need stable Rust 1.95 or newer.
```sh
git clone https://github.com/jyviko/fieldcraft && cd fieldcraft
git clone https://github.com/tangency-labs/fieldcraft && cd fieldcraft
cargo run --release -p fieldcraft -- --sample cantilever # the desktop app on a sample model
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo test -p fieldcraft-solver # the tests of one crate
@@ -28,32 +28,32 @@ cargo xtask oracle --quick # the verification ca
## Where help is wanted most
See the [`help wanted`](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) labels, and
See the [`help wanted`](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) labels, and
the `area:` labels for your field. In order of how much they would help users:
1. **Reproducible meshes** ([#115](https://github.com/jyviko/fieldcraft/issues/115)). The same part
1. **Reproducible meshes** ([#115](https://github.com/tangency-labs/fieldcraft/issues/115)). The same part
must give the same tet mesh on every platform and every run, as the solvers already do.
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/jyviko/fieldcraft/issues/85)).
2. **All-hex meshing of thin and curved parts** ([#85](https://github.com/tangency-labs/fieldcraft/issues/85)).
The octree mesher over-refines thin walls; concave transition templates and an up-front depth
are next. Hex meshing is the centre of gravity of the project.
3. **Getting results in and out**: ParaView export ([#90](https://github.com/jyviko/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/jyviko/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/jyviko/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/jyviko/fieldcraft/issues/92), [#93](https://github.com/jyviko/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/jyviko/fieldcraft/issues/97),
[#106](https://github.com/jyviko/fieldcraft/issues/106)). An engineer with a published benchmark
3. **Getting results in and out**: ParaView export ([#90](https://github.com/tangency-labs/fieldcraft/issues/90)),
Gmsh import ([#91](https://github.com/tangency-labs/fieldcraft/issues/91)), Abaqus deck reading
([#98](https://github.com/tangency-labs/fieldcraft/issues/98)), CSV and STL
([#92](https://github.com/tangency-labs/fieldcraft/issues/92), [#93](https://github.com/tangency-labs/fieldcraft/issues/93)).
4. **Verification benchmarks** ([#97](https://github.com/tangency-labs/fieldcraft/issues/97),
[#106](https://github.com/tangency-labs/fieldcraft/issues/106)). An engineer with a published benchmark
can add it as an oracle case without touching solver code.
5. **Nonlinear elements and materials**: incompatible modes at finite strain
([#82](https://github.com/jyviko/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/jyviko/fieldcraft/issues/81)), and the material library
([#99](https://github.com/jyviko/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/jyviko/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/jyviko/fieldcraft/issues/94), [#95](https://github.com/jyviko/fieldcraft/issues/95)).
([#82](https://github.com/tangency-labs/fieldcraft/issues/82)), kinematic hardening
([#81](https://github.com/tangency-labs/fieldcraft/issues/81)), and the material library
([#99](https://github.com/tangency-labs/fieldcraft/issues/99)).
6. **The app**: typed command forms ([#112](https://github.com/tangency-labs/fieldcraft/issues/112)), XY and
path plots ([#94](https://github.com/tangency-labs/fieldcraft/issues/94), [#95](https://github.com/tangency-labs/fieldcraft/issues/95)).
Bigger areas on the [roadmap](ROADMAP.md) — contact, dynamics, composites, coupled fields, wave
propagation, turbulence — are open to anyone who wants to own one: start a thread in
[Discussions](https://github.com/jyviko/fieldcraft/discussions).
[Discussions](https://github.com/tangency-labs/fieldcraft/discussions).
## Before you start: claim an issue
@@ -61,12 +61,53 @@ Every change starts from an issue, and only the maintainer merges.
1. Pick an open issue labelled **help wanted** or **good first issue** that is not labelled
**claimed**. If what you want to do has no issue, open one first and wait for a reply.
2. Comment that you would like to take it. The maintainer assigns it to you and labels it
**claimed**; then it is yours. Work on unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. An issue with no visible progress
for 14 days goes back to the pool.
2. Comment `/claim` on it. The bot assigns it to you, labels it **claimed** and replies; then it is
yours. It refuses an issue someone else holds (naming them and when their claim lapses), an
issue an open pull request already covers, and an issue without **help wanted** or **good
first issue**: on those, say what you would like to do and the maintainer replies. Work on
unassigned issues may not be accepted.
3. Open a draft pull request early, linked with `Closes #N`. A pull request that references an
open-for-contributors issue nobody holds (`Closes #N` in the body, `(#N)` in the title) claims
it the same way; if someone holds it already, or an older open pull request references it,
the bot says so on your pull request instead.
4. An issue with no visible progress for 14 days goes back to the pool: a daily sweep frees a claim
with no linked pull request 14 days after it was made, and one with a pull request after 14
days without pushes. `/unclaim` releases it early (you or a maintainer).
Issues labelled **in progress** are being worked on by the core team; please don't start on them.
The bot never lets them be claimed, and never frees an issue a maintainer holds.
## Labels and triage
The triage bot labels every new issue and pull request (`.github/workflows/triage.yml`). It only
adds labels; correct them freely and it won't put them back.
- **Pull requests** get `area:*` from the paths they change: `crates/mesh` → `area:mesh`,
`crates/lbm*` → `area:cfd`, `crates/solver`, `crates/fem`, `crates/explicit` → `area:solver`,
`crates/ui-egui` and `apps/` → `area:ui`, `crates/io`, `crates/decks` → `area:io`, `docs/` and
top-level `*.md` → `area:docs`, `.github/`, `xtask/`, `packaging/`, `scripts/`, `tools/` →
`area:infra`.
- **Issues** get `bug` or `enhancement` from the form they were filed with (or a crash or
"Feature request" title), and an `area:*` from words in the title or the command an in-app
report names. Until it has a priority, an issue carries `needs-triage`; adding a `priority:`
label removes it.
- **Maintainers** comment `/triage area:mesh high S` to sort an issue or pull request: any
existing label (case, and `-` or `_` for a space, don't matter: `in-progress`), `high` for
`priority: high`, `S`/`M`/`L` for a size (which replaces the old one), `-label` removes one.
Any `/triage` with a change clears `needs-triage`.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, reports filed from the app (**Help › Report a Bug…**),
and maintainers' issues are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/fieldcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## Reporting a problem or an idea
Use the templates under **New issue**. Show it: for anything you can see — the view, a mesh, a
panel, a plot — add a screenshot or a short screen recording (drag it into the issue) and mark
the problem on it. For a mesh, a section (`fieldcraft-cli snapshot … --section`) shows what the
outside hides. A picture is often the fastest way for someone else to see what you see.
## Making the change
@@ -78,7 +119,9 @@ Issues labelled **in progress** are being worked on by the core team; please don
3. User-visible behaviour is a command (`crates/engine/src/cmd/`) with tests, including hostile
parameters.
4. Meshing and solver algorithms cite the paper they come from in the module docs.
5. Never contribute code, text or data from GPL-family projects or commercial CAE products
5. A change you can see (the view, meshes, panels, results, icons) shows a picture before and after
in the pull request.
6. Never contribute code, text or data from GPL-family projects or commercial CAE products
(see [docs/adr/0001-libraries.md](docs/adr/0001-libraries.md)).
By contributing you agree your work is licensed under MIT OR Apache-2.0.
Generated
+504 -49
View File
File diff suppressed because it is too large. Load diff
+21 -15
View File
@@ -3,12 +3,12 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.8"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
authors = ["FieldCraft contributors"]
repository = "https://github.com/jyviko/fieldcraft"
repository = "https://github.com/tangency-labs/fieldcraft"
[workspace.dependencies]
# internal
@@ -27,6 +27,9 @@ 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" }
fieldcraft-gpu = { path = "crates/gpu" }
fieldcraft-lbm-gpu = { path = "crates/lbm-gpu" }
# foundation
thiserror = "2"
@@ -34,9 +37,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# 3MF packages (docs/formats/tagged-mesh.md): the same versions SolidCraft uses.
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# 3MF packages (docs/formats/tagged-mesh.md). zip is ahead of SolidCraft (still on 3), so both are built.
zip = { version = "9", default-features = false, features = ["deflate"] }
quick-xml = "0.42"
# content hashes and part checksums of .fieldcraft files (the version SolidCraft uses)
sha2 = "0.11"
@@ -50,6 +53,9 @@ resvg = { version = "0.48", default-features = false }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
# the wgpu egui-wgpu uses, for the offscreen viewport (crates/gpu); backends come per target there
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
pollster = "1.0"
# Shared Craft-family egui components (icon buttons, tabs, widget palette); the same rev as
# VectorCraft. MIT OR Apache-2.0.
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675" }
@@ -58,13 +64,13 @@ rfd = "0.17"
# Geometry: SolidCraft's B-rep kernel (MIT OR Apache-2.0), pinned. Only crates/geometry uses it
# (plan/adr/0001-libraries.md).
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
solidcraft-geom = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
solidcraft-kernel = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
solidcraft-doc = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
solidcraft-io = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
# Runs SolidCraft command scripts (the test corpus recipes); only behind fieldcraft-geometry's
# `scripts` feature.
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
solidcraft-engine = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -128,10 +134,10 @@ debug = "line-tables-only"
# reach a dependent workspace, so FieldCraft applies the same ones from the same pinned commit;
# keep this list and the rev in step with SolidCraft's [patch.crates-io].
[patch.crates-io]
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
truck-shapeops = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
truck-stepio = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
truck-meshalgo = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
matext4cgmath = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
# egui-wgpu with SolidCraft's fixes (vendor/egui-wgpu there): MSAA clamped to what the device
# supports and 1x on OpenGL adapters, so the requested 4x never fails pipeline validation at start.
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "a629236650c44ee73edf36fab0dcd2385103a610" }
egui-wgpu = { git = "https://github.com/bherbruck/solidcraft", rev = "b1695873f5d0031f51a8d5e7ec036fced6f000e2" }
+1 -1
View File
@@ -6,7 +6,7 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
Third-party material
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`). Geometry comes from SolidCraft (https://github.com/bherbruck/solidcraft,
(generated by `cargo xtask licences`, in every release package). Geometry comes from SolidCraft (https://github.com/bherbruck/solidcraft,
MIT OR Apache-2.0), which builds on the truck crates (https://github.com/ricosjp/truck, Apache-2.0,
Copyright (c) RICOS Co. Ltd.), some with SolidCraft's modifications.
+21 -9
View File
@@ -10,8 +10,8 @@ see the results — with a nonlinear implicit solver (large deformation, plastic
in place. Hex meshing is the centre of gravity. Next: buckling and arc-length, contact, then
the wider material and multiphysics library. See [ROADMAP.md](ROADMAP.md).
**Want to help?** Pick an issue labelled
[good first issue](https://github.com/jyviko/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/jyviko/fieldcraft/labels/help%20wanted) and read
[good first issue](https://github.com/tangency-labs/fieldcraft/labels/good%20first%20issue) or
[help wanted](https://github.com/tangency-labs/fieldcraft/labels/help%20wanted) and read
[CONTRIBUTING.md](CONTRIBUTING.md).
![Claude analyses an I-beam cantilever in FieldCraft over MCP: it opens the part, hex meshes it, sets steel, the clamp and a 1 kN tip load, solves, shows von Mises stress and the deformed shape, and checks the tip deflection against beam theory](docs/media/claude-mcp.gif)
@@ -28,15 +28,15 @@ theory with shear 0.177 mm).
## Download
[![Download for macOS](https://img.shields.io/badge/macOS-Download-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Download for Linux](https://img.shields.io/badge/Linux-Download-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/jyviko/fieldcraft/releases/latest)
[![Try it in your browser](https://img.shields.io/badge/Browser-Try%20it-2ea44f?style=for-the-badge&logo=webassembly&logoColor=white)](https://jyviko.github.io/fieldcraft/)
[![Download for macOS](https://img.shields.io/badge/macOS-Download-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Download for Windows](https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Download for Linux](https://img.shields.io/badge/Linux-Download-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/tangency-labs/fieldcraft/releases/latest)
[![Try it in your browser](https://img.shields.io/badge/Browser-Try%20it-2ea44f?style=for-the-badge&logo=webassembly&logoColor=white)](https://tangency-labs.github.io/fieldcraft/)
- **macOS:** open the `.dmg` and drag FieldCraft into Applications (Apple silicon and Intel).
- **Windows:** unzip and run `fieldcraft.exe`.
- **Linux:** the `.AppImage` (allow it to run as a program), or the `.tar.gz`.
- **Browser:** [try it at jyviko.github.io/fieldcraft](https://jyviko.github.io/fieldcraft/), no install.
- **Browser:** [try it at tangency-labs.github.io/fieldcraft](https://tangency-labs.github.io/fieldcraft/), no install.
- **Command line:** `fieldcraft-cli` ships in every package; AI agents connect to it over MCP
([docs/mcp.md](docs/mcp.md)).
@@ -114,10 +114,11 @@ against a closed form or a reference solution ([docs/examples.md](docs/examples.
## Build
```sh
cargo xtask ci # fmt, assets, licences, layers, clippy, tests, wasm
cargo xtask ci # fmt, assets, layers, clippy, tests, wasm
cargo xtask test --heavy # the heavy (ignored) tests and the doc tests
cargo run -p fieldcraft-cli -- commands # every command and its parameters
cargo run -p fieldcraft-cli -- run script.json # run a command script
cargo run -p fieldcraft-cli -- snapshot script.json --out shot.png # draw it to a PNG, no window
cargo run -p fieldcraft-cli -- mcp # MCP server on stdio for AI agents (docs/mcp.md)
```
@@ -135,10 +136,21 @@ 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
are in [NOTICE](NOTICE); third-party licences in [THIRD-PARTY-LICENSES.txt](THIRD-PARTY-LICENSES.txt).
are in [NOTICE](NOTICE); third-party licences in
THIRD-PARTY-LICENSES.txt, which every release package carries (`cargo xtask licences` writes it).
FieldCraft is an independent project, not affiliated with, sponsored or endorsed by Dassault
Systèmes, MSC Software, Siemens or Ansys. Abaqus, Nastran, MSC Nastran, Simcenter Nastran and
+60 -6
View File
@@ -28,17 +28,17 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|---|---|---|
| M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done |
| M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`fieldcraft-mesh::surface`, `mesh.surface.check`, done) | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| M2 Tet meshing and size field | Size field (global, curvature and proximity; edge seeds done: `mesh.seed.edge` on persistent design edge names (`<face>|<face>`) with a count or size, geometric bias and an exact, min or free constraint, shown as ticks and under Mesh › Seeds, honoured by the hex sweep, extrude, revolve, mapped, blocks and cells meshers through interval assignment (conflicting exact seeds named) and by the tet mesher, which divides the edge at the seed's nodes and grades its size field from them; per-face seeds to do; the `fieldcraft_mesh::size::SizeField` trait and edge-division helpers are in; size controls done (#435): `mesh.size.set` on faces, bodies, a box or a sphere with a growth rate (`mesh.size.growth`), saved with the model, listed by `mesh.size.list`, drawn in the viewport, honoured by the tet mesher's graded field, the structured hex meshers' and the block projection's divisions and the octree's depth); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `fieldcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, flip repair of rounding (§5.1) checked by the validity gate; `fieldcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); quality stage (done: `fieldcraft-mesh::tet` — curvature-graded size field, edge-collapse coarsening, constrained Delaunay refinement with boundary points on the B-rep faces and curves, ODT smoothing, Klingner–Shewchuk improvement, tet4 and tet10 with mid-side nodes on the B-rep, quality report; one validity criterion shared by the gate and both solvers; the filleted-bracket fillet stress converges, tests/oracle); mesh verify per set | in progress |
| 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 |
| 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 |
| 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 (node tools done: `mesh.node.merge` equivalences coincident nodes with sets and tags following the survivors and element collapses left out, previewed in its form; `mesh.check.duplicates` lists unmerged nodes, repeated elements, cracks and free edges and selects them; `mesh.renumber` compact, by body or in reverse Cuthill–McKee order, every reference following) 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 |
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
@@ -50,9 +50,46 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M21 Biomechanics and biomaterials | Soft tissue: Holzapfel–Gasser–Ogden fibre-reinforced hyperelasticity with dispersion, Fung, transversely isotropic models; biphasic and poro-hyperelastic cartilage (M19); active muscle (Hill-type); hydrogel swelling. Bone: density-dependent moduli from CT data, strain-energy-density remodelling (Huiskes, Beaupré). Growth and remodelling by kinematic growth (F = Fe·Fg). Implants and biomaterials (titanium, PEEK, UHMWPE) from the M17 library. Verified against published experiments with their tolerances | planned |
| M22 Design studies | Parameters of the SolidCraft design driven from FieldCraft: parametric sweeps, design of experiments, response surfaces and sensitivities; topology optimisation (SIMP with filters, manufacturing constraints); size and shape optimisation; submodelling (driven boundaries from a global model); uncertainty quantification | planned |
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 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: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 62 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 15 published benchmarks (the MacNeal–Harder solid tests, the Scordelis–Lo roof with solids and with shells, the pinched cylinder, NAFEMS LE1 in 3D and 2D, LE2, LE3, LE5, LE7, LE10, FV32) and 8 pending cases with citations (the pinched hemisphere and twisted beam shell meshes, thermal, Cook's membrane, Taylor bar, CFD, explicit Hertz contact); `mesh.block` mapped blocks and 2D patches build their meshes, curved shell patches are written node by node | started |
| Continuous: 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 |
## 0.6.0: the UI/UX release
What landed for 0.6.0 (milestone 0.6.0 on GitHub):
- **Browser:** status checks in one aligned column, Interactions counting ties, a selected set
highlighted in the viewport, seeds selectable, editable and deletable, definitions shown with
units instead of raw JSON, one highlighted item per context menu, given names shown as typed,
and a setup checklist that does not ask for a load on a displacement-driven model or for a
support or load on a free body in explicit dynamics (#315, #335, #336, #352, #356, #361, #366).
- **Results:** units on every legend, summary, probe and reaction sum; legend ranges that can be
clamped; max and min markers in the viewport; an energies plot with axes and the current frame;
the deformed shape kept when the field changes; a sensible auto scale for large strains; setup
symbols off over contours; heat flow through each convection and flux load; probe and reaction
results exported to CSV (#92, #191, #319, #321, #325–#332, #362).
- **Forms, progress and jobs:** solves and meshers run as jobs with their step, stage, count and
elapsed time and a Cancel that interrupts every solver and mesher; the control channel and MCP
wait for a long solve; mesher forms report what they made; typed fields for label lists, point
lists, multi-choices and number-or-auto, JSON fields with hints, defaults shown as set; forms
without orphan sets on Cancel; unknown parameters refused (#169, #316, #322–#324, #337, #339,
#340, #353, #357, #367, #371).
- **Picking and preparation:** vertices pickable, selected edges visible over the mesh, a
reference point placed by a pick or a hole's centre, Seed Edges switching the filter to edges,
live previews and default names for datums and partitions, readable partition cell names, the
Partitioned Mesh form with picks and per-cell methods, a mesh marked out of date when its seeds
or partitions change, remeshing from the current mesh's settings (#338, #341–#349, #351).
- **UI conveniences:** File › New (`file.new`), Help › Crash Reports, the I-beam sample, every
sample with a Start-card thumbnail and a gallery capture, node tools (merge, duplicates, free
edges, renumber), the mesh quality panel's histograms and worst elements, Mirror Mesh and Mesh
Block on the ribbon, Rigid Wall in Interactions, readable journal entries and status messages,
a sample's name on its document tab, the web build naming an unknown sample (#113, #143, #144,
#200, #234, #300, #333, #334, #348, #350, #354, #355, #359, #360, #365).
- **Mesh soundness:** tet improvement that no longer crowds one vertex, hex20 extrudes that pass
the validity gate where hex8 does, biased seeds carried to the opposite edge of a mapped face,
designs with a closed internal void and face names over 256 characters meshed (#197, #257,
#311, #364, #374).
- **Heat:** heat transfer across ties for bonded multi-body thermal models (#358).
## Next
@@ -86,3 +123,20 @@ What exists and what is missing, by area; each gap is in a milestone above.
| Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) |
| Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) |
| Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) |
### Heated flow results (#157)
Temperature is available on fluid slices, obstacle surfaces and streamline colours when the
saved frames carry it. Signed face-average wall heat follows recorded histories and the
model's power/area units. Commands, the Results panel and headless pictures share the view;
initial frames without recorded wall heat remain uncoloured.
### Flow start-up transient and gallery clip (#164)
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
#557).
+1 -1
View File
@@ -3,7 +3,7 @@
FieldCraft is young; only `main` and the latest release get fixes.
Please **don't open a public issue** for a security problem. Report it privately through the
repository's [Security tab](https://github.com/jyviko/fieldcraft/security/advisories/new) with
repository's [Security tab](https://github.com/tangency-labs/fieldcraft/security/advisories/new) with
what you found, how to reproduce it (a model, deck or script), the version and platform, and the
impact you expect.
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fieldcraft-cli"
description = "FieldCraft from the command line: list commands, run scripts, serve MCP"
description = "FieldCraft from the command line: list commands, run scripts, draw snapshots, serve MCP"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,6 +10,14 @@ workspace = true
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-lbm-gpu = { workspace = true }
fieldcraft-gpu = { workspace = true }
fieldcraft-render = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-mcp = { workspace = true }
fieldcraft-update = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
png = { workspace = true }
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(())
}
+46 -1
View File
@@ -1,11 +1,18 @@
//! fieldcraft-cli commands
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli snapshot <script.json> --out shot.png [--view V | --camera JSON] [--size WxH] [--section x|y|z[=V]] [--field F]
//! 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 snapshot;
mod update;
use std::path::Path;
use std::process::ExitCode;
@@ -18,6 +25,19 @@ usage:
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
and print the model summary
fieldcraft-cli snapshot <script.json> --out FILE.png [--view V | --camera JSON]
[--size WxH] [--section x|y|z[=VALUE]] [--field FIELD]
run a script and draw the model to a PNG with no
window: the viewport's GPU pipeline on a hardware or
software GPU, else the CPU rasterizer (FIELDCRAFT_GPU=
auto|hardware|software|cpu); V: iso (default), x, y, z,
front, back, left, right, top, bottom; size 1280x800;
--section hides the elements beyond a plane (at the
mesh's middle, or VALUE); --field contours a nodal
field of the solved step (results.contour);
--camera '{\"yaw\":30,\"pitch\":20,\"target\":...,
\"fit\":true,\"fov\":35}' aims as MCP screenshot's camera
(default target: the model's centre of mass)
fieldcraft-cli features print docs/features.md (every capability, ready or
coming soon) from the capability catalogue
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
@@ -28,6 +48,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
";
@@ -35,6 +63,8 @@ usage:
const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
fn main() -> ExitCode {
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"` (scripts and MCP).
fieldcraft_lbm_gpu::register();
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
@@ -52,6 +82,9 @@ 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("snapshot") => snapshot::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"));
@@ -120,7 +153,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
}
fn run(path: &str) -> Result<(), String> {
/// The JSON report of a picture: what drew it and what it shows (the MCP `screenshot` reply's
/// fields).
pub fn picture_report(r: &fieldcraft_gpu::Renderer, shown: &fieldcraft_engine::view::Shown) -> serde_json::Value {
fieldcraft_mcp::picture_report(r, shown)
}
/// The session after running the script at `path`.
fn session_of(path: &str) -> Result<Session, String> {
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > MAX_SCRIPT_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024)));
@@ -129,6 +169,11 @@ fn run(path: &str) -> Result<(), String> {
let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::default();
session.run_script(&script).map_err(|e| e.to_string())?;
Ok(session)
}
fn run(path: &str) -> Result<(), String> {
let mut session = session_of(path)?;
let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?);
Ok(())
+261
View File
@@ -0,0 +1,261 @@
//! `fieldcraft-cli snapshot`: a script in, a PNG out, with no window and no display. The picture
//! is the viewport's: the engine's scene ([`fieldcraft_engine::view::frame`]) drawn with the
//! window's GPU pipelines ([`fieldcraft_gpu::capture`]) on a hardware GPU or a software one
//! (lavapipe, llvmpipe, WARP), and by the CPU rasterizer as the last tier (`FIELDCRAFT_GPU`,
//! [`fieldcraft_gpu::select`]).
use fieldcraft_engine::view::{self, Aim, Palette, Section, Target};
use fieldcraft_gpu::Renderer;
use fieldcraft_gpu::capture::{self, Capturer};
use fieldcraft_gpu::select::Policy;
use fieldcraft_render::{Orientation, StandardView};
/// The picture size when `--size` is not given.
const DEFAULT_SIZE: (u32, u32) = (1280, 800);
#[derive(Clone, Debug, PartialEq)]
pub struct Options {
pub script: String,
pub out: String,
pub view: StandardView,
/// A camera aimed as the MCP `screenshot`'s (`--camera JSON`; not with `--view`).
pub camera: Option<Aim>,
pub size: (u32, u32),
/// A section through the mesh (`--section x|y|z[=VALUE]`).
pub section: Option<Section>,
/// A nodal field contoured on the last solved step (`--field von_mises`).
pub field: Option<String>,
}
/// A view name: a standard view, or the axis looked along (`x`: from the right, `y`: from the
/// front, `z`: from the top).
fn view_of(s: &str) -> Option<StandardView> {
match s.to_ascii_lowercase().as_str() {
"x" => Some(StandardView::Right),
"y" => Some(StandardView::Front),
"z" => Some(StandardView::Top),
other => StandardView::parse(other),
}
}
/// `WxH`, each side 1..=the renderer's limit.
fn size_of(s: &str) -> Option<(u32, u32)> {
let (w, h) = s.split_once(['x', 'X'])?;
let (w, h) = (w.trim().parse::<u32>().ok()?, h.trim().parse::<u32>().ok()?);
let max = fieldcraft_gpu::capture::MAX_SIDE;
((1..=max).contains(&w) && (1..=max).contains(&h) && u64::from(w) * u64::from(h) <= fieldcraft_gpu::capture::MAX_PIXELS).then_some((w, h))
}
/// The options: the script, then each flag once, as `--flag value` or `--flag=value`.
pub fn options(args: &[String]) -> Result<Options, String> {
let (mut script, mut out, mut view, mut size, mut section, mut field, mut camera) = (None, None, None, None, None, None, None);
let mut it = args.iter();
while let Some(arg) = it.next() {
if !arg.starts_with("--") {
if script.replace(arg.clone()).is_some() {
return Err(format!("snapshot takes one script, not `{arg}` too"));
}
continue;
}
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) => (f, Some(v.to_string())),
None => (arg.as_str(), None),
};
let value = inline.or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value"))?;
let twice = match flag {
"--out" => out.replace(value).is_some(),
"--view" => view
.replace(
view_of(&value).ok_or_else(|| format!("--view: unknown view `{value}` (iso, x, y, z, front, back, left, right, top, bottom)"))?,
)
.is_some(),
"--size" => size
.replace(size_of(&value).ok_or_else(|| {
format!(
"--size: `{value}` is not WxH with each side 1..={} and at most {} pixels",
fieldcraft_gpu::capture::MAX_SIDE,
fieldcraft_gpu::capture::MAX_PIXELS
)
})?)
.is_some(),
"--section" => section
.replace(Section::parse(&value).ok_or_else(|| format!("--section: `{value}` is not x, y, z or AXIS=VALUE (x=12.5)"))?)
.is_some(),
"--field" => field.replace(value).is_some(),
"--camera" => {
let v: serde_json::Value = serde_json::from_str(&value).map_err(|e| format!("--camera: not JSON: {e}"))?;
camera.replace(view::camera(&v).map_err(|e| format!("--camera: {e}"))?).is_some()
}
other => return Err(format!("snapshot: unknown option `{other}`")),
};
if twice {
return Err(format!("{flag} is given twice"));
}
}
if view.is_some() && camera.is_some() {
return Err("give --view or --camera, not both".into());
}
Ok(Options {
script: script.ok_or("snapshot needs a script")?,
out: out.ok_or("snapshot needs --out FILE.png")?,
view: view.unwrap_or(StandardView::Iso),
camera,
size: size.unwrap_or(DEFAULT_SIZE),
section,
field,
})
}
/// The PNG of the session `o` describes, drawn as `policy` says ([`capture::png`]).
pub fn picture(o: &Options, policy: Policy) -> Result<(Vec<u8>, Renderer, view::Shown), String> {
let mut session = crate::session_of(&o.script)?;
if let Some(field) = &o.field {
session.execute("results.contour", &serde_json::json!({"field": field})).map_err(|e| format!("--field: {e}"))?;
}
let (w, h) = o.size;
let f = match &o.camera {
Some(aim) => view::frame_at(&session, aim, w as usize, h as usize, &Palette::LIGHT, o.section).map_err(|e| e.to_string())?,
None => view::frame(&session, &Orientation::of(o.view), w as usize, h as usize, &Palette::LIGHT, o.section),
};
let capturer = Capturer::open(policy);
let (png, renderer) = capture::png(&capturer, policy, &f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| e.to_string())?;
Ok((png, renderer, f.shown))
}
pub fn run(args: &[String]) -> Result<(), String> {
let o = options(args)?;
let policy = Policy::from_env().map_err(|e| e.to_string())?;
let (png, renderer, shown) = picture(&o, policy)?;
std::fs::write(&o.out, &png).map_err(|e| format!("{}: {e}", o.out))?;
let mut report = crate::picture_report(&renderer, &shown);
if let Some(r) = report.as_object_mut() {
r.insert("out".into(), o.out.clone().into());
r.insert("width".into(), o.size.0.into());
r.insert("height".into(), o.size.1.into());
match &o.camera {
Some(aim) => r.insert("camera".into(), Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() }.json()),
None => r.insert("view".into(), o.view.label().into()),
};
}
println!("{report}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(args: &[&str]) -> Result<Options, String> {
options(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn options_are_checked() {
let o = opts(&["s.json", "--out", "a.png"]).unwrap();
assert_eq!(
o,
Options {
script: "s.json".into(),
out: "a.png".into(),
view: StandardView::Iso,
camera: None,
size: DEFAULT_SIZE,
section: None,
field: None
}
);
let o = opts(&["s", "--out", "o", "--section", "y=2.5", "--field", "displacement"]).unwrap();
assert_eq!((o.section, o.field.as_deref()), (Some(Section { axis: 1, at: Some(2.5) }), Some("displacement")));
assert_eq!(opts(&["s", "--out", "o", "--section=Z"]).unwrap().section, Some(Section { axis: 2, at: None }));
let o = opts(&["--view=z", "s.json", "--size", "640x480", "--out=b.png"]).unwrap();
assert_eq!((o.view, o.size), (StandardView::Top, (640, 480)));
assert_eq!(opts(&["s", "--out", "o", "--view", "X"]).unwrap().view, StandardView::Right);
assert_eq!(opts(&["s", "--out", "o", "--view", "front"]).unwrap().view, StandardView::Front);
let o = opts(&["s", "--out", "o", "--camera", r#"{"yaw": 30, "pitch": 10, "target": [1, 2, 3], "fit": true}"#]).unwrap();
assert_eq!(o.camera.and_then(|a| a.target), Some(Target::Point(fieldcraft_render::Vec3::new(1.0, 2.0, 3.0))));
for bad in [
vec![],
vec!["s.json"],
vec!["--out", "a.png"],
vec!["s", "t", "--out", "o"],
vec!["s", "--out"],
vec!["s", "--out", "o", "--out", "p"],
vec!["s", "--out", "o", "--view", "diagonal"],
vec!["s", "--out", "o", "--size", "0x10"],
vec!["s", "--out", "o", "--size", "10"],
vec!["s", "--out", "o", "--size", "99999x10"],
vec!["s", "--out", "o", "--size", "8192x8192"],
vec!["s", "--out", "o", "--size", "-5x10"],
vec!["s", "--out", "o", "--nope", "1"],
vec!["s", "--out", "o", "--section", "w"],
vec!["s", "--out", "o", "--section", "x=inf"],
vec!["s", "--out", "o", "--section", "x=", "--field", "f"],
vec!["s", "--out", "o", "--camera", "{yaw: 1}"],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0}"#],
vec!["s", "--out", "o", "--camera", r#"{"yaw": 0, "pitch": 0}"#, "--view", "x"],
] {
assert!(opts(&bad).is_err(), "{bad:?}");
}
}
/// PNG width, height and pixels (RGBA).
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
fn script(dir: &std::path::Path, name: &str, solve: bool) -> String {
let mut steps = fieldcraft_engine::sample::cantilever();
if solve && let Some(a) = steps.as_array_mut() {
a.push(serde_json::json!({"command": "solve.static", "params": {}}));
}
let path = dir.join(name);
std::fs::write(&path, steps.to_string()).unwrap();
path.to_string_lossy().into()
}
fn opts_for(script: String) -> Options {
Options { script, out: String::new(), view: StandardView::Iso, camera: None, size: (320, 200), section: None, field: None }
}
/// The cantilever drawn headless: a PNG of the size asked for, with the model in it (pixels
/// of the hex colour) and its element edges (dark pixels). `auto` draws on the GPU when the
/// machine has one and on the CPU otherwise (it must be the GPU under `FIELDCRAFT_REQUIRE_GPU=1`); `cpu` always
/// on the CPU, and both pictures show the same model. A section and a field change it.
#[test]
fn snapshots_the_model_and_its_mesh_headless() {
let dir = std::env::temp_dir().join(format!("fieldcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let o = opts_for(script(&dir, "cantilever.json", false));
for policy in [Policy::Auto, Policy::Cpu] {
let (png, drawn, _) = picture(&o, policy).unwrap();
let (w, h, px) = decode(&png);
assert_eq!((w, h), (320, 200), "{drawn:?}");
let hex = Palette::LIGHT.hex;
let near = |p: &[u8], c: fieldcraft_render::Rgb, d: u8| p[0].abs_diff(c.0) <= d && p[1].abs_diff(c.1) <= d && p[2].abs_diff(c.2) <= d;
// Shaded faces vary in brightness around the hex colour; the edges are near black.
let faces = px.chunks(4).filter(|p| near(p, hex, 60) && p[2] > p[0]).count();
let edges = px.chunks(4).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 90).count();
assert!(faces > 500, "{drawn:?}: {faces} face pixels");
assert!(edges > 100, "{drawn:?}: {edges} edge pixels");
if policy == Policy::Cpu {
assert_eq!(drawn, Renderer::Cpu { why: None });
} else if fieldcraft_gpu::select::gpu_required() {
assert!(matches!(drawn, Renderer::Gpu { .. }), "FIELDCRAFT_REQUIRE_GPU=1 but drawn by {drawn:?}");
}
}
// A section along the beam draws half of it.
let whole = picture(&o, Policy::Cpu).unwrap().0;
let cut = picture(&Options { section: Some(Section { axis: 0, at: None }), ..o.clone() }, Policy::Cpu).unwrap().0;
assert_ne!(whole, cut);
// A field needs a solved step; on a solved one it is the contour reported.
assert!(picture(&Options { field: Some("von_mises".into()), ..o.clone() }, Policy::Cpu).is_err());
let solved = Options { field: Some("displacement".into()), ..opts_for(script(&dir, "solved.json", true)) };
let (_, _, shown) = picture(&solved, Policy::Auto).unwrap();
assert_eq!(shown.contour.map(|c| c.0).as_deref(), Some("displacement"));
std::fs::remove_dir_all(&dir).ok();
}
}
+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);
}
+39
View File
@@ -0,0 +1,39 @@
//! `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/tangency-labs/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());
}
+2 -2
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"
base64 = "0.23"
+3 -3
View File
@@ -9,15 +9,15 @@
<meta property="og:site_name" content="FieldCraft">
<meta property="og:title" content="FieldCraft">
<meta property="og:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta property="og:url" content="https://jyviko.github.io/fieldcraft/">
<meta property="og:image" content="https://jyviko.github.io/fieldcraft/og.png">
<meta property="og:url" content="https://tangency-labs.github.io/fieldcraft/">
<meta property="og:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="FieldCraft: Open-source CAE built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="FieldCraft">
<meta name="twitter:description" content="Open-source CAE built in Rust: structures, heat, fluids, and MCP for AI agents.">
<meta name="twitter:image" content="https://jyviko.github.io/fieldcraft/og.png">
<meta name="twitter:image" content="https://tangency-labs.github.io/fieldcraft/og.png">
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/icon_128x128@2x.png" data-target-path="icons" />
+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
+40 -3
View File
@@ -6,18 +6,55 @@ edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/native_crash.rs
# (the Windows exception filter, which has no safe API) can allow it. Keep the rest in sync with
# [workspace.lints].
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
neg_cmp_op_on_partial_ord = "allow"
[dependencies]
fieldcraft-engine = { workspace = true }
fieldcraft-lbm-gpu = { workspace = true }
fieldcraft-ui-egui = { workspace = true }
fieldcraft-io = { workspace = true }
fieldcraft-update = { workspace = true }
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.4"
# A termination signal (SIGTERM, SIGINT) closes the window the normal way (terminate.rs).
[target.'cfg(unix)'.dependencies]
signal-hook = "0.4"
# The exception filter and minidump of a native crash (src/native_crash.rs).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
# WriteFile's OVERLAPPED.
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
# MiniDumpWriteDump.
"Win32_System_Memory",
"Win32_System_Threading",
] }
[build-dependencies]
# The Windows executable's icon resource (build.rs).
@@ -65,7 +102,7 @@ assets = [
[package.metadata.generate-rpm]
name = "fieldcraft"
license = "MIT OR Apache-2.0"
url = "https://github.com/jyviko/fieldcraft"
url = "https://github.com/tangency-labs/fieldcraft"
assets = [
{ source = "target/release/fieldcraft", dest = "/usr/bin/fieldcraft", mode = "755" },
{ source = "target/release/fieldcraft-cli", dest = "/usr/bin/fieldcraft-cli", mode = "755" },
+5
View File
@@ -115,6 +115,11 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(REPLY_TIMEOUT).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
// A request waiting for a job (a solve, a mesher): its reply comes when the job
// ends, however long it runs; the job always answers, even when cancelled.
if r.get(fieldcraft_ui_egui::control::PENDING).is_some() {
r = rrx.recv().unwrap_or_else(|_| json!({"ok": false, "error": "the job ended without a reply"}));
}
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
+129 -7
View File
@@ -9,12 +9,17 @@
//! `fieldcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
// Unsafe code is denied (Cargo.toml): only the Windows exception filter allows it (native_crash.rs).
mod control_server;
#[cfg(windows)]
mod native_crash;
mod terminate;
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 +52,10 @@ struct App {
autosaver: Option<Autosaver>,
/// App time of the last autosave check.
autosaved_at: f64,
/// Frames drawn so far, up to 2 (the second means the first was presented: the app is up).
frames: u8,
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
usage: Option<usage::Sender>,
}
impl App {
@@ -61,6 +70,14 @@ impl App {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.frames < 2 {
self.frames += 1;
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
crash::phase(crash::RUNNING);
}
}
self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
self.autosaved_at = self.app.now;
@@ -76,6 +93,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 +122,65 @@ 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()
);
crashed
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface
/// up: the crash journal keeps it, and the adapter once it is picked (#299).
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
crash::set_environment(&format!("Renderer: {detail}"));
}
crash::phase(step);
}
/// Command-line options.
#[derive(Debug, Default, PartialEq)]
struct Args {
@@ -142,6 +224,8 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
}
fn main() -> eframe::Result {
// The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`.
fieldcraft_lbm_gpu::register();
let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) {
Ok(Some(a)) => a,
Ok(None) => return Ok(()),
@@ -152,9 +236,16 @@ 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();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
#[cfg(windows)]
native_crash::install();
eframe::egui_wgpu::set_startup_hook(startup_step);
crash::phase("window");
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft")
@@ -177,13 +268,42 @@ fn main() -> eframe::Result {
"FieldCraft",
options,
Box::new(move |cc| {
let mut app = ModelApp::new(Session::default()).with_services(services());
crash::phase("app setup");
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);
crash::set_environment(&format!("Renderer: {:?} {} ({}), MSAA {}x", info.backend, info.name, info.driver, rs.msaa_samples));
crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(windows)]
native_crash::reinstall();
}
// `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248).
let ctx = cc.egui_ctx.clone();
if let Err(e) = terminate::watch(move || {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ctx.request_repaint();
}) {
log::warn!("termination signals: {e}");
}
if let Some(port) = args.control {
app = app.with_control(control_server::start(port, cc.egui_ctx.clone()));
@@ -202,7 +322,9 @@ 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);
crash::phase("first frame");
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, frames: 0, usage }))
}),
)
}
+290
View File
@@ -0,0 +1,290 @@
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md, #299).
//!
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
//! crash journal (`fieldcraft_engine::crash::native_paths`). The next start puts both into the
//! unclean exit's report, with the startup step the journal says the app was in. Everything that
//! can be tested without Windows (reading the file, the report, the startup steps) lives in
//! `fieldcraft_engine::crash` and is tested there.
//!
//! Installing an exception filter and asking the system which module holds an address have no
//! safe API, hence the `unsafe_code` allowance on this module, the only one in FieldCraft (the
//! app crate denies unsafe code instead of forbidding it for this). The filter runs on a thread
//! that may have a broken heap or almost no stack left: it doesn't allocate, formats into a buffer
//! on the stack, and skips the minidump after a stack overflow.
//!
//! Adapted from SolidCraft's `apps/solidcraft/src/native_crash.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
#![allow(unsafe_code)]
use std::fmt::Write as _;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
struct Paths {
info: Vec<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = OnceLock::new();
/// The filter that was installed before ours (called after ours), as an address.
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
/// The filter has started writing (a second exception while it does is left alone).
static BUSY: AtomicBool = AtomicBool::new(false);
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
/// Catch native crashes from now on (after `fieldcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, dump)) = fieldcraft_engine::crash::native_paths() else { return };
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
set_filter();
}
}
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
/// which is then called after ours.
pub fn reinstall() {
if PATHS.get().is_some() {
set_filter();
}
}
fn set_filter() {
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
// process's life.
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
if let Some(p) = previous
&& p as usize != filter as *const () as usize
{
PREVIOUS.store(p as usize, Ordering::SeqCst);
}
}
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
if !BUSY.swap(true, Ordering::SeqCst) {
// SAFETY: the system passes valid exception pointers for the duration of the call.
unsafe { record(pointers) };
}
let previous = PREVIOUS.load(Ordering::SeqCst);
if previous != 0 && previous != filter as *const () as usize {
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this filter.
return unsafe { previous(pointers) };
}
EXCEPTION_CONTINUE_SEARCH
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 2048],
len: usize,
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.bytes.len().saturating_sub(self.len);
let n = s.len().min(room);
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The usual names of the exception codes a crash shows.
fn code_name(code: u32) -> &'static str {
match code {
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
0x8000_0003 => "EXCEPTION_BREAKPOINT",
0xE06D_7363 => "C++ exception",
_ => "exception",
}
}
/// Write what `pointers` says into the info file, then the minidump.
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
let Some(paths) = PATHS.get() else { return };
if pointers.is_null() {
return;
}
// SAFETY: checked non-null; the system keeps it valid during the filter.
let rec = unsafe { (*pointers).ExceptionRecord };
if rec.is_null() {
return;
}
// SAFETY: as above.
let rec = unsafe { &*rec };
let code = rec.ExceptionCode as u32;
let address = rec.ExceptionAddress as usize;
let mut module: HMODULE = std::ptr::null_mut();
let mut name = [0u16; 512];
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
// handle to release; `name` is as long as we say.
let len = unsafe {
if GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
address as *const u16,
&mut module,
) != 0
{
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
} else {
0
}
};
// SAFETY: no preconditions.
let thread = unsafe { GetCurrentThreadId() };
let mut b = Buf { bytes: [0; 2048], len: 0 };
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
if len > 0 {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
for c in char::decode_utf16(name.iter().take(len).copied()) {
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
'"' => b.write_str("\\\""),
'\\' => b.write_str("\\\\"),
c if (c as u32) < 0x20 => b.write_str("?"),
c => b.write_char(c),
};
}
let _ = b.write_str("\"");
}
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
let what = match rec.ExceptionInformation[0] {
0 => "read of",
1 => "write to",
8 => "execution at",
_ => "access to",
};
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
}
let _ = b.write_str("}");
let bytes = b.bytes.get(..b.len).unwrap_or_default();
// SAFETY: NUL-terminated path; the handle is closed below.
unsafe {
let file = create(&paths.info);
if !file.is_null() {
let mut written = 0u32;
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
CloseHandle(file);
}
}
// After a stack overflow there is no stack left for writing a minidump.
if code == STATUS_STACK_OVERFLOW {
return;
}
// SAFETY: as above; the exception information points at this thread's exception.
unsafe {
let file = create(&paths.dump);
if !file.is_null() {
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
&exception,
std::ptr::null(),
std::ptr::null(),
);
CloseHandle(file);
}
}
}
/// A new file at `path` (NUL-terminated) for writing, or null.
unsafe fn create(path: &[u16]) -> HANDLE {
// SAFETY: the caller passes a NUL-terminated path.
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// The child half: install crash reporting and the filter, then fault in native code.
#[test]
fn child() {
let Some(dir) = std::env::var_os("FIELDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = fieldcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234" };
let fresh = fieldcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
if std::env::var_os("FIELDCRAFT_NATIVE_CRASH_FAULT").is_none() {
fieldcraft_engine::crash::end_session();
return;
}
super::install();
fieldcraft_engine::crash::phase("device creation");
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
// pointer's field would be).
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_NATIVE_CRASH_CHILD", dir);
if fault {
c.env("FIELDCRAFT_NATIVE_CRASH_FAULT", "1");
}
c.output().unwrap()
}
#[test]
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
let dir = std::env::temp_dir().join(format!("fieldcraft-native-crash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, true);
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
assert_eq!(v["code"], "0xC0000005", "{v}");
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
assert!(v["module"].as_str().unwrap().to_lowercase().contains("fieldcraft"), "the fault is in the test binary: {v}");
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
// The next start reports it.
let out = run(&dir, false);
assert!(out.status.success(), "{out:?}");
let reports = fieldcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = fieldcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in fieldcraft"), "{r}");
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
assert!(r.get("minidump_path").is_some(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+143
View File
@@ -0,0 +1,143 @@
//! A request to terminate (SIGTERM, SIGINT on Unix) closes the window the normal way.
//!
//! Without this, `kill PID`, Ctrl-C in the terminal, a system shutdown or a test harness's timeout
//! ended the process on the spot: no `on_exit`, so no last autosave and no clean end of the crash
//! journal, and the next start filed an `unclean_exit` crash report for what was no crash (#248:
//! "ended without a clean exit between commands (last: control ui.capture)" was an automation run
//! stopped with SIGTERM after its capture). Now the first signal asks the window to close, which
//! autosaves, ends the journal and exits as a click on Close does; the journal notes the signal,
//! so a run that still dies while closing says why. A second signal, for a window that does not
//! close, ends the process at once with the signal's default action.
//!
//! SIGHUP is left alone: `nohup fieldcraft &` ignores it on purpose so the app outlives its
//! terminal, and a handler would override that (a safe API cannot ask whether a signal was
//! ignored at launch).
//!
//! Windows has no such signals: a window there is closed through its messages, which eframe
//! already turns into a normal close.
use std::io;
/// The signals that ask the app to terminate.
#[cfg(unix)]
const SIGNALS: [i32; 2] = [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT];
/// The signal's usual name, for the log and the crash journal.
#[cfg(unix)]
fn name(signal: i32) -> &'static str {
match signal {
signal_hook::consts::SIGTERM => "SIGTERM",
signal_hook::consts::SIGINT => "SIGINT",
_ => "signal",
}
}
/// Call `close` (on a thread of its own) on the first termination signal; a second one ends the
/// process with the signal's default action.
#[cfg(unix)]
pub fn watch(close: impl Fn() + Send + 'static) -> io::Result<()> {
let mut signals = signal_hook::iterator::Signals::new(SIGNALS)?;
std::thread::Builder::new().name("terminate".into()).spawn(move || {
let mut asked = false;
for signal in signals.forever() {
if asked {
log::warn!("{}: the window did not close; ending now", name(signal));
if let Err(e) = signal_hook::low_level::emulate_default_handler(signal) {
log::error!("{}: {e}", name(signal));
std::process::exit(128 + signal);
}
continue;
}
asked = true;
log::info!("{}: closing", name(signal));
fieldcraft_engine::crash::activity(&format!("terminating ({})", name(signal)));
close();
}
})?;
Ok(())
}
/// Nothing to watch without Unix signals.
#[cfg(not(unix))]
pub fn watch(_close: impl Fn() + Send + 'static) -> io::Result<()> {
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use fieldcraft_engine::crash;
/// The child half: start crash reporting as the app does, then get SIGTERM. With the watcher
/// the "window" (here the main thread) closes the normal way; without it the process dies.
#[test]
fn child() {
let Some(dir) = std::env::var_os("FIELDCRAFT_TERMINATE_CHILD") else { return };
let build = crash::Build { version: "9.9.9", commit: "abc1234" };
let fresh = crash::install(PathBuf::from(dir), build);
if std::env::var_os("FIELDCRAFT_TERMINATE_NEXT").is_some() {
// The next start: what it reports of the run before.
println!("FRESH {}", fresh.iter().map(|s| s.message.as_str()).collect::<Vec<_>>().join(" | "));
crash::end_session();
return;
}
crash::activity("control ui.capture");
if std::env::var_os("FIELDCRAFT_TERMINATE_WATCH").is_some() {
let (tx, rx) = mpsc::channel();
super::watch(move || {
let _ = tx.send(());
})
.unwrap();
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
// What `on_exit` does once the window has closed.
rx.recv().unwrap();
crash::end_session();
println!("CLOSED");
return;
}
signal_hook::low_level::raise(signal_hook::consts::SIGTERM).unwrap();
// Not reached: SIGTERM's default action ends the process.
std::thread::sleep(std::time::Duration::from_secs(10));
}
#[test]
fn sigterm_closes_cleanly_and_leaves_no_crash_report() {
let root = std::env::temp_dir().join(format!("fieldcraft-terminate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let run = |dir: &Path, mode: &str| {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "terminate::tests::child", "--test-threads=1", "--nocapture"]).env("FIELDCRAFT_TERMINATE_CHILD", dir);
match mode {
"watch" => c.env("FIELDCRAFT_TERMINATE_WATCH", "1"),
"next" => c.env("FIELDCRAFT_TERMINATE_NEXT", "1"),
_ => &mut c,
};
c.output().unwrap()
};
// What the next start in `dir` reports of the run before.
let reported_at_next_start = |dir: &Path| {
let out = run(dir, "next");
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
stdout.lines().find_map(|l| l.split_once("FRESH ").map(|(_, r)| r.trim().to_string())).unwrap_or_else(|| panic!("{stdout}"))
};
// Without the watcher (the bug): killed, and the journal it leaves reads as a crash.
let unwatched = root.join("unwatched");
let out = run(&unwatched, "kill");
use std::os::unix::process::ExitStatusExt;
assert_eq!(out.status.signal(), Some(signal_hook::consts::SIGTERM), "{out:?}");
let reported = reported_at_next_start(&unwatched);
assert!(reported.contains("ended without a clean exit") && reported.contains("control ui.capture"), "{reported}");
// With it: the close runs, the journal ends, and nothing is reported as a crash.
let watched = root.join("watched");
let out = run(&watched, "watch");
assert!(out.status.success(), "{out:?}");
assert!(String::from_utf8_lossy(&out.stdout).contains("CLOSED"), "{out:?}");
assert_eq!(reported_at_next_start(&watched), "");
let _ = std::fs::remove_dir_all(&root);
}
}
+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.check</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.5 L2 9.5" fill="none" stroke="#f0b44a" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 17 C13.5 15.067 15.067 13.5 17 13.5 C18.933 13.5 20.5 15.067 20.5 17 C20.5 18.933 18.933 20.5 17 20.5 C15.067 20.5 13.5 18.933 13.5 17 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 19.5 L22 22" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 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.
+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.defeature</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 8 C9 8.828 10.343 9.5 12 9.5 C13.657 9.5 15 8.828 15 8 C15 7.172 13.657 6.5 12 6.5 C10.343 6.5 9 7.172 9 8 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 11 L16.5 5" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 886 B

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.face.absorb</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z" fill="#f0b44a" stroke="#f0b44a" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 823 B

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.face.split</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10 L16.5 6" fill="none" stroke="#f0b44a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 4.5 L9 2.5 M15 9.5 L19 7.5" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

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.faces.merge</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 6 L16.5 10" fill="none" stroke="#f0b44a" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 2"/>
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 844 B

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.stitch</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 6 L10.5 6 L10.5 18 L3 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 6 L21 6 L21 18 L13.5 18 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 8.5 L15 8.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12 L15 12" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15.5 L15 15.5" fill="none" stroke="#f0b44a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.block</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M4 6 L16 3 L20 15 L8 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5 L12.33 16.33 M12 4 L16.67 17.67 M5.33 10.33 L17.33 7 M6.67 14.67 L18.67 11" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 559 B

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>mesh.export.stl</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 9 L9 5 L15 9 L9 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9 L9 21 M9 13 L15 17" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 817 B

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>mesh.hex.extrude</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M6 20 L18 20 L18 11.6 C14 9.467 10 9.467 6 11.6 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 17.2 L18 17.2 M6 14.4 C10 12.8 14 12.8 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 9.2 C9 5.333 15 5.333 21 9.2" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 879 B

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>mesh.mirror</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M12 2 L12 22" fill="none" stroke="#4f9ce8" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 1.5"/>
<path d="M10 5 L3 7 L3 17 L10 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12 L10 12 M6.5 6 L6.5 18" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 5 L21 7 L21 17 L14 19 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 12 L21 12 M17.5 6 L17.5 18" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 923 B

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>mesh.node.move</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M5 4 L19 4 C19.552 4 20 4.448 20 5 L20 19 C20 19.552 19.552 20 19 20 L5 20 C4.448 20 4 19.552 4 19 L4 5 C4 4.448 4.448 4 5 4 Z" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12 L20 12 M12 4 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12 L15 9 L20 12 M12 4 L15 9 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.2 9 C13.2 8.006 14.006 7.2 15 7.2 C15.994 7.2 16.8 8.006 16.8 9 C16.8 9.994 15.994 10.8 15 10.8 C14.006 10.8 13.2 9.994 13.2 9 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

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>mesh.node.relax</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 12 C3 7.029 7.029 3 12 3 C16.971 3 21 7.029 21 12 C21 16.971 16.971 21 12 21 C7.029 21 3 16.971 3 12 Z" fill="none" stroke="#dde2ea" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 2"/>
<path d="M5 12 L19 12 M12 5 L12 19" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.6 12 C9.6 10.675 10.675 9.6 12 9.6 C13.325 9.6 14.4 10.675 14.4 12 C14.4 13.325 13.325 14.4 12 14.4 C10.675 14.4 9.6 13.325 9.6 12 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1017 B

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>mesh.plan</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 7 L12 11 L20 7 M12 11 L12 20" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.8 7 C8.8 7.884 10.233 8.6 12 8.6 C13.767 8.6 15.2 7.884 15.2 7 C15.2 6.116 13.767 5.4 12 5.4 C10.233 5.4 8.8 6.116 8.8 7 Z" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 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>mesh.quality.mark</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L11 3 L11 11 L3 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3 L21 3 L21 11 L13 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13 L11 13 L11 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 14 L21 13 L20 21 L14 19 Z" fill="#f0b44a" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 822 B

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>mesh.size.set</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L21 3 L21 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 21 M3 12 L21 12" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12 L21 12 L21 21 L12 21 Z" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.5 12 L16.5 21 M12 16.5 L21 16.5" fill="none" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.transition</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 4 L21 4 L21 20 L3 20 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 4 L7.5 20 M3 9.33 L12 9.33 M3 14.67 L12 14.67" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4 L21 9.33 L12 14.67 L21 20 M12 14.67 L21 14.67 M12 4 L21 4" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4 L12 20" fill="none" stroke="#4f9ce8" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 816 B

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>results.export.vtu</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M4 3 L12 3 L16 7 L16 21 L4 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3 L12 7 L16 7" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17 L7 14 M10 17 L10 11 M13 17 L13 13" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 837 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"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M9 3 L16 6.5 L16 13 L9 16.5 L2 13 L2 6.5 Z", "fill": "soft", "stroke": "line"}, {"d": "M2 6.5 L9 10 L16 6.5 M9 10 L9 16.5", "stroke": "line"}, {"line": [2, 6.5, 2, 9.5], "stroke": "warm", "width": 2.25}, {"circle": [17, 17, 3.5], "stroke": "accent", "width": 1.75}, {"line": [19.5, 19.5, 22, 22], "stroke": "accent", "width": 2}]}
@@ -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": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M9 8 A3 1.5 0 1 0 15 8 A3 1.5 0 1 0 9 8 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M7.5 11 L16.5 5", "stroke": "accent", "width": 1.75}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"d": "M14.5 5.1 L17 6.2 L8 10.3 L5.5 9.2 Z", "fill": "warm", "stroke": "warm", "width": 1}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 10, 16.5, 6], "stroke": "warm", "width": 2}, {"d": "M5 4.5 L9 2.5 M15 9.5 L19 7.5", "stroke": "accent", "width": 1.5, "dash": [1.5, 1.5]}]}
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 6, 16.5, 10], "stroke": "warm", "width": 1.25, "dash": [1.5, 2]}, {"d": "M3 8 L12 4 L21 8 L12 12 Z", "stroke": "accent", "width": 1.75}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 6 L10.5 6 L10.5 18 L3 18 Z", "fill": "soft", "stroke": "line"}, {"d": "M13.5 6 L21 6 L21 18 L13.5 18 Z", "fill": "soft", "stroke": "line"}, {"line": [9, 8.5, 15, 8.5], "stroke": "warm", "width": 1.75}, {"line": [9, 12, 15, 12], "stroke": "warm", "width": 1.75}, {"line": [9, 15.5, 15, 15.5], "stroke": "warm", "width": 1.75}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M4 6 L16 3 L20 15 L8 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M8 5 L12.33 16.33 M12 4 L16.67 17.67 M5.33 10.33 L17.33 7 M6.67 14.67 L18.67 11", "stroke": "accent", "width": 1}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 9 L9 5 L15 9 L9 13 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9", "stroke": "line"}, {"d": "M3 9 L9 21 M9 13 L15 17", "stroke": "line", "width": 1}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M6 20 L18 20 L18 11.6 Q12 8.4 6 11.6 Z", "fill": "soft", "stroke": "line"}, {"d": "M6 17.2 L18 17.2 M6 14.4 Q12 12 18 14.4 M10 20 L10 9.9 M14 20 L14 9.9", "stroke": "accent", "width": 1}, {"d": "M3 9.2 Q12 3.4 21 9.2", "stroke": "accent", "width": 2}, {"d": "M12 6 L12 3 M10.5 4.5 L12 3 L13.5 4.5", "stroke": "line"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M12 2 L12 22", "stroke": "accent", "width": 1.25, "dash": [2, 1.5]}, {"d": "M10 5 L3 7 L3 17 L10 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 12 L10 12 M6.5 6 L6.5 18", "stroke": "line", "width": 1}, {"d": "M14 5 L21 7 L21 17 L14 19 Z", "fill": "soft", "stroke": "line"}, {"d": "M14 12 L21 12 M17.5 6 L17.5 18", "stroke": "line", "width": 1}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [4, 4, 16, 16], "radius": 1, "stroke": "line", "width": 1.5}, {"d": "M4 12 L20 12 M12 4 L12 20", "stroke": "line", "width": 1.5}, {"d": "M4 12 L15 9 L20 12 M12 4 L15 9 L12 20", "stroke": "accent", "width": 1.5}, {"circle": [15, 9, 1.8], "fill": "accent", "stroke": "accent"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"circle": [12, 12, 9], "stroke": "line", "width": 1.25, "dash": [2, 2]}, {"d": "M5 12 L19 12 M12 5 L12 19", "stroke": "line", "width": 1.0}, {"d": "M7.5 8 L16.5 8 M7.5 16 L16.5 16 M8 7.5 L8 16.5 M16 7.5 L16 16.5", "stroke": "line", "width": 1.0}, {"circle": [12, 12, 2.4], "fill": "accent", "stroke": "accent"}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"d": "M12 3 L20 7 L20 16 L12 20 L4 16 L4 7 Z", "fill": "soft", "stroke": "line"}, {"d": "M4 7 L12 11 L20 7 M12 11 L12 20", "stroke": "line"}, {"d": "M4 11.5 L12 15.5 L20 11.5 M8 9 L8 18 M16 9 L16 18", "stroke": "accent", "width": 1}, {"d": "M12 3 L12 5.4 M20 7 L15.2 7 M12 11 L12 8.6 M4 7 L8.8 7", "stroke": "accent", "width": 1}, {"d": "M8.8 7 A3.2 1.6 0 1 0 15.2 7 A3.2 1.6 0 1 0 8.8 7 Z", "stroke": "warm", "width": 1.5}]}
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [3, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [13, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [3, 13, 8, 8], "fill": "soft", "stroke": "line"}, {"d": "M13 14 L21 13 L20 21 L14 19 Z", "fill": "warm", "stroke": "line"}]}
Loaded 100 of 495 files, more files were not shown because too many files have changed in this diff. Show more