Repository: rerun-io/rerun
Pull request #9750: Use approximate equals when comparing floats in snippets
Author: login: emilk
Labels: name: 🔨 testing
description: testing and benchmarks
name: exclude from changelog
description: PRs with this won't show up in CHANGELOG.md
Created: 2025-04-15T20:46:49Z | Merged: 2025-04-17T10:40:26Z

### Related
* Closes #3235
* Part of https://github.com/rerun-io/rerun/issues/9110
* Unblocks https://github.com/rerun-io/rerun/pull/9751
* Unblocks https://github.com/rerun-io/rerun/issues/9588

### What
In our snippet comparisons, ignore small numeric differences (that often occur because of differences in programming language, or compiler/interpreter version)

Linked issue #3235: Allow for some leeway when comparing floats in `rerun compare`
E.g. expose a CLI flag or something.

This makes API examples much more natural at it avoids doing crazy stuff just to make the roundtrip tests pass across different languages.
Linked issue comments: user: login: emilk
created_at: 2025-03-27T07:11:40Z
body: (copied from a [duplicate issue](https://github.com/rerun-io/rerun/issues/9394)):

A lot of our snippet comparison tests are disabled due to small floating point differences between Python, Rust and C++. We should add an `--aproximate` mode to the `rerun rrd compare` function that ignores small differences.

It doesn't need to be perfect or robust, just good enough for our testing purposes. Something like:

* If the datatype is f32 or f64
* Subtract and see that the abs difference is less than 0.01
author_association: MEMBER
user: login: emilk
created_at: 2025-04-15T11:33:32Z
body: Fixing this would also improve our backwards compatibility tests, by comparing migrated .rrd snippets with the newly produced rrd snippets (which should be the same, within rounding error)
author_association: MEMBER

Diff:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c64b0178f627..ff201630bf5e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -202,7 +202,7 @@ Makes sure all of the snippets in the [snippets/](./docs/snippets/) folder are w
 ### "Roundtrip" tests
 
 ```sh
-pixi run ./tests/roundtrips.py
+pixi run -e py ./tests/roundtrips.py
 ```
 
 A set of cross SDK language tests that makes sure that the same logging commands for a select group of archetypes
diff --git a/Cargo.lock b/Cargo.lock
index 090a2e779fa7..a90c7afd7a03 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6601,7 +6601,9 @@ dependencies = [
 name = "re_arrow_util"
 version = "0.23.0-alpha.3+dev"
 dependencies = [
+ "anyhow",
  "arrow",
+ "half",
  "insta",
  "itertools 0.14.0",
  "re_log",
diff --git a/crates/store/re_chunk/src/chunk.rs b/crates/store/re_chunk/src/chunk.rs
index 2bb4bb031264..1ef5982071cc 100644
--- a/crates/store/re_chunk/src/chunk.rs
+++ b/crates/store/re_chunk/src/chunk.rs
@@ -1,6 +1,7 @@
 use std::sync::atomic::{AtomicU64, Ordering};
 
 use ahash::HashMap;
+use anyhow::Context as _;
 use arrow::{
     array::{
         Array as ArrowArray, ArrayRef as ArrowArrayRef, FixedSizeBinaryArray,
@@ -130,6 +131,30 @@ impl ChunkComponents {
     ) -> impl Iterator<Item = (ComponentDescriptor, ArrowListArray)> {
         self.0.into_values().flatten()
     }
+
+    /// Approximate equal, that ignores small numeric differences.
+    ///
+    /// Returns `Ok` if similar.
+    /// If there is a difference, a description of that difference is returned in `Err`.
+    /// We use [`anyhow`] to provide context.
+    ///
+    /// Useful for tests.
+    pub fn ensure_similar(left: &Self, right: &Self) -> anyhow::Result<()> {
+        anyhow::ensure!(left.len() == right.len());
+        for (comp_name, left_comp_map) in left.iter() {
+            let Some(right_map) = right.get(comp_name) else {
+                anyhow::bail!("rhs is missing {comp_name:?}");
+            };
+            for (descr, left_array) in left_comp_map {
+                let Some(right_array) = right_map.get(descr) else {
+                    anyhow::bail!("rhs {comp_name:?} is missing {descr:?}");
+                };
+                re_arrow_util::ensure_similar(&left_array.to_data(), &right_array.to_data())
+                    .with_context(|| format!("Component {comp_name:?}"))?;
+            }
+        }
+        Ok(())
+    }
 }
 
 impl std::ops::Deref for ChunkComponents {
@@ -267,12 +292,16 @@ impl Chunk {
         self
     }
 
-    /// Returns `true` is two [`Chunk`]s are similar, although not byte-for-byte equal.
+    /// Returns `Ok` if two [`Chunk`]s are _similar_, although not byte-for-byte equal.
     ///
     /// In particular, this ignores chunks and row IDs, as well as `log_time` timestamps.
+    /// It also forgives small numeric inaccuracies in floating point buffers.
+    ///
+    /// If there is a difference, a description of that difference is returned in `Err`.
+    /// We use [`anyhow`] to provide context.
     ///
     /// Useful for tests.
-    pub fn are_similar(lhs: &Self, rhs: &Self) -> bool {
+    pub fn ensure_similar(lhs: &Self, rhs: &Self) -> anyhow::Result<()> {
         let Self {
             id: _,
             entity_path,
@@ -283,36 +312,32 @@ impl Chunk {
             components,
         } = lhs;
 
-        if *entity_path != rhs.entity_path {
-            return false;
-        }
+        anyhow::ensure!(*entity_path == rhs.entity_path);
 
-        if timelines.keys().collect_vec() != rhs.timelines.keys().collect_vec() {
-            return false;
-        }
+        anyhow::ensure!(timelines.keys().collect_vec() == rhs.timelines.keys().collect_vec());
 
-        let timelines: IntMap<_, _> = timelines
-            .iter()
-            .map(|(timeline, time_column)| (*timeline, time_column))
-            .filter(|(timeline, _time_column)| timeline != &TimelineName::log_time())
-            .collect();
-        let rhs_timelines: IntMap<_, _> = rhs
-            .timelines
-            .iter()
-            .map(|(timeline, time_column)| (*timeline, time_column))
-            .filter(|(timeline, _time_column)| timeline != &TimelineName::log_time())
-            .collect();
-        if timelines != rhs_timelines {
-            return false;
+        for (timeline, left_time_col) in timelines {
+            let right_time_col = rhs
+                .timelines
+                .get(timeline)
+                .ok_or_else(|| anyhow::format_err!("right is missing timeline {timeline:?}"))?;
+            if timeline == &TimelineName::log_time() {
+                continue; // We expect this to differ
+            }
+            if timeline == "sim_time" {
+                continue; // Small numeric differences
+            }
+            anyhow::ensure!(
+                left_time_col == right_time_col,
+                "Timeline differs: {timeline:?}"
+            );
         }
 
         // Handle edge case: recording time on partition properties should ignore start time.
         if entity_path == &EntityPath::recording_properties() {
             // We're going to filter out some components on both lhs and rhs.
             // Therefore, it's important that we first check that the number of components is the same.
-            if components.len() != rhs.components.len() {
-                return false;
-            }
+            anyhow::ensure!(components.len() == rhs.components.len());
 
             // Copied from `rerun.archetypes.RecordingProperties`.
             let recording_time_descriptor = ComponentDescriptor {
@@ -342,14 +367,11 @@ impl Chunk {
                 })
                 .collect::<IntMap<_, _>>();
 
-            if lhs_components != rhs_components {
-                return false;
-            }
-        } else if *components != rhs.components {
-            return false;
+            anyhow::ensure!(lhs_components == rhs_components);
+            Ok(())
+        } else {
+            ChunkComponents::ensure_similar(components, &rhs.components)
         }
-
-        true
     }
 
     // Only used for tests atm
diff --git a/crates/store/re_chunk/src/transport.rs b/crates/store/re_chunk/src/transport.rs
index 0fbc607f85e5..dbb4ddca3570 100644
--- a/crates/store/re_chunk/src/transport.rs
+++ b/crates/store/re_chunk/src/transport.rs
@@ -14,6 +14,7 @@ impl Chunk {
     /// Prepare the [`Chunk`] for transport.
     ///
     /// It is probably a good idea to sort the chunk first.
+    // TODO(#8744): this is infallible, so we should not return a `Result` here.
     pub fn to_record_batch(&self) -> ChunkResult<ArrowRecordBatch> {
         re_tracing::profile_function!();
         Ok(self.to_chunk_batch()?.into())
@@ -22,6 +23,7 @@ impl Chunk {
     /// Prepare the [`Chunk`] for transport.
     ///
     /// It is probably a good idea to sort the chunk first.
+    // TODO(#8744): this is infallible, so we should not return a `Result` here.
     pub fn to_chunk_batch(&self) -> ChunkResult<re_sorbet::ChunkBatch> {
         re_tracing::profile_function!();
         self.sanity_check()?;
diff --git a/crates/store/re_format_arrow/src/lib.rs b/crates/store/re_format_arrow/src/lib.rs
index 8cd9f7077d94..d8e58f0fb431 100644
--- a/crates/store/re_format_arrow/src/lib.rs
+++ b/crates/store/re_format_arrow/src/lib.rs
@@ -4,13 +4,13 @@ use std::fmt::Formatter;
 
 use arrow::{
     array::{Array, ArrayRef, ListArray},
-    datatypes::{DataType, Field, Fields, IntervalUnit, TimeUnit},
+    datatypes::{DataType, Field, Fields},
     util::display::{ArrayFormatter, FormatOptions},
 };
 use comfy_table::{presets, Cell, Row, Table};
 use itertools::{Either, Itertools as _};
 
-use re_arrow_util::ArrowArrayDowncastRef as _;
+use re_arrow_util::{format_data_type, ArrowArrayDowncastRef as _};
 use re_tuid::Tuid;
 use re_types_core::Loggable as _;
 
@@ -76,122 +76,6 @@ fn parse_tuid(array: &dyn Array, index: usize) -> Option<Tuid> {
 
 // ---
 
-// arrow has `ToString` implemented, but it is way too verbose.
-#[repr(transparent)]
-struct DisplayTimeUnit(TimeUnit);
-
-impl std::fmt::Display for DisplayTimeUnit {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        let s = match self.0 {
-            TimeUnit::Second => "s",
-            TimeUnit::Millisecond => "ms",
-            TimeUnit::Microsecond => "us",
-            TimeUnit::Nanosecond => "ns",
-        };
-        f.write_str(s)
-    }
-}
-
-// arrow has `ToString` implemented, but it is way too verbose.
-#[repr(transparent)]
-struct DisplayIntervalUnit(IntervalUnit);
-
-impl std::fmt::Display for DisplayIntervalUnit {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        let s = match self.0 {
-            IntervalUnit::YearMonth => "year/month",
-            IntervalUnit::DayTime => "day/time",
-            IntervalUnit::MonthDayNano => "month/day/nano",
-        };
-        f.write_str(s)
-    }
-}
-
-// arrow has `ToString` implemented, but it is way too verbose.
-#[repr(transparent)]
-struct DisplayDatatype<'a>(&'a DataType);
-
-impl std::fmt::Display for DisplayDatatype<'_> {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        let s = match &self.0 {
-            DataType::Null => "null",
-            DataType::Boolean => "bool",
-            DataType::Int8 => "i8",
-            DataType::Int16 => "i16",
-            DataType::Int32 => "i32",
-            DataType::Int64 => "i64",
-            DataType::UInt8 => "u8",
-            DataType::UInt16 => "u16",
-            DataType::UInt32 => "u32",
-            DataType::UInt64 => "u64",
-            DataType::Float16 => "f16",
-            DataType::Float32 => "f32",
-            DataType::Float64 => "f64",
-            DataType::Timestamp(unit, timezone) => {
-                let s = if let Some(tz) = timezone {
-                    format!("Timestamp({}, {tz})", DisplayTimeUnit(*unit))
-                } else {
-                    format!("Timestamp({})", DisplayTimeUnit(*unit))
-                };
-                return f.write_str(&s);
-            }
-            DataType::Date32 => "Date32",
-            DataType::Date64 => "Date64",
-            DataType::Time32(unit) => {
-                let s = format!("Time32({})", DisplayTimeUnit(*unit));
-                return f.write_str(&s);
-            }
-            DataType::Time64(unit) => {
-                let s = format!("Time64({})", DisplayTimeUnit(*unit));
-                return f.write_str(&s);
-            }
-            DataType::Duration(unit) => {
-                let s = format!("Duration({})", DisplayTimeUnit(*unit));
-                return f.write_str(&s);
-            }
-            DataType::Interval(unit) => {
-                let s = format!("Interval({})", DisplayIntervalUnit(*unit));
-                return f.write_str(&s);
-            }
-            DataType::Binary => "Binary",
-            DataType::FixedSizeBinary(size) => return write!(f, "FixedSizeBinary[{size}]"),
-            DataType::LargeBinary => "LargeBinary",
-            DataType::Utf8 => "Utf8",
-            DataType::LargeUtf8 => "LargeUtf8",
-            DataType::List(ref field) => {
-                let s = format!("List[{}]", Self(field.data_type()));
-                return f.write_str(&s);
-            }
-            DataType::FixedSizeList(field, len) => {
-                let s = format!("FixedSizeList[{}; {len}]", Self(field.data_type()));
-                return f.write_str(&s);
-            }
-            DataType::LargeList(field) => {
-                let s = format!("LargeList[{}]", Self(field.data_type()));
-                return f.write_str(&s);
-            }
-            DataType::Struct(fields) => return write!(f, "Struct[{}]", fields.len()),
-            DataType::Union(fields, _) => return write!(f, "Union[{}]", fields.len()),
-            DataType::Map(field, _) => return write!(f, "Map[{}]", Self(field.data_type())),
-            DataType::Dictionary(key, value) => {
-                return write!(f, "Dictionary{{{}: {}}}", Self(key), Self(value));
-            }
-            DataType::Decimal128(_, _) => "Decimal128",
-            DataType::Decimal256(_, _) => "Decimal256",
-            DataType::BinaryView => "BinaryView",
-            DataType::Utf8View => "Utf8View",
-            DataType::ListView(field) => return write!(f, "ListView[{}]", Self(field.data_type())),
-            DataType::LargeListView(field) => {
-                return write!(f, "LargeListView[{}]", Self(field.data_type()));
-            }
-            DataType::RunEndEncoded(_run_ends, values) => {
-                return write!(f, "RunEndEncoded[{}]", Self(values.data_type()));
-            }
-        };
-        f.write_str(s)
-    }
-}
-
 struct DisplayMetadata {
     prefix: &'static str,
     metadata: Metadata,
@@ -350,8 +234,6 @@ fn format_dataframe_without_metadata(
     columns: &[ArrayRef],
     opts: &RecordBatchFormatOpts,
 ) -> (usize, Table) {
-    const MAXIMUM_CELL_CONTENT_WIDTH: u16 = 100;
-
     let &RecordBatchFormatOpts {
         transposed,
         width,
@@ -407,20 +289,7 @@ fn format_dataframe_without_metadata(
 
             for i in 0..col.len() {
                 let cell = match formatter(i) {
-                    Ok(string) => {
-                        let chars: Vec<_> = string.chars().collect();
-                        if chars.len() > MAXIMUM_CELL_CONTENT_WIDTH as usize {
-                            Cell::new(
-                                chars
-                                    .into_iter()
-                                    .take(MAXIMUM_CELL_CONTENT_WIDTH.saturating_sub(1).into())
-                                    .chain(['…'])
-                                    .collect::<String>(),
-                            )
-                        } else {
-                            Cell::new(string)
-                        }
-                    }
+                    Ok(string) => format_cell(string),
                     Err(err) => Cell::new(err),
                 };
                 cells.push(cell);
@@ -437,13 +306,13 @@ fn format_dataframe_without_metadata(
                     Cell::new(format!(
                         "{}\n---\ntype: {}",
                         trim_name(field.name()),
-                        DisplayDatatype(field.data_type()),
+                        format_data_type(field.data_type()),
                     ))
                 } else {
                     Cell::new(format!(
                         "{}\n---\ntype: {}\n{}",
                         trim_name(field.name()),
-                        DisplayDatatype(field.data_type()),
+                        format_data_type(field.data_type()),
                         DisplayMetadata {
                             prefix: "",
                             metadata: field.metadata().clone().into_iter().collect()
@@ -467,20 +336,7 @@ fn format_dataframe_without_metadata(
             let cells: Vec<_> = formatters
                 .iter()
                 .map(|formatter| match formatter(row) {
-                    Ok(string) => {
-                        let chars: Vec<_> = string.chars().collect();
-                        if chars.len() > MAXIMUM_CELL_CONTENT_WIDTH as usize {
-                            Cell::new(
-                                chars
-                                    .into_iter()
-                                    .take(MAXIMUM_CELL_CONTENT_WIDTH.saturating_sub(1).into())
-                                    .chain(['…'])
-                                    .collect::<String>(),
-                            )
-                        } else {
-                            Cell::new(string)
-                        }
-                    }
+                    Ok(string) => format_cell(string),
                     Err(err) => Cell::new(err),
                 })
                 .collect();
@@ -502,3 +358,20 @@ fn format_dataframe_without_metadata(
 
     (num_columns, table)
 }
+
+fn format_cell(string: String) -> Cell {
+    const MAXIMUM_CELL_CONTENT_WIDTH: u16 = 100;
+
+    let chars: Vec<_> = string.chars().collect();
+    if chars.len() > MAXIMUM_CELL_CONTENT_WIDTH as usize {
+        Cell::new(
+            chars
+                .into_iter()
+                .take(MAXIMUM_CELL_CONTENT_WIDTH.saturating_sub(1).into())
+                .chain(['…'])
+                .collect::<String>(),
+        )
+    } else {
+        Cell::new(string)
+    }
+}
diff --git a/crates/store/re_types/src/archetypes/capsules3d.rs b/crates/store/re_types/src/archetypes/capsules3d.rs
index 1c76fdaca242..43c6de61a4f8 100644
--- a/crates/store/re_types/src/archetypes/capsules3d.rs
+++ b/crates/store/re_types/src/archetypes/capsules3d.rs
@@ -36,27 +36,30 @@ use ::re_types_core::{DeserializationError, DeserializationResult};
 ///
 ///     rec.log(
 ///         "capsules",
-///         &rerun::Capsules3D::from_lengths_and_radii([0., 2., 4., 6., 8.], [1., 0.5, 0.5, 0.5, 1.])
-///             .with_colors([
-///                 rerun::Color::from_rgb(255, 0, 0),
-///                 rerun::Color::from_rgb(188, 188, 0),
-///                 rerun::Color::from_rgb(0, 255, 0),
-///                 rerun::Color::from_rgb(0, 188, 188),
-///                 rerun::Color::from_rgb(0, 0, 255),
-///             ])
-///             .with_translations([
-///                 vec3(0., 0., 0.),
-///                 vec3(2., 0., 0.),
-///                 vec3(4., 0., 0.),
-///                 vec3(6., 0., 0.),
-///                 vec3(8., 0., 0.),
-///             ])
-///             .with_rotation_axis_angles((0..5).map(|i| {
-///                 rerun::RotationAxisAngle::new(
-///                     [1.0, 0.0, 0.0],
-///                     rerun::Angle::from_degrees(i as f32 * -22.5),
-///                 )
-///             })),
+///         &rerun::Capsules3D::from_lengths_and_radii(
+///             [0.0, 2.0, 4.0, 6.0, 8.0],
+///             [1.0, 0.5, 0.5, 0.5, 1.0],
+///         )
+///         .with_colors([
+///             rerun::Color::from_rgb(255, 0, 0),
+///             rerun::Color::from_rgb(188, 188, 0),
+///             rerun::Color::from_rgb(0, 255, 0),
+///             rerun::Color::from_rgb(0, 188, 188),
+///             rerun::Color::from_rgb(0, 0, 255),
+///         ])
+///         .with_translations([
+///             vec3(0., 0., 0.),
+///             vec3(2., 0., 0.),
+///             vec3(4., 0., 0.),
+///             vec3(6., 0., 0.),
+///             vec3(8., 0., 0.),
+///         ])
+///         .with_rotation_axis_angles((0..5).map(|i| {
+///             rerun::RotationAxisAngle::new(
+///                 [1.0, 0.0, 0.0],
+///                 rerun::Angle::from_degrees(i as f32 * -22.5),
+///             )
+///         })),
 ///     )?;
 ///
 ///     Ok(())
diff --git a/crates/top/rerun/src/commands/rrd/compare.rs b/crates/top/rerun/src/commands/rrd/compare.rs
index 21092b939989..40a833441b30 100644
--- a/crates/top/rerun/src/commands/rrd/compare.rs
+++ b/crates/top/rerun/src/commands/rrd/compare.rs
@@ -83,7 +83,7 @@ impl CompareCommand {
             'outer: for chunk1 in &chunks1 {
                 for chunk2 in chunks2_opt.iter_mut().filter(|c| c.is_some()) {
                     #[allow(clippy::unwrap_used)]
-                    if re_chunk::Chunk::are_similar(chunk1, chunk2.as_ref().unwrap()) {
+                    if re_chunk::Chunk::ensure_similar(chunk1, chunk2.as_ref().unwrap()).is_ok() {
                         *chunk2 = None;
                         continue 'outer;
                     }
@@ -93,18 +93,32 @@ impl CompareCommand {
             }
         }
 
+        fn format_chunk(chunk: &Chunk) -> String {
+            re_format_arrow::format_record_batch_opts(
+                &chunk.to_record_batch().expect("Cannot fail in practice"),
+                &re_format_arrow::RecordBatchFormatOpts {
+                    transposed: false,
+                    width: Some(800),
+                    include_metadata: true,
+                    include_column_metadata: true,
+                },
+            )
+            .to_string()
+        }
+
         if !*unordered || unordered_failed {
             for (chunk1, chunk2) in izip!(chunks1, chunks2) {
-                anyhow::ensure!(
-                    re_chunk::Chunk::are_similar(&chunk1, &chunk2),
-                    "Chunks do not match:\n{}",
-                    similar_asserts::SimpleDiff::from_str(
-                        &format!("{chunk1}"),
-                        &format!("{chunk2}"),
-                        "got",
-                        "expected",
-                    ),
-                );
+                re_chunk::Chunk::ensure_similar(&chunk1, &chunk2).with_context(|| {
+                    format!(
+                        "Chunks diff:\n{}",
+                        similar_asserts::SimpleDiff::from_str(
+                            &format_chunk(&chunk1),
+                            &format_chunk(&chunk2),
+                            "got",
+                            "expected",
+                        ),
+                    )
+                })?;
             }
         }
 
diff --git a/crates/utils/re_arrow_util/Cargo.toml b/crates/utils/re_arrow_util/Cargo.toml
index c2b64f596448..1ffaaec53af9 100644
--- a/crates/utils/re_arrow_util/Cargo.toml
+++ b/crates/utils/re_arrow_util/Cargo.toml
@@ -26,7 +26,9 @@ all-features = true
 re_log.workspace = true
 re_tracing.workspace = true
 
+anyhow.workspace = true
 arrow.workspace = true
+half.workspace = true
 itertools.workspace = true
 
 [dev-dependencies]
diff --git a/crates/utils/re_arrow_util/src/compare.rs b/crates/utils/re_arrow_util/src/compare.rs
new file mode 100644
index 000000000000..4455e0868663
--- /dev/null
+++ b/crates/utils/re_arrow_util/src/compare.rs
@@ -0,0 +1,150 @@
+use anyhow::{bail, ensure, Context as _};
+use half::f16;
+use itertools::izip;
+
+use crate::format_data_type;
+
+/// Are two arrays equal, ignoring small numeric differences?
+///
+/// Returns `Ok` if similar.
+/// If there is a difference, a description of that difference is returned in `Err`.
+/// We use [`anyhow`] to provide context.
+pub fn ensure_similar(
+    left: &arrow::array::ArrayData,
+    right: &arrow::array::ArrayData,
+) -> anyhow::Result<()> {
+    ensure!(left.data_type() == right.data_type());
+
+    let data_type = left.data_type();
+
+    if matches!(data_type, arrow::datatypes::DataType::Union { .. }) {
+        // We encode arrow unions slightly different in Python and Rust.
+        // TODO(#6388): Remove this hack once we have stopped using arrow unions.
+        ensure!(left == right);
+        return Ok(());
+    }
+
+    ensure!(left.len() == right.len());
+    ensure!(left.offset() == right.offset());
+    ensure!(left.null_count() == right.null_count());
+    ensure!(left.nulls() == right.nulls());
+
+    {
+        // Compare buffers:
+        let left_buffers = left.buffers();
+        let right_buffers = right.buffers();
+
+        ensure!(left_buffers.len() == right_buffers.len());
+
+        for (i, (left_buff, right_buff)) in izip!(left_buffers, right_buffers).enumerate() {
+            ensure_buffers_equal(left_buff, right_buff, data_type)
+                .with_context(|| format!("Datatype {}", format_data_type(data_type)))
+                .with_context(|| format!("Buffer {i}"))?;
+        }
+    }
+
+    {
+        // Compare children:
+        let left_children = left.child_data();
+        let right_children = right.child_data();
+
+        ensure!(left_children.len() == right_children.len());
+
+        for (i, (left_child, right_child)) in izip!(left_children, right_children).enumerate() {
+            ensure_similar(left_child, right_child)
+                .with_context(|| format!("Datatype {}", format_data_type(data_type)))
+                .with_context(|| format!("Child {i}"))?;
+        }
+    }
+
+    Ok(())
+}
+
+fn ensure_buffers_equal(
+    left_buff: &arrow::buffer::Buffer,
+    right_buff: &arrow::buffer::Buffer,
+    data_type: &arrow::datatypes::DataType,
+) -> anyhow::Result<()> {
+    ensure!(left_buff.len() == right_buff.len());
+
+    if data_type == &arrow::datatypes::DataType::Float16 {
+        // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+        let left_floats = left_buff.typed_data::<f16>();
+        let right_floats = right_buff.typed_data::<f16>();
+        for (&l, &r) in izip!(left_floats, right_floats) {
+            if !almost_equal_f64(l.to_f64(), r.to_f64(), 1e-3) {
+                bail!("Significant f16 difference: {l} vs {r}");
+            }
+        }
+    } else if data_type == &arrow::datatypes::DataType::Float32 {
+        // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+        let left_floats = left_buff.typed_data::<f32>();
+        let right_floats = right_buff.typed_data::<f32>();
+        for (&l, &r) in izip!(left_floats, right_floats) {
+            if !almost_equal_f64(l as f64, r as f64, 1e-3) {
+                bail!("Significant f32 difference: {l} vs {r}");
+            }
+        }
+    } else if data_type == &arrow::datatypes::DataType::Float64 {
+        // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+        let left_floats = left_buff.typed_data::<f64>();
+        let right_floats = right_buff.typed_data::<f64>();
+        for (&l, &r) in izip!(left_floats, right_floats) {
+            if !almost_equal_f64(l, r, 1e-8) {
+                bail!("Significant f64 difference: {l} vs {r}");
+            }
+        }
+    } else {
+        ensure!(left_buff == right_buff);
+    }
+
+    Ok(())
+}
+
+/// Return true when arguments are the same within some rounding error.
+///
+/// For instance `almost_equal(x, x.to_degrees().to_radians(), f64::EPSILON)` should hold true for all x.
+/// The `epsilon`  can be `f64::EPSILON` to handle simple transforms (like degrees -> radians)
+/// but should be higher to handle more complex transformations.
+pub fn almost_equal_f64(a: f64, b: f64, epsilon: f64) -> bool {
+    if a == b {
+        true // handle infinites
+    } else {
+        let abs_max = a.abs().max(b.abs());
+        abs_max <= epsilon || ((a - b).abs() / abs_max) <= epsilon
+    }
+}
+
+#[test]
+fn test_almost_equal() {
+    for &x in &[
+        0.0_f64,
+        f64::MIN_POSITIVE,
+        1e-20,
+        1e-10,
+        f64::EPSILON,
+        0.1,
+        0.99,
+        1.0,
+        1.001,
+        1e10,
+        f64::MAX / 100.0,
+        // f64::MAX, // overflows in rad<->deg test
+        f64::INFINITY,
+    ] {
+        for &x in &[-x, x] {
+            for roundtrip in &[
+                |x: f64| x.to_degrees().to_radians(),
+                |x: f64| x.to_radians().to_degrees(),
+            ] {
+                let epsilon = f64::EPSILON;
+                assert!(
+                    almost_equal_f64(x, roundtrip(x), epsilon),
+                    "{} vs {}",
+                    x,
+                    roundtrip(x)
+                );
+            }
+        }
+    }
+}
diff --git a/crates/utils/re_arrow_util/src/format_data_type.rs b/crates/utils/re_arrow_util/src/format_data_type.rs
new file mode 100644
index 000000000000..b3da763822f4
--- /dev/null
+++ b/crates/utils/re_arrow_util/src/format_data_type.rs
@@ -0,0 +1,125 @@
+//! `arrow` has `ToString` implemented, but it is way too verbose.
+
+use std::fmt::Formatter;
+
+use arrow::datatypes::{DataType, IntervalUnit, TimeUnit};
+
+/// Compact format of an arrow data type.
+pub fn format_data_type(data_type: &DataType) -> String {
+    DisplayDatatype(data_type).to_string()
+}
+
+#[repr(transparent)]
+struct DisplayTimeUnit(TimeUnit);
+
+impl std::fmt::Display for DisplayTimeUnit {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let s = match self.0 {
+            TimeUnit::Second => "s",
+            TimeUnit::Millisecond => "ms",
+            TimeUnit::Microsecond => "us",
+            TimeUnit::Nanosecond => "ns",
+        };
+        f.write_str(s)
+    }
+}
+
+// arrow has `ToString` implemented, but it is way too verbose.
+#[repr(transparent)]
+struct DisplayIntervalUnit(IntervalUnit);
+
+impl std::fmt::Display for DisplayIntervalUnit {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let s = match self.0 {
+            IntervalUnit::YearMonth => "year/month",
+            IntervalUnit::DayTime => "day/time",
+            IntervalUnit::MonthDayNano => "month/day/nano",
+        };
+        f.write_str(s)
+    }
+}
+
+// arrow has `ToString` implemented, but it is way too verbose.
+#[repr(transparent)]
+struct DisplayDatatype<'a>(&'a DataType);
+
+impl std::fmt::Display for DisplayDatatype<'_> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let s = match &self.0 {
+            DataType::Null => "null",
+            DataType::Boolean => "bool",
+            DataType::Int8 => "i8",
+            DataType::Int16 => "i16",
+            DataType::Int32 => "i32",
+            DataType::Int64 => "i64",
+            DataType::UInt8 => "u8",
+            DataType::UInt16 => "u16",
+            DataType::UInt32 => "u32",
+            DataType::UInt64 => "u64",
+            DataType::Float16 => "f16",
+            DataType::Float32 => "f32",
+            DataType::Float64 => "f64",
+            DataType::Timestamp(unit, timezone) => {
+                let s = if let Some(tz) = timezone {
+                    format!("Timestamp({}, {tz})", DisplayTimeUnit(*unit))
+                } else {
+                    format!("Timestamp({})", DisplayTimeUnit(*unit))
+                };
+                return f.write_str(&s);
+            }
+            DataType::Date32 => "Date32",
+            DataType::Date64 => "Date64",
+            DataType::Time32(unit) => {
+                let s = format!("Time32({})", DisplayTimeUnit(*unit));
+                return f.write_str(&s);
+            }
+            DataType::Time64(unit) => {
+                let s = format!("Time64({})", DisplayTimeUnit(*unit));
+                return f.write_str(&s);
+            }
+            DataType::Duration(unit) => {
+                let s = format!("Duration({})", DisplayTimeUnit(*unit));
+                return f.write_str(&s);
+            }
+            DataType::Interval(unit) => {
+                let s = format!("Interval({})", DisplayIntervalUnit(*unit));
+                return f.write_str(&s);
+            }
+            DataType::Binary => "Binary",
+            DataType::FixedSizeBinary(size) => return write!(f, "FixedSizeBinary[{size}]"),
+            DataType::LargeBinary => "LargeBinary",
+            DataType::Utf8 => "Utf8",
+            DataType::LargeUtf8 => "LargeUtf8",
+            DataType::List(ref field) => {
+                let s = format!("List[{}]", Self(field.data_type()));
+                return f.write_str(&s);
+            }
+            DataType::FixedSizeList(field, len) => {
+                let s = format!("FixedSizeList[{}; {len}]", Self(field.data_type()));
+                return f.write_str(&s);
+            }
+            DataType::LargeList(field) => {
+                let s = format!("LargeList[{}]", Self(field.data_type()));
+                return f.write_str(&s);
+            }
+            DataType::Struct(fields) => return write!(f, "Struct[{}]", fields.len()),
+            DataType::Union(fields, _) => return write!(f, "Union[{}]", fields.len()),
+            DataType::Map(field, _) => return write!(f, "Map[{}]", Self(field.data_type())),
+            DataType::Dictionary(key, value) => {
+                return write!(f, "Dictionary{{{}: {}}}", Self(key), Self(value));
+            }
+            DataType::Decimal128(_, _) => "Decimal128",
+            DataType::Decimal256(_, _) => "Decimal256",
+            DataType::BinaryView => "BinaryView",
+            DataType::Utf8View => "Utf8View",
+            DataType::ListView(field) => return write!(f, "ListView[{}]", Self(field.data_type())),
+            DataType::LargeListView(field) => {
+                return write!(f, "LargeListView[{}]", Self(field.data_type()));
+            }
+            DataType::RunEndEncoded(_run_ends, values) => {
+                return write!(f, "RunEndEncoded[{}]", Self(values.data_type()));
+            }
+        };
+        f.write_str(s)
+    }
+}
diff --git a/crates/utils/re_arrow_util/src/lib.rs b/crates/utils/re_arrow_util/src/lib.rs
index ff1c1372483b..0f7a50a0256a 100644
--- a/crates/utils/re_arrow_util/src/lib.rs
+++ b/crates/utils/re_arrow_util/src/lib.rs
@@ -2,6 +2,10 @@
 
 mod arrays;
 mod batches;
+mod compare;
+mod format_data_type;
 
 pub use self::arrays::*;
 pub use self::batches::*;
+pub use self::compare::*;
+pub use self::format_data_type::*;
diff --git a/docs/snippets/all/archetypes/capsules3d_batch.cpp b/docs/snippets/all/archetypes/capsules3d_batch.cpp
index ef001d33f8a6..28bffbe57e28 100644
--- a/docs/snippets/all/archetypes/capsules3d_batch.cpp
+++ b/docs/snippets/all/archetypes/capsules3d_batch.cpp
@@ -27,7 +27,7 @@ int main() {
                 {8.0f, 0.0f, 0.0f},
             })
             .with_rotation_axis_angles({
-                rerun::RotationAxisAngle(),
+                rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(0.0)),
                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-22.5)),
                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-45.0)),
                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-67.5)),
diff --git a/docs/snippets/all/archetypes/capsules3d_batch.rs b/docs/snippets/all/archetypes/capsules3d_batch.rs
index 5a2280adc1a8..2b61cd058df1 100644
--- a/docs/snippets/all/archetypes/capsules3d_batch.rs
+++ b/docs/snippets/all/archetypes/capsules3d_batch.rs
@@ -7,27 +7,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
 
     rec.log(
         "capsules",
-        &rerun::Capsules3D::from_lengths_and_radii([0., 2., 4., 6., 8.], [1., 0.5, 0.5, 0.5, 1.])
-            .with_colors([
-                rerun::Color::from_rgb(255, 0, 0),
-                rerun::Color::from_rgb(188, 188, 0),
-                rerun::Color::from_rgb(0, 255, 0),
-                rerun::Color::from_rgb(0, 188, 188),
-                rerun::Color::from_rgb(0, 0, 255),
-            ])
-            .with_translations([
-                vec3(0., 0., 0.),
-                vec3(2., 0., 0.),
-                vec3(4., 0., 0.),
-                vec3(6., 0., 0.),
-                vec3(8., 0., 0.),
-            ])
-            .with_rotation_axis_angles((0..5).map(|i| {
-                rerun::RotationAxisAngle::new(
-                    [1.0, 0.0, 0.0],
-                    rerun::Angle::from_degrees(i as f32 * -22.5),
-                )
-            })),
+        &rerun::Capsules3D::from_lengths_and_radii(
+            [0.0, 2.0, 4.0, 6.0, 8.0],
+            [1.0, 0.5, 0.5, 0.5, 1.0],
+        )
+        .with_colors([
+            rerun::Color::from_rgb(255, 0, 0),
+            rerun::Color::from_rgb(188, 188, 0),
+            rerun::Color::from_rgb(0, 255, 0),
+            rerun::Color::from_rgb(0, 188, 188),
+            rerun::Color::from_rgb(0, 0, 255),
+        ])
+        .with_translations([
+            vec3(0., 0., 0.),
+            vec3(2., 0., 0.),
+            vec3(4., 0., 0.),
+            vec3(6., 0., 0.),
+            vec3(8., 0., 0.),
+        ])
+        .with_rotation_axis_angles((0..5).map(|i| {
+            rerun::RotationAxisAngle::new(
+                [1.0, 0.0, 0.0],
+                rerun::Angle::from_degrees(i as f32 * -22.5),
+            )
+        })),
     )?;
 
     Ok(())
diff --git a/docs/snippets/all/archetypes/instance_poses3d_combined.py b/docs/snippets/all/archetypes/instance_poses3d_combined.py
index 71e0d8d488bd..e9e1a9ae4e02 100644
--- a/docs/snippets/all/archetypes/instance_poses3d_combined.py
+++ b/docs/snippets/all/archetypes/instance_poses3d_combined.py
@@ -9,7 +9,10 @@
 
 # Log a box and points further down in the hierarchy.
 rr.log("world/box", rr.Boxes3D(half_sizes=[[1.0, 1.0, 1.0]]))
-rr.log("world/box/points", rr.Points3D(np.vstack([xyz.ravel() for xyz in np.mgrid[3 * [slice(-10, 10, 10j)]]]).T))
+lin = np.linspace(-10, 10, 10)
+z, y, x = np.meshgrid(lin, lin, lin, indexing="ij")
+point_grid = np.vstack([x.flatten(), y.flatten(), z.flatten()]).T
+rr.log("world/box/points", rr.Points3D(point_grid))
 
 for i in range(180):
     rr.set_time("frame", sequence=i)
diff --git a/docs/snippets/all/archetypes/mesh3d_partial_updates.py b/docs/snippets/all/archetypes/mesh3d_partial_updates.py
index 03e6b7d746ff..f8fd7583ec1f 100644
--- a/docs/snippets/all/archetypes/mesh3d_partial_updates.py
+++ b/docs/snippets/all/archetypes/mesh3d_partial_updates.py
@@ -18,8 +18,8 @@
     ),
 )
 
-# Only update its vertices' positions each frame
-factors = np.abs(np.sin(np.arange(1, 300, dtype=np.float32) * 0.04))
-for i, factor in enumerate(factors):
+# Only update its vertices' positions each frame:
+for i in range(1, 300):
+    factor = np.abs(np.sin(i * 0.04))
     rr.set_time("frame", sequence=i)
     rr.log("triangle", rr.Mesh3D.from_fields(vertex_positions=vertex_positions * factor))
diff --git a/docs/snippets/all/archetypes/transform3d_axes.cpp b/docs/snippets/all/archetypes/transform3d_axes.cpp
index 1ca877d0a5ac..90e5b3e1d5a5 100644
--- a/docs/snippets/all/archetypes/transform3d_axes.cpp
+++ b/docs/snippets/all/archetypes/transform3d_axes.cpp
@@ -6,7 +6,7 @@ int main() {
     const auto rec = rerun::RecordingStream("rerun_example_transform3d_axes");
     rec.spawn().exit_on_failure();
 
-    auto base_axes = rerun::Transform3D().with_axis_length(1.0);
+    auto base_axes = rerun::Transform3D::clear_fields().with_axis_length(1.0);
 
     rec.set_time_sequence("step", 0);
 
@@ -17,7 +17,7 @@ int main() {
 
         rec.log(
             "base/rotated",
-            rerun::Transform3D().with_axis_length(0.5).with_rotation_axis_angle(
+            rerun::Transform3D::clear_fields().with_axis_length(0.5).with_rotation_axis_angle(
                 rerun::RotationAxisAngle(
                     {1.0f, 1.0f, 1.0f},
                     rerun::Angle::degrees(static_cast<float>(deg))
@@ -27,7 +27,9 @@ int main() {
 
         rec.log(
             "base/rotated/translated",
-            rerun::Transform3D().with_axis_length(0.5).with_translation({2.0f, 0.0f, 0.0f})
+            rerun::Transform3D::clear_fields().with_axis_length(0.5).with_translation(
+                {2.0f, 0.0f, 0.0f}
+            )
         );
     }
 }
diff --git a/docs/snippets/all/archetypes/transform3d_axes.py b/docs/snippets/all/archetypes/transform3d_axes.py
index 1e53a5b11f55..0cec60a2dd22 100644
--- a/docs/snippets/all/archetypes/transform3d_axes.py
+++ b/docs/snippets/all/archetypes/transform3d_axes.py
@@ -14,8 +14,9 @@
     rr.set_time("step", sequence=deg)
     rr.log(
         "base/rotated",
-        rr.Transform3D(
-            rotation=rr.RotationAxisAngle(
+        rr.Transform3D.from_fields(
+            clear_unset=True,
+            rotation_axis_angle=rr.RotationAxisAngle(
                 axis=[1.0, 1.0, 1.0],
                 degrees=deg,
             ),
@@ -24,7 +25,8 @@
     )
     rr.log(
         "base/rotated/translated",
-        rr.Transform3D(
+        rr.Transform3D.from_fields(
+            clear_unset=True,
             translation=[2.0, 0, 0],
             axis_length=0.5,
         ),
diff --git a/docs/snippets/all/archetypes/transform3d_hierarchy.py b/docs/snippets/all/archetypes/transform3d_hierarchy.py
index 5c66339f7fb8..40addb2c80b6 100644
--- a/docs/snippets/all/archetypes/transform3d_hierarchy.py
+++ b/docs/snippets/all/archetypes/transform3d_hierarchy.py
@@ -6,10 +6,12 @@
 
 rr.init("rerun_example_transform3d_hierarchy", spawn=True)
 
-# One space with the sun in the center, and another one with the planet.
-rr.send_blueprint(
-    rrb.Horizontal(rrb.Spatial3DView(origin="sun"), rrb.Spatial3DView(origin="sun/planet", contents="sun/**")),
-)
+if False:
+    # One space with the sun in the center, and another one with the planet.
+    # TODO(#5521): enable this once we have it in Rust too, so that the snippets compare equally
+    rr.send_blueprint(
+        rrb.Horizontal(rrb.Spatial3DView(origin="sun"), rrb.Spatial3DView(origin="sun/planet", contents="sun/**")),
+    )
 
 rr.set_time("sim_time", duration=0)
 
diff --git a/docs/snippets/snippets.toml b/docs/snippets/snippets.toml
index 137b7b7822ed..68bd42ef75ac 100644
--- a/docs/snippets/snippets.toml
+++ b/docs/snippets/snippets.toml
@@ -280,25 +280,16 @@ quick_start = [ # These examples don't have exactly the same implementation.
   "rust",
 ]
 "archetypes/arrows3d_simple" = [
-  "py",  # Python uses doubles
-  "cpp", # Trigonometry doesn't work out the same with all C++ toolchains (*some* Windows machines yield a different result)
+  "py", # Python has different colors. TODO(emilk): fix that
 ]
 "archetypes/bar_chart" = [ # On Windows this logs f64 instead of u64 unless a numpy array with explicit type is used.
   "py",
 ]
-"archetypes/capsules3d_batch" = [ # TODO(#3235): Degree to radian conversion is slightly different.
-  "cpp",
-  "py",
-  "rust",
-]
 "archetypes/ellipsoids3d_simple" = [ # TODO(#3206): examples use different RNGs
   "cpp",
   "py",
   "rust",
 ]
-"archetypes/mesh3d_partial_updates" = [ # Python uses doubles
-  "py",
-]
 "archetypes/pinhole_simple" = [ # TODO(#3206): examples use different RNGs
   "cpp",
   "py",
@@ -314,9 +305,6 @@ quick_start = [ # These examples don't have exactly the same implementation.
   "py",
   "rust",
 ]
-"archetypes/scalar_multiple_plots" = [ # trigonometric functions have slightly different outcomes
-  "cpp",
-]
 "archetypes/tensor_simple" = [ # TODO(#3206): examples use different RNGs
   "cpp",
   "py",
@@ -327,45 +315,6 @@ quick_start = [ # These examples don't have exactly the same implementation.
   "py",
   "rust",
 ]
-"archetypes/transform3d_axes" = [ # TODO(#3235): Degree to radian conversion is slightly different.
-  "cpp",
-  "py",
-  "rust",
-]
-"archetypes/transform3d_hierarchy" = [ # Uses a lot of trigonometry which is surprisingly easy to get the same on Rust & C++, but not on Python/Numpy
-  "py",
-]
-"archetypes/instance_poses3d_combined" = [ # TODO(#3235): Slight floating point differences in point grid.
-  "cpp",
-  "py",
-  "rust",
-]
-"archetypes/mesh3d_instancing" = [ # Python uses doubles
-  "py",
-]
-"archetypes/series_line_style" = [
-  "cpp", # somehow, C++ yields a different result, see <https://github.com/rerun-io/rerun/pull/8851>
-]
-"archetypes/series_point_style" = [
-  "cpp", # somehow, C++ yields a different result, see <https://github.com/rerun-io/rerun/pull/8851>
-]
-
-"archetypes/scalars_multiple_plots" = [ # TODO(#3235): Slight floating point differences
-  "cpp",
-  "py",
-  "rust",
-]
-"archetypes/series_lines_style" = [ # TODO(#3235): Slight floating point differences
-  "cpp",
-  "py",
-  "rust",
-]
-"archetypes/series_points_style" = [ # TODO(#3235): Slight floating point differences
-  "cpp",
-  "py",
-  "rust",
-]
-
 
 # -----------------------------------------------------------------------------
 
diff --git a/rerun_cpp/src/rerun/archetypes/capsules3d.hpp b/rerun_cpp/src/rerun/archetypes/capsules3d.hpp
index 1fd0b1705b3c..73cd164fcb61 100644
--- a/rerun_cpp/src/rerun/archetypes/capsules3d.hpp
+++ b/rerun_cpp/src/rerun/archetypes/capsules3d.hpp
@@ -64,7 +64,7 @@ namespace rerun::archetypes {
     ///                 {8.0f, 0.0f, 0.0f},
     ///             })
     ///             .with_rotation_axis_angles({
-    ///                 rerun::RotationAxisAngle(),
+    ///                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(0.0)),
     ///                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-22.5)),
     ///                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-45.0)),
     ///                 rerun::RotationAxisAngle({1.0f, 0.0f, 0.0f}, rerun::Angle::degrees(-67.5)),
diff --git a/rerun_py/rerun_sdk/rerun/archetypes/instance_poses3d.py b/rerun_py/rerun_sdk/rerun/archetypes/instance_poses3d.py
index f08de362d06e..676e7dcb8b4e 100644
--- a/rerun_py/rerun_sdk/rerun/archetypes/instance_poses3d.py
+++ b/rerun_py/rerun_sdk/rerun/archetypes/instance_poses3d.py
@@ -52,7 +52,10 @@ class InstancePoses3D(Archetype):
 
     # Log a box and points further down in the hierarchy.
     rr.log("world/box", rr.Boxes3D(half_sizes=[[1.0, 1.0, 1.0]]))
-    rr.log("world/box/points", rr.Points3D(np.vstack([xyz.ravel() for xyz in np.mgrid[3 * [slice(-10, 10, 10j)]]]).T))
+    lin = np.linspace(-10, 10, 10)
+    z, y, x = np.meshgrid(lin, lin, lin, indexing="ij")
+    point_grid = np.vstack([x.flatten(), y.flatten(), z.flatten()]).T
+    rr.log("world/box/points", rr.Points3D(point_grid))
 
     for i in range(180):
         rr.set_time("frame", sequence=i)
diff --git a/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py b/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py
index e57505edff70..3cc78e3b8b01 100644
--- a/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py
+++ b/rerun_py/rerun_sdk/rerun/archetypes/transform3d.py
@@ -81,10 +81,12 @@ class Transform3D(Transform3DExt, Archetype):
 
     rr.init("rerun_example_transform3d_hierarchy", spawn=True)
 
-    # One space with the sun in the center, and another one with the planet.
-    rr.send_blueprint(
-        rrb.Horizontal(rrb.Spatial3DView(origin="sun"), rrb.Spatial3DView(origin="sun/planet", contents="sun/**")),
-    )
+    if False:
+        # One space with the sun in the center, and another one with the planet.
+        # TODO(#5521): enable this once we have it in Rust too, so that the snippets compare equally
+        rr.send_blueprint(
+            rrb.Horizontal(rrb.Spatial3DView(origin="sun"), rrb.Spatial3DView(origin="sun/planet", contents="sun/**")),
+        )
 
     rr.set_time("sim_time", duration=0)
 


Reviews:
user: login: teh-cmc
body: neat
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:09:39Z
user: login: emilk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:18:56Z
user: login: emilk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T09:20:18Z

Review comments:
diff_hunk: @@ -93,18 +93,32 @@ impl CompareCommand {
             }
         }
 
+        fn format_chunk(chunk: &Chunk) -> String {
+            re_format_arrow::format_record_batch_opts(
+                &chunk.to_record_batch().expect("Cannot fail in practice"),
+                &re_format_arrow::RecordBatchFormatOpts {
+                    transposed: true,
+                    width: None,
+                    include_metadata: true,
+                    include_column_metadata: false,
+                },
path: crates/top/rerun/src/commands/rrd/compare.rs
user: login: teh-cmc
body: I really want to see all the metadata when a roundtrip fails:
```suggestion
                &re_format_arrow::RecordBatchFormatOpts {
                    transposed: false,
                    width: Some(800),
                    include_metadata: true,
                    include_column_metadata: true,
                },
```

created_at: 2025-04-17T08:55:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,138 @@
+use half::f16;
+use itertools::izip;
+
+/// Are two arrays equal, ignoring small numeric differences?
+pub fn approximate_equals(left: &arrow::array::ArrayData, right: &arrow::array::ArrayData) -> bool {
+    if left.data_type() != right.data_type()
+        || left.len() != right.len()
+        || left.offset() != right.offset()
+    {
+        return false;
+    }
+
+    let data_type = left.data_type();
+
+    if left.null_count() != right.null_count() {
+        return false;
+    }
+    if left.nulls() != right.nulls() {
+        return false;
+    }
+
+    {
+        // Compare buffers:
+        let left_buffers = left.buffers();
+        let right_buffers = right.buffers();
+
+        if left_buffers.len() != right_buffers.len() {
+            return false;
+        }
+
+        for (i, (left_buff, right_buff)) in izip!(left_buffers, right_buffers).enumerate() {
+            if left_buff.len() != right_buff.len() {
+                return false;
+            }
+
+            if data_type == &arrow::datatypes::DataType::Float16 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f16>();
+                let right_floats = right_buff.typed_data::<f16>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l.to_f32(), r.to_f32(), 1e-3) {
+                        re_log::debug!("Significant f16 difference: {l} vs {r}");
+                        return false;
+                    }
+                }
+            } else if data_type == &arrow::datatypes::DataType::Float32 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f32>();
+                let right_floats = right_buff.typed_data::<f32>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l, r, 1e-3) {
+                        re_log::debug!("Significant f32 difference: {l} vs {r}");
+                        return false;
+                    }
+                }
+            } else if data_type == &arrow::datatypes::DataType::Float64 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f64>();
+                let right_floats = right_buff.typed_data::<f64>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l as f32, r as f32, 1e-5) {
+                        re_log::debug!("Significant f64 difference: {l} vs {r}");
+                        return false;
path: crates/utils/re_arrow_util/src/compare.rs
user: login: teh-cmc
body: Doesn't the lossy f32 cast undermine the whole thing? Shouldn't `almost_equal_f32` be `almost_equal_f64` instead? Any reason why it's not?
created_at: 2025-04-17T08:58:59Z
author_association: CONTRIBUTOR
path: docs/snippets/snippets.toml
user: login: teh-cmc
body: sweet
created_at: 2025-04-17T09:02:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,138 @@
+use half::f16;
+use itertools::izip;
+
+/// Are two arrays equal, ignoring small numeric differences?
+pub fn approximate_equals(left: &arrow::array::ArrayData, right: &arrow::array::ArrayData) -> bool {
path: crates/utils/re_arrow_util/src/compare.rs
user: login: teh-cmc
body: Neat. Pretty sweet what one can do these days with just `ArrayData` and `Buffer` :eyes: 
created_at: 2025-04-17T09:08:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,138 @@
+use half::f16;
+use itertools::izip;
+
+/// Are two arrays equal, ignoring small numeric differences?
+pub fn approximate_equals(left: &arrow::array::ArrayData, right: &arrow::array::ArrayData) -> bool {
path: crates/utils/re_arrow_util/src/compare.rs
user: login: emilk
body: Yeah, it's really cool
created_at: 2025-04-17T09:18:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,138 @@
+use half::f16;
+use itertools::izip;
+
+/// Are two arrays equal, ignoring small numeric differences?
+pub fn approximate_equals(left: &arrow::array::ArrayData, right: &arrow::array::ArrayData) -> bool {
+    if left.data_type() != right.data_type()
+        || left.len() != right.len()
+        || left.offset() != right.offset()
+    {
+        return false;
+    }
+
+    let data_type = left.data_type();
+
+    if left.null_count() != right.null_count() {
+        return false;
+    }
+    if left.nulls() != right.nulls() {
+        return false;
+    }
+
+    {
+        // Compare buffers:
+        let left_buffers = left.buffers();
+        let right_buffers = right.buffers();
+
+        if left_buffers.len() != right_buffers.len() {
+            return false;
+        }
+
+        for (i, (left_buff, right_buff)) in izip!(left_buffers, right_buffers).enumerate() {
+            if left_buff.len() != right_buff.len() {
+                return false;
+            }
+
+            if data_type == &arrow::datatypes::DataType::Float16 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f16>();
+                let right_floats = right_buff.typed_data::<f16>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l.to_f32(), r.to_f32(), 1e-3) {
+                        re_log::debug!("Significant f16 difference: {l} vs {r}");
+                        return false;
+                    }
+                }
+            } else if data_type == &arrow::datatypes::DataType::Float32 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f32>();
+                let right_floats = right_buff.typed_data::<f32>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l, r, 1e-3) {
+                        re_log::debug!("Significant f32 difference: {l} vs {r}");
+                        return false;
+                    }
+                }
+            } else if data_type == &arrow::datatypes::DataType::Float64 {
+                // Approximate compare to accommodate differences in snippet output from Python/C++/Rust
+                let left_floats = left_buff.typed_data::<f64>();
+                let right_floats = right_buff.typed_data::<f64>();
+                for (&l, &r) in izip!(left_floats, right_floats) {
+                    if !almost_equal_f32(l as f32, r as f32, 1e-5) {
+                        re_log::debug!("Significant f64 difference: {l} vs {r}");
+                        return false;
path: crates/utils/re_arrow_util/src/compare.rs
user: login: emilk
body: It cuts off some precision, before comparing them to even lesser precision 🤷‍♂️
created_at: 2025-04-17T09:20:16Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-15T20:48:20Z
body: <!-- add-pr-comment:web-viewer-build-status -->

Web viewer built successfully. If applicable, you should also test it:

- [ ] I have tested the web viewer

| Result | Commit  | Link | Manifest |
| ------ | ------- | ---- | -------- |
| ✅ | 7ffb400 | https://rerun.io/viewer/pr/9750 | [`+nightly`](https://rerun.io/viewer/pr/9750?manifest_url=https://app.rerun.io/version/nightly/examples_manifest.json) [`+main`](https://rerun.io/viewer/pr/9750?manifest_url=https://app.rerun.io/version/main/examples_manifest.json) |

<sup>Note: This comment is updated whenever you push a commit.</sup>
author_association: CONTRIBUTOR
user: login: emilk
created_at: 2025-04-17T08:19:37Z
body: @rerun-bot full-check
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-17T08:20:15Z
body: <!-- add-pr-comment:pr-9750-14511282516 -->

Started a full build: https://github.com/rerun-io/rerun/actions/runs/14511290423
author_association: CONTRIBUTOR
user: login: emilk
created_at: 2025-04-17T09:55:30Z
body: @rerun-bot full-check
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-17T09:56:05Z
body: <!-- add-pr-comment:pr-9750-14513037076 -->

Started a full build: https://github.com/rerun-io/rerun/actions/runs/14513044184
author_association: CONTRIBUTOR