`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.
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.
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.
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo