Send the sample transformation chain in mounting order
Build Packages / build:windows:nocuda (push) Successful in 2m34s
Build Packages / build:viewer-tgz:cpu (push) Successful in 17m9s
Build Packages / build:viewer-tgz:cuda (push) Successful in 19m41s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 22m39s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 23m20s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 27m40s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m55s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 29m34s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m34s
Build Packages / XDS test (durin plugin) (push) Successful in 12m14s
Build Packages / build:rpm (rocky9) (push) Successful in 22m26s
Build Packages / Generate python client (push) Successful in 41s
Build Packages / build:rpm (rocky8) (push) Successful in 27m9s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m36s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m45s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 24m42s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m29s
Build Packages / DIALS test (push) Successful in 23m27s
Build Packages / Unit tests (push) Failing after 1h54m37s
Build Packages / build:windows:cuda (push) Successful in 18m2s

The chain could not be expressed. A goniometer axis, the Smargon chi/phi and a
grid stage each travelled by a different route - the `goniometer` map, a private
JSON key inside user_data, and `grid_scan` - and nothing said what order they are
mounted in. The order cannot go in the `goniometer` map either: that is a DECTRIS
stream2 field, and RFC 8949 requires deterministic encoders to SORT map keys, so
a map's order is not something a consumer may rely on.

So `transformations` is sent as an ordered ARRAY, base first, each element
carrying name, type, axis vector and angles. The `goniometer` map and `grid_scan`
are still emitted beside it, unchanged, for consumers that only know stream2 -
nothing vendor-defined is mutated, and a stream2 consumer sees exactly what it
saw before.

Smargon chi and phi are ordinary stationary axes that were only ever separate for
historical reasons, and they now appear in the chain like any other. They are
also read back: reader/ had no smargon support at all, so re-opening a file lost
the head position silently. Combined with the earlier change that writes them for
a still rather than only alongside a rotation or a grid scan, the round trip is
now closed.

Metadata version 7. A broker and a writer from different releases must not be
mixed across this: an older writer ignores the chain and reads the unordered map,
so anything whose order matters - a Smargon position, or a grid scan combined
with a rotation - is not reproduced. Said so in docs/CBOR.md and the changelog.

CBORSerialize_Start_Transformations asserts the ORDER survives, not just the
contents, which is the whole point of the array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:07:32 +02:00
co-authored by Claude Opus 5
parent d2f57975e8
commit 842c43a86e
9 changed files with 188 additions and 2 deletions
@@ -1015,6 +1015,43 @@ namespace {
}
}
// The ordered sample chain (see TransformationAxis). An array, so the mounting order survives -
// a CBOR map's order carries no guarantee. Sent alongside the goniometer map, which says the same
// thing without the order for consumers that only know DECTRIS stream2.
void ProcessTransformations(StartMessage &message, CborValue &value) {
CborValue array_value;
cborErr(cbor_value_enter_container(&value, &array_value));
while (!cbor_value_at_end(&array_value)) {
CborValue map_value;
cborErr(cbor_value_enter_container(&array_value, &map_value));
TransformationAxis axis;
while (!cbor_value_at_end(&map_value)) {
const auto key = GetCBORString(map_value);
if (key == "name")
axis.name = GetCBORString(map_value);
else if (key == "type")
axis.rotation = (GetCBORString(map_value) == "rotation");
else if (key == "vector")
axis.vector = GetCoord(map_value);
else if (key == "start")
axis.start = GetCBORFloat(map_value);
else if (key == "increment")
axis.increment = GetCBORFloat(map_value);
else
cbor_value_advance(&map_value);
}
cborErr(cbor_value_leave_container(&array_value, &map_value));
if (axis.name.empty())
throw JFJochException(JFJochExceptionCategory::CBORError,
"Transformation axis without a name");
message.transformations.push_back(axis);
}
cborErr(cbor_value_leave_container(&value, &array_value));
}
void ProcessGoniometerMap(StartMessage &message, CborValue &value) {
CborValue map_value;
@@ -1261,6 +1298,8 @@ namespace {
ProcessAxis(value, message.detector_translation);
else if (key == "goniometer")
ProcessGoniometerMap(message, value);
else if (key == "transformations")
ProcessTransformations(message, value);
else if (key == "grid_scan")
message.grid_scan = ProcessGridScan(value);
else if (key == "pixel_mask_enabled")