Skip to content

Results & fields

A simulation result is data on a mesh: one number (or one vector) per vertex, with a name and a unit. EngrCAD carries those as MeshFields attached to a Part, colour-maps them in every viewer, and exports them to ParaView — and none of that needs a solver, because a field is just values. Anything that can produce a number at a point can drive the whole pipeline: a distance, a clearance, a hand-written load case, or eventually an FEA solve.

Attaching a result

MeshField.Sample evaluates a function at every vertex of a part’s display mesh, in vertex-index order — which is exactly what a MeshField is indexed by.

// A drilled plate coloured by how far each surface point is from the nearest bore -- the
// ligament / edge-distance check, evaluated straight off the bores' own distance field.
var top = SketchPlane.At((0, 0, 5), Vector3d.UnitX, Vector3d.UnitY);
var plate = Shape.Box(90, 50, 10)
.Drill(StandardHoles.Clearance(8), [new(-28, 0), new(0, 14), new(28, 0)], depth: 12, top);
var part = new Part("plate", plate);
var mesh = part.GetMesh();
// The bores as their own field: Evaluate is the signed distance to that surface, so on
// the plate's skin it reads "how much material to the nearest hole".
var bores = Sdf.Union(
Sdf.Cylinder(4.5, 40).Translate((-28, 0, 0)),
Sdf.Cylinder(4.5, 40).Translate((0, 14, 0)),
Sdf.Cylinder(4.5, 40).Translate((28, 0, 0)));
part.AddResult(MeshField.Sample(mesh, "edge distance", "mm", p => bores.Evaluate(p)));
part.FieldDisplay = new FieldDisplay
{
Field = "edge distance",
Range = new FieldRange(0, 25), // a scale chosen by the engineer, not by the data
};
var scene = new Scene();
scene.Add(part);

A drilled plate colour-mapped by distance to the nearest bore, with a legend

Three things are in that picture and all three come from the same handful of lines: the plate is painted through a perceptual colour map, the legend on the left states what the colours mean and over what range, and the range is the one the model asked for. Several visible parts showing genuinely different displays each get their own bar, stacked top-to-bottom in draw order (as many as fit vertically) — one bar over two scales would be a legend that lies — while parts sharing one display share one bar. Leave Range out and the field’s own min and max are used instead — which is the right default for a first look and the wrong one for a figure, since the bore walls sit at distance zero give or take a rounding error and the legend would say so.

Part.Results is a list, so a part can carry several and FieldDisplay names the one to show. Re-adding a result under an existing name replaces it, so re-running a solve updates the display instead of accumulating stale twins.

var part = new Part("plate", Shape.Box(40, 20, 4));
var mesh = part.GetMesh();
part.AddResult(MeshField.Sample(mesh, "von Mises", "MPa", p => 120 - 2 * Math.Abs(p.X)));
part.AddResult(MeshField.SampleVector(mesh, "displacement", "mm",
p => new Vector3d(0, 0, -0.001 * p.X * p.X)));
if (part.Results.Count != 2) throw new Exception("both results should attach");
// A second result under the same name replaces the first (a re-solve, not a twin).
part.AddResult(MeshField.Sample(mesh, "von Mises", "MPa", _ => 0));
if (part.Results.Count != 2) throw new Exception("re-solving must not accumulate twins");
if (part.Result("von Mises")!.Range.Max != 0) throw new Exception("the live result should win");

Scalars, vectors and derived fields

A vector field (a displacement, a heat flux) reads as its magnitude wherever one number is wanted — colouring, the legend, the range — so FieldDisplay { Field = "displacement" } plots the deflection magnitude with no extra call. Magnitude() and Component(i) produce the derived field as an object of its own, for export or for its own legend.

var mesh = Shape.Box(10, 10, 10).ToMesh();
var u = MeshField.SampleVector(mesh, "displacement", "mm", p => new Vector3d(0, 0, p.Z * 0.1));
// ScalarAt is the magnitude for a vector field; the range follows it.
if (Math.Abs(u.Range.Max - 0.5) > 1e-9) throw new Exception($"range was {u.Range}");
var magnitude = u.Magnitude(); // named "|displacement|", same units
var uz = u.Component(2); // named "displacement.Z"
if (magnitude.IsVector || uz.IsVector) throw new Exception("both derivations are scalar");
// The magnitude is unsigned by construction; a COMPONENT keeps its sign, which is
// exactly when the diverging map earns its keep.
if (magnitude.Range.Min < 0) throw new Exception("a magnitude cannot be negative");
if (Math.Abs(uz.Range.Min + 0.5) > 1e-9) throw new Exception($"uz should reach -0.5, was {uz.Range}");

The deformed shape

A displacement result can move the model, with the undeformed shape ghosted behind it, because the comparison is the point.

// A cantilever plate under an analytic deflection, exaggerated 40x. The colour is the
// deflection magnitude; the faint body behind it is the undeformed plate.
var part = new Part("cantilever", Shape.Box(120, 24, 6), Palette.Steel);
var mesh = part.GetMesh();
// A tip-loaded cantilever's shape: w(x) proportional to x^2 (3L - x), fixed at x = 0.
const double L = 120, tip = 0.35;
double Deflection(double x) => -tip * (x * x * (3 * L - x)) / (2 * L * L * L);
part.AddResult(MeshField.SampleVector(mesh, "displacement", "mm",
p => new Vector3d(0, 0, Deflection(p.X + 60))));
part.FieldDisplay = new FieldDisplay
{
Field = "displacement", // a vector result colours by its magnitude
Deform = "displacement",
DeformScale = 40, // real deflections are invisible; the legend states the factor
};
var scene = new Scene();
scene.Add(part);

A cantilever plate bent by its displacement result, the undeformed shape ghosted behind it

The legend’s title carries 40X DEFORMED, deliberately: a deformed plot whose exaggeration is not stated is a picture of a shape that does not exist. Set ShowUndeformed = false to drop the ghost, or DeformScale = 0 to leave the geometry alone entirely.

A deformed part draws its feature-edge overlay, displaced with the shape: each edge sample carries its own displacement (interpolated from the field at the sample’s nearest mesh facet — exact for any affine displacement, and within the facets’ own interpolation otherwise), riding a line-program attribute under the same uDeformScale the fills follow, so the outline is right at every exaggeration and animating it is still one float per frame. The wireframe view follows the displacement the same way — its endpoints are mesh vertices, so they take their own displacement vectors exactly. Picking follows what is drawn at the part’s own scale, so a click selects the part where it is on screen.

The displacement is sent once, as a vertex attribute, and the shader applies position + uDeformScale * displacement. A mesh with no displacement buffer reads zero, so a part with no results is untouched however the uniform is set — the same constant-when-absent rule the colour attribute follows. Two consequences worth knowing: the exaggeration is one float, so animating it is free (see the load ramp); and the shading is exact rather than carried over, because a facet normal is exactly quadratic in the scale, so three coefficients reproduce it at every exaggeration.

Every view style reads the field

A field-coloured part keeps its result colours in Wireframe and Points too: the wireframe’s segments take each endpoint’s own vertex colour (from the same mapping the fills use, so the two readings cannot disagree) and the point sprites read the mesh’s colour buffer directly. A part with no result keeps its part colour, exactly as before — and a cell-associated field keeps the part colour in wireframe, honestly: a mesh edge borders two faces, so an endpoint has no one cell colour to take.

// The edge-distance plate again, drawn as a wireframe: the mesh edges carry the
// field's colours, so the structure and the values read together.
var top = SketchPlane.At((0, 0, 5), Vector3d.UnitX, Vector3d.UnitY);
var plate = Shape.Box(90, 50, 10)
.Drill(StandardHoles.Clearance(8), [new(-28, 0), new(0, 14), new(28, 0)], depth: 12, top);
var part = new Part("plate", plate) { DisplayMode = DisplayMode.Wireframe };
var mesh = part.GetMesh();
var bores = Sdf.Union(
Sdf.Cylinder(4.5, 40).Translate((-28, 0, 0)),
Sdf.Cylinder(4.5, 40).Translate((0, 14, 0)),
Sdf.Cylinder(4.5, 40).Translate((28, 0, 0)));
part.AddResult(MeshField.Sample(mesh, "edge distance", "mm", p => bores.Evaluate(p)));
part.FieldDisplay = new FieldDisplay
{
Field = "edge distance",
Range = new FieldRange(0, 25),
};
var scene = new Scene();
scene.Add(part);

The drilled plate's wireframe, its edges coloured by the field

Transient playback

A transient run’s stored steps play back as a field-sequence track — the fifth animation slot, and the one whose sample is a result selection rather than matrices, a camera or a scalar. Steps are (result name, real seconds) pairs; t maps linearly over the run’s real span with hold-last-step semantics, because the stored states are the answers at their own instants — holding is honest where tweening colours between two solutions would invent a state the solver never produced. A part participates when it carries a display and all the run’s steps as results; the clip shows one range throughout (the display’s explicit range, else the union of the steps’ own), since a legend that rescales per frame lies. Applying a step re-uploads one colour buffer and nothing else — measured at 0.042/0.68 ms per frame on 12k/195k-vertex meshes — and a still of the animation at a step is byte-identical to a static render of the same configuration. The batched APNG/frame export applies the same rule per frame through the shared upload cache, and the browser viewer applies it through a colours-only buffer update — so an exported clip, the desktop window and the web viewer all play a transient run’s steps from one rule.

The axis itself is document data: Part.AddResultSequence publishes a run’s states as ordinary results under derived names (“T @ 0.5s”) and records a ResultSequence — the order and the instants — which persists with the document (write-only-when-stated, so a document using none is byte-identical) and from which FieldSequenceTrack.For builds the playback by one rule. A reloaded document then knows not just its states but WHEN they were; the track and the saved axis cannot disagree. A re-published sequence under the same name replaces the old one and removes any steps it no longer uses, so a re-solve with different instants cannot leave stale twins behind.

var part = new Part("plate", Shape.Box(20, 10, 2));
var mesh = part.GetMesh();
// Publish the run WITH its time axis: each step becomes a result named "T @ ...s",
// and the ResultSequence records the order and instants a saved document used to lose.
part.AddResultSequence("T", [
(MeshField.Sample(mesh, "state", "K", p => 300 + p.X), 0.0),
(MeshField.Sample(mesh, "state", "K", p => 300 + 4 * p.X), 5.0),
]);
part.FieldDisplay = new FieldDisplay { Field = "T @ 0s" };
var track = FieldSequenceTrack.For(part, "T"); // the saved axis IS the playback
if (track.FieldAt(0) != "T @ 0s" || track.FieldAt(1) != "T @ 5s")
throw new Exception("t maps over the run's real time");
if (track.FieldAt(0.49) != "T @ 0s")
throw new Exception("hold-last: 2.45 s still shows the 0 s step");
// The run's one range: the union of the steps' own.
var range = track.RunRange(part);
if (range.Min != 260 || range.Max != 340)
throw new Exception($"the clip's range is the union, was {range.Min}..{range.Max}");

Colour maps and ranges

Two maps, and the choice matters:

FieldColorMapFor
Viridis (default)A magnitude — stress, temperature, deflection — where only “more” and “less” mean anything. Monotone in lightness, so it survives greyscale printing and colour-vision deficiency.
DivergingA signed quantity where the crossing is the interesting value. Blue–grey–red, with the neutral midpoint at the middle of the range.

A diverging map only means what it looks like over a range centred on zero, and EngrCAD will not silently re-centre one for you — quietly widening a range would change what the numbers on the legend say. Ask for it:

var mesh = Shape.Box(60, 20, 4).ToMesh();
var bending = MeshField.Sample(mesh, "bending stress", "MPa", p => 12 * p.Z);
var display = new FieldDisplay
{
Field = "bending stress",
ColorMap = FieldColorMap.Diverging,
Range = bending.Range.SymmetricAboutZero(), // so grey means zero
};
var range = display.Range!.Value;
if (Math.Abs(range.Min + range.Max) > 1e-12) throw new Exception("the range should straddle zero");
if (Math.Abs(range.Normalize(0) - 0.5) > 1e-12) throw new Exception("zero should land mid-map");

An explicit Range is also how several parts — or several load cases — are made comparable: without one, each field spans its own min and max and every picture gets its own private scale.

Two range behaviours are worth knowing. A constant field normalizes to 0.5 rather than to an end, because a field with no variation has no position to report and an extreme colour would read as a hot spot. And NaN is skipped when ranging, so one undefined value does not collapse a legend — and it paints a distinct neutral grey (ColorMaps.NoValueColor): NaN is “no value” (an infinite fatigue life, a part with no data in a merged export), not “small”, so it must not take the bottom of the ramp and read as the smallest finite value. When the displayed field carries a no-value node the legend adds a matching NO VALUE swatch below the bar; a finite field’s legend is unchanged. A LogScale display’s non-positive values land in the same grey for the same reason — a log scale has no position for them.

Log-scale fields

A field whose values are base-10 logarithms declares it in its units string — log10(cycles), the convention the fatigue life field established — and the legend reads that declaration: tick labels print the anti-logged values, ticks sit on the integer decades where the range spans at least two of them (with the end ticks always printing the true range), and the title states the base units with a LOG SCALE tag. The units string is the one opt-in, made by the field’s producer and carried by the field itself, so it round-trips wherever the field does; the colour mapping stays linear over the log values, which is exactly what a log colour axis is.

var life = MeshField.Scalar("life", "log10(cycles)", [2.0, 6.5, 10.0]);
if (!FieldLegend.TryLogUnits(life.Units, out var baseUnits) || baseUnits != "cycles")
throw new Exception("the units string declares the log transform");
var display = new ResolvedFieldDisplay(
life, new FieldRange(2, 10), FieldColorMap.Viridis, null, 1, true);
var ticks = FieldLegend.TickMarks(display);
if (ticks[0].Label != "100" || ticks[^1].Label != "1E+10")
throw new Exception("ticks must print anti-logged values");
if (FieldLegend.Title(display) != "LIFE [CYCLES, LOG SCALE]")
throw new Exception("the title must state the base units and the scale");

A field carrying real values that span decades (raw cycle counts, contact pressure) takes the other spelling: FieldDisplay.LogScale. The colour position becomes (log₁₀v − log₁₀min)/(log₁₀max − log₁₀min), so a value one decade up the range moves one decade up the ramp; a non-positive value has no log position and paints the no-value grey (NaN’s own convention); the legend prints the same decade ticks the units spelling prints for the same data — the two share one tick builder, so they cannot drift — and the title tags the field’s own units LOG SCALE. A log display needs a strictly positive range and is refused by name when it resolves otherwise; the flag rides in the document file write-only-when-set, so a file that never uses it is byte-identical. A display wants one spelling or the other, never both: the units string says the values are already logged, the flag says the colours should log them.

var life = MeshField.Scalar("life", "cycles", [10.0, 1e3, 1e5]);
var display = new ResolvedFieldDisplay(
life, new FieldRange(10, 1e5), FieldColorMap.Viridis, null, 1, true, LogScale: true);
// 10^3 is the LOG midpoint of [10, 10^5] — linearly it would sit at t = 0.0099.
var colors = FieldRendering.SourceColors(life, display.Range, display.ColorMap, logScale: true);
if (colors[1] != ColorMaps.Sample(FieldColorMap.Viridis, 0.5))
throw new Exception("the decade midpoint must take the map's middle colour");
if (FieldLegend.Title(display) != "LIFE [CYCLES, LOG SCALE]")
throw new Exception("the title must tag the scale");
if (new FieldRange(4, 4).Normalize(4) != 0.5) throw new Exception("a constant field sits mid-map");
if (!FieldRange.Of([1, double.NaN, 5]).Equals(new FieldRange(1, 5)))
throw new Exception("NaN must not poison a range");
// The colours the legend and the fills both use come from one call.
var cold = ColorMaps.Sample(FieldColorMap.Viridis, new FieldRange(0, 100), 0);
var hot = ColorMaps.Sample(FieldColorMap.Viridis, new FieldRange(0, 100), 100);
if (cold.B <= cold.R || hot.R <= hot.B) throw new Exception("viridis runs blue to yellow");

Exporting to ParaView (.vtu)

--export scene.vtu writes a VTK XML unstructured grid: the flattened instances’ geometry merged into one grid, plus every part’s results as point-data arrays.

var part = new Part("plate", Shape.Box(40, 20, 4));
var mesh = part.GetMesh();
part.AddResult(MeshField.Sample(mesh, "von Mises", "MPa", p => 40 + p.Z * 4));
part.AddResult(MeshField.SampleVector(mesh, "displacement", "mm",
p => new Vector3d(0, 0, -0.002 * p.X * p.X)));
string path = Path.Combine(Scratch, "plate.vtu");
VtuWriter.WriteFile(mesh, part.Results, path);
var root = System.Xml.Linq.XDocument.Load(path).Root!;
if ((string?)root.Attribute("type") != "UnstructuredGrid") throw new Exception("wrong grid type");
var arrays = root.Descendants("PointData").Single().Elements("DataArray")
.ToDictionary(e => (string)e.Attribute("Name")!, e => (string?)e.Attribute("NumberOfComponents"));
if (arrays["von Mises"] != "1") throw new Exception("stress should be a scalar array");
if (arrays["displacement"] != "3") throw new Exception("displacement should be a vector array");

The writer’s seam is deliberately (points, cells, cell types, point data) rather than a mesh type: a surface result writes triangles, quads and polygons today, and a volumetric mesher writes VtkCellType.Tetra through the same call with nothing in the writer changing.

When a scene merges several parts, the arrays are the union of their result names and a part that lacks one contributes NaN — VTK’s own “no value”, which ParaView paints in the map’s NaN colour. Dropping the array would lose the result that does exist, and zeros would show a fake safe region.

Where results fit in the document

Results live on the Part, not in a viewport. That is what makes them survive tab and scene plumbing, appear identically in the desktop window, a headless render and the browser client, and be visible to a script with no viewer reference at all.

Attaching a result is free and never meshes anything, so it does not interfere with Scene.PreMesh running parts in parallel. The one contract to keep is that a field’s values index the part’s display-mesh vertices, in vertex order — part.GetMesh().VertexCount of them. A field of the wrong length is reported by name when something tries to draw it, never silently ignored:

var part = new Part("plate", Shape.Box(10, 10, 10));
part.AddResult(MeshField.Scalar("stress", "MPa", [1, 2, 3])); // far too short
part.FieldDisplay = new FieldDisplay { Field = "stress" };
// Resolution itself succeeds -- it does not mesh, deliberately, so a properties panel or
// an MCP tool can call it with no GL. The length check belongs to whatever draws it.
if (!part.TryResolveFieldDisplay(out var display, out _)) throw new Exception("should resolve");
if (display.Field.Count == part.GetMesh().VertexCount) throw new Exception("this field is short");
// A display naming a result that is not there fails loudly, and says what IS there.
part.FieldDisplay = new FieldDisplay { Field = "temperature" };
if (part.TryResolveFieldDisplay(out _, out string? error)) throw new Exception("should fail");
if (error is null || !error.Contains("stress")) throw new Exception("the error should list the results");

In the viewer the Fields toolbar toggle switches the whole thing off (every part back to its own colour and undeformed shape) and the properties panel shows the selected part’s results, the one being displayed, its range and any deformation scale — with a Result dropdown switching which result the part shows (the load-step/frequency selector in its honest discrete form: results are named states, so the control is a choice, not a range). Switching keeps the rest of the display — deformation, range, map — and it is one undoable edit through the same seam saving and MCP share. Headlessly, EngrCad.RenderToImage(scene, path, fields: false) does the same — which is how a geometry figure is taken of a model that also carries results.

Cell-associated fields

A field is vertex-associated by default, and interpolates across faces; a cell-associated field (MeshField.CellScalar, or the constructor’s FieldAssociation.Cell) carries one value per face — an element quality, a material id, a per-element stress — and renders flat on each. The association is part of the field’s identity: every derived operation (Magnitude, Component, Renamed, Scaled) preserves it, VtuWriter routes each field to PointData or CellData by it (counts validated against the right total), and the flat render mesh’s source-face map places a cell value on every duplicate of its face’s corners, so the flat look needs no shader change. A smooth render mesh shares vertices between faces, so it honestly carries no face map and a cell display on one refuses with the reason. A structural .vtu now carries the per-element von Mises as cell data beside the recovered nodal field — the value the assembly actually integrated, before any nodal recovery.