Quantity formatting for text annotation fields
A FieldRun is a run inside a TextBlock that displays the value of a property on some other element, rather than literal text the author typed. When the source element changes, the field's cached display string is recomputed so the annotation stays in step with the data it describes.
A property stores its value in one unit, but an annotation usually needs to display it in another: a length persisted in meters may have to read as millimeters on one drawing and feet on the next. Fields whose target property resolves to a "quantity" or "coordinate" value bridge that gap by rendering through the standard iTwin.js quantity formatting pipeline.
Formatting stays on the backend, because text layout is a backend concern. For the mechanics of the relationship that keeps fields up to date, see ElementDrivesTextAnnotation.
Format one field
Out of the box, a quantity field is presented using the format its KindOfQuantity declares in the iModel's schemas, in the metric unit system:
// Nothing registered: the field is presented using the format Snippets.LENGTH declares.
const fieldRun = FieldRun.create({
propertyHost: { elementId, schemaName: "Snippets", className: "Widget" },
propertyPath: { propertyName: "length" },
});
const block = TextBlock.create();
block.appendRun(fieldRun);
ElementDrivesTextAnnotation.evaluateFields({ iModel, block }); const formattedContent = fieldRun.cachedContent; // "2.5 m"
An application that wants something else adopts a FormatSet for the iModel, then evaluates the blocks that need it:
// The Snippets.LENGTH KindOfQuantity persists its values in meters. This FormatSet
// presents that KindOfQuantity in millimeters instead.
const formatSet: FormatSet = {
name: "Millimeters",
label: "Millimeters",
unitSystem: "metric",
formats: {
"Snippets.LENGTH": {
type: "Decimal",
precision: 2,
formatTraits: ["keepSingleZero", "showUnitLabel"],
uomSeparator: " ",
composite: { includeZero: true, units: [{ name: "Units.MM", label: "mm" }] },
},
},
};
// Adopt it for the iModel. Registration is synchronous and replaces any prior registration. ElementDrivesTextAnnotation.registerFieldFormatting({ iModel, formatSet });
// A field displaying the length property of a widget that is 2.5 meters long.
const fieldRun = FieldRun.create({
propertyHost: { elementId, schemaName: "Snippets", className: "Widget" },
propertyPath: { propertyName: "length" },
});
const block = TextBlock.create(); block.appendRun(fieldRun);
// Evaluation updates the cached content of every field in the block, in memory. ElementDrivesTextAnnotation.evaluateFields({ iModel, block });
const formattedContent = fieldRun.cachedContent; // "2500 mm"
Three things have to line up:
- A FormatSet naming the KindOfQuantity to present and the units to present it in.
- A registration, which adopts the FormatSet for the iModel. It is synchronous and does no work up front.
- An evaluation, which formats the fields in a block. A FormatterSpec is built for each field as it is evaluated and discarded afterwards; building one costs on the order of a microsecond, so nothing is cached.
Evaluating fields
ElementDrivesTextAnnotation.evaluateFields updates the FieldRun.cachedContent of every field in the supplied TextBlock and returns the number it changed:
const numUpdated = ElementDrivesTextAnnotation.evaluateFields({ iModel, block });
It mutates the in-memory TextBlock; it does not persist. Callers that want the formatted output to survive the session must assign the updated block back to the owning element (for example via TextAnnotation2d.setAnnotation / TextAnnotation3d.setAnnotation) and call element.update() inside a transaction.
The same evaluation runs automatically from the TxnManager field-update callbacks when a source element changes. Those callbacks are synchronous, which is why evaluation is too. Resolving a format, looking up its units and building the spec are all synchronous on the backend — the iModel's SchemaContext loads schemas synchronously, and the bundled BIS units need no loading at all — so nothing has to be prepared ahead of time.
Overrides and format resolution
A property resolves to "quantity" if it is numeric (double, int or long); point2d and point3d resolve to "coordinate". Classifying a property as "quantity" only decides whether the formatting pipeline is consulted for it — a value that resolves no format still renders as a bare number, so counts and identifiers are unaffected.
A field that should not simply inherit its property's KindOfQuantity configures the QuantityFieldFormatOptions block on FieldFormatOptions:
const fieldRun = FieldRun.create({ propertyHost: { elementId, schemaName: "Snippets", className: "Widget" }, propertyPath: { propertyName: "length" }, formatOptions: { quantity: { // Name the KindOfQuantity to format through. Here it is the property's own KoQ, // stated explicitly; naming a different one overrides it. kindOfQuantity: "Snippets.LENGTH", // Optionally scope resolution to a specific registered FormatSet. formatSet: formatSetId, }, }, });
kindOfQuantity and persistenceUnit are independent overrides: setting one falls through to the property side for the other, and an empty string counts as unset. A name the iModel's schemas define may use any case and either Schema.Item or Schema:Item spelling; a KindOfQuantity defined only by a FormatSet must match its key exactly. This lets a caller control how a value is formatted (via kindOfQuantity) while still reading the persistence unit from the EC property, or supply a unit for a property that has none.
For each "quantity" or "coordinate" field the formatter looks up a FormatterSpec by (KindOfQuantity name, persistence unit name) pair, in this order:
- Effective override pair.
formatOptions.quantity.kindOfQuantity ?? propertyKindOfQuantityfor the name,propertyPersistenceUnit ?? formatOptions.quantity.persistenceUnitfor the unit. - Property-side pair.
(propertyKindOfQuantity, propertyPersistenceUnit)— skipped when identical to the effective pair.
The two halves fall back in opposite directions on purpose. kindOfQuantity only chooses how a magnitude is displayed, so the field's choice wins and the property's is the fallback. persistenceUnit states what the stored magnitude means, and the schema is the authority on that: when the property declares a persistence unit, that unit is used and a persistenceUnit naming a different one is ignored with a warning logged (category BackendLoggerCategory.IModelDb). persistenceUnit matters for properties that have no unit of their own — a "coordinate", a plain double, or a JSON leaf — where it supplies the missing half of the pair.
The first pair whose format-props lookup and persistence-unit lookup both succeed wins. If none succeeds, "quantity" and "coordinate" fields fall back to their raw string representation (value.toString() for "quantity", a (x, y[, z]) tuple for "coordinate").
Registering FormatSets
Registration is optional. An iModel with no registration resolves each KindOfQuantity to the presentation format its schema declares. Register only to layer your own FormatSets over those defaults, or to choose a different unit system:
ElementDrivesTextAnnotation.registerFieldFormatting({ iModel, formatSet });
Do so when the iModel opens. Field evaluation fires from TxnManager callbacks on any source-element edit, and an edit that lands before registration formats through the schema default and persists that string. Since registering does not walk existing annotations, that field is not revisited until the next edit to the same source.
Core performs no discovery of its own: it never walks the iModel looking for annotations, and it does not need to. Each field's spec is built from the registered FormatSets and the iModel's schemas at the moment it is evaluated, so the cost of formatting is proportional to the number of fields evaluated, not to the size of the iModel.
Units
Unit lookup uses the units bundled with @itwin/core-quantity — the Units schema that BIS-based iModels share. A persistence unit or format unit defined only by a custom schema in the iModel is not recognized; a field depending on one renders raw and is logged (below).
Diagnosing unformatted fields
If a field needs a spec that cannot be built — its KindOfQuantity is defined by neither a registered FormatSet nor the iModel's schemas, one of its units is not a bundled unit, or the format's units belong to a different phenomenon than the persisted value — it renders as value.toString() and a warning is logged under the BackendLoggerCategory.IModelDb category naming the element, property, FormatSet and the (KindOfQuantity, persistence unit) pairs that were tried. Treat these as a diagnostic for FormatSets or annotations that are out of step with each other rather than as an error report.
Multiple FormatSets and registration lifetime
To mix formats within a single iModel:
// A second FormatSet presenting the same KindOfQuantity in feet, registered under an // application-chosen id that fields reference to opt into it. const imperialFormatSetId = "0x1000"; const imperialFormatSet: FormatSet = { name: "Imperial", label: "Imperial", unitSystem: "imperial", formats: { "Snippets.LENGTH": { type: "Decimal", precision: 2, formatTraits: ["keepSingleZero", "showUnitLabel"], uomSeparator: " ", composite: { includeZero: true, units: [{ name: "Units.FT", label: "ft" }] }, }, }, };
ElementDrivesTextAnnotation.registerFieldFormatting({ iModel, formatSet: millimeterFormatSet, // applies to every field that names no other formatSets: [{ id: imperialFormatSetId, formatSet: imperialFormatSet }], });
// A field opts into the imperial set by naming its id. const imperialField = FieldRun.create({ propertyHost: { elementId, schemaName: "Snippets", className: "Widget" }, propertyPath: { propertyName: "length" }, formatOptions: { quantity: { formatSet: imperialFormatSetId } }, });
const block = TextBlock.create(); block.appendRun(imperialField); ElementDrivesTextAnnotation.evaluateFields({ iModel, block });
const imperialContent = imperialField.cachedContent; // "8.2 ft"
Generally speaking, the FormatSet id should be the id of the FormatSet definition element, but as Core does not enforce the definition element workflow, this is typed as a string. If two entries share an id, the last one wins.
This is still a single registration. One call supplies every FormatSet the iModel uses, and fields select among them at evaluation time. There is no need to register once per FormatSet, and no need to re-register to change which format a given field gets.
Registration lifetime
A registration lives exactly as long as its IModelDb object. Core holds it through a weak reference to the iModel, so closing the iModel releases it and there is nothing to unregister on close.
Registering does not reformat existing annotations; applications that need to refresh already-persisted cachedContent must re-evaluate the affected blocks explicitly. ElementDrivesTextAnnotation.onFieldFormattingChanged fires after every registration, whether or not the configuration changed, and is the natural place to trigger that refresh. A listener that throws does not undo the registration.
To revert an iModel to the schema default, call registerFieldFormatting({ iModel }) with no FormatSets. This does not turn formatting off: the next source-element edit re-renders a field that was "2500 mm" under the FormatSet as "2.5 m" under the schema. Changing the adopted FormatSet therefore needs only a second registerFieldFormatting call — each registration replaces the prior one — rather than a revert followed by a register.
Advanced
Coordinates and JSON values
Core does not carry a built-in coordinate format: how coordinates are formatted is application policy and belongs to the FormatsProvider / FormatSet supplied by the host. Coordinate values whose EC property has no KindOfQuantity require the caller to declare both kindOfQuantity and persistenceUnit in formatOptions.quantity for an override to take effect — Core does not synthesize a persistence unit from the BIS geometry meters convention. Callers that want that convention should pass Units.LENGTH.M (from @itwin/core-quantity) explicitly.
The same rule applies to a field whose FieldPropertyPath.jsonAccessors reach into a Json string property such as JsonProperties. A numeric leaf is treated as a "quantity", but it has no EC property behind it and therefore no property-side pair to fall through to — so declare both kindOfQuantity and persistenceUnit to have it formatted. Declaring one or neither is harmless: the field renders its raw value, exactly as it would have without a quantity type. A JSON null resolves to no value at all, so the field displays its invalid-content indicator rather than a stringified null.
Last Updated: 02 October, 2026