brep_kernel/props/mass_properties/identity.rs
1//! The content identity of the face set the integrator actually reads.
2//!
3//! Two `solid_mass_properties` / `solid_signed_volume` calls return the same
4//! bits exactly when they see the same geometry in the same order. This hash
5//! is that statement, made checkable: it walks precisely the fields the
6//! integrator reads and nothing else, in the order it reads them.
7//!
8//! **What goes in.** Per shell in order, per face in order: the carrier's
9//! degrees, knot vectors and control points, `same_sense`, then per loop in
10//! order and per coedge in order the pcurve's degree, knots and control
11//! points and the coedge's `forward` flag.
12//!
13//! **What stays out.** `id` and `name` on every record, and the solid's
14//! `vertices` and `edges` — `solid_props.rs` never reads them, so a solid
15//! whose edge table was renumbered integrates to the same bits and must hash
16//! the same. `genus` is topology bookkeeping the integrator never consults.
17//!
18//! **Order, not set.** Both entry points run a compensated sum over the faces
19//! in shell order; permuting the faces permutes the addends and can move the
20//! last bits. So the hash is over the SEQUENCE. "The same face set" is a
21//! station-signature statement; this is the stronger one that licenses
22//! serving a stored value.
23//!
24//! **Why the bits and not the value.** `f64::to_bits` distinguishes `-0.0`
25//! from `0.0` and makes every NaN payload its own key. Both are what the
26//! integrator sees: it can consume a negative zero (the sign survives a
27//! multiply) and it never normalizes a NaN. Hashing the value would let two
28//! inputs the integrator distinguishes share a key.
29
30use crate::{BrepSolid, FaceRecord, NurbsCurve, NurbsSurface, Vec4};
31
32/// The 128-bit content identity of a solid's face set.
33///
34/// Two hashes are equal when the two solids present the integrator with the
35/// same geometry in the same order. It is a hash, so equality is evidence and
36/// not proof — but see the width note on [`absorb`]: a consumer that serves a
37/// stored value on a hit is betting on 2^-128, and this file owes that bet a
38/// mixer worth the number.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub struct FaceSetIdentity {
41 lanes: [u64; 2],
42 /// Faces walked, carried beside the digest as a cheap first discriminator
43 /// and as the thing a profile line can print.
44 pub faces: u64,
45}
46
47impl FaceSetIdentity {
48 pub fn hex(&self) -> String {
49 format!("{:016x}{:016x}", self.lanes[0], self.lanes[1])
50 }
51}
52
53/// The splitmix64 finalizer (Steele/Lea/Flood, "Fast Splittable Pseudorandom
54/// Number Generators"): two multiply-xorshift rounds that avalanche every
55/// input bit across all 64 output bits.
56#[inline]
57fn mix64(mut z: u64) -> u64 {
58 z ^= z >> 30;
59 z = z.wrapping_mul(0xbf58_476d_1ce4_e5b9);
60 z ^= z >> 27;
61 z = z.wrapping_mul(0x94d0_49bb_1331_11eb);
62 z ^ (z >> 31)
63}
64
65/// Two independent 64-bit streams over the same word sequence, distinguished
66/// by their seed and their per-word increment.
67///
68/// **Why not `FxHasher`.** The crate's fast hasher is a rotate-xor-multiply
69/// per word with no finalizer; it is the right tool for a `HashMap` bucket
70/// index and the wrong one here. Control-point words are highly structured —
71/// exact zeros, exact ones, repeated knots, shared coordinates — and a weak
72/// mixer's collisions cluster on exactly that kind of input. A collision here
73/// does not cost a slow lookup, it serves a WRONG VOLUME silently. splitmix64
74/// per word, twice, is a few ns per station's worth of data against an
75/// integrator that spends 335 ns per station.
76struct TwoLane {
77 state: [u64; 2],
78}
79
80const SEEDS: [u64; 2] = [0x9e37_79b9_7f4a_7c15, 0xc2b2_ae3d_27d4_eb4f];
81const STEPS: [u64; 2] = [0xa076_1d64_78bd_642f, 0xe703_7ed1_a0b4_28db];
82
83impl TwoLane {
84 fn new() -> Self {
85 Self { state: SEEDS }
86 }
87
88 #[inline]
89 fn u64(&mut self, value: u64) {
90 for lane in 0..2 {
91 // Order-sensitive by construction: the state enters the mix, so
92 // permuting the words permutes the digest.
93 self.state[lane] = mix64(self.state[lane] ^ value).wrapping_add(STEPS[lane]);
94 }
95 }
96
97 #[inline]
98 fn usize(&mut self, value: usize) {
99 self.u64(value as u64);
100 }
101
102 #[inline]
103 fn bool(&mut self, value: bool) {
104 self.u64(value as u64);
105 }
106
107 /// `to_bits`, not the value: it separates `-0.0` from `0.0` and gives every
108 /// NaN payload its own key. Both are what the integrator sees — it can
109 /// consume a negative zero (the sign survives a multiply) and it never
110 /// normalizes a NaN — so hashing the value would let two inputs the
111 /// integrator distinguishes share a key.
112 #[inline]
113 fn f64(&mut self, value: f64) {
114 self.u64(value.to_bits());
115 }
116
117 fn floats(&mut self, values: &[f64]) {
118 // Length first: without it `[a], [b, c]` and `[a, b], [c]` are the
119 // same word stream.
120 self.usize(values.len());
121 for value in values {
122 self.f64(*value);
123 }
124 }
125
126 fn control_point(&mut self, point: &Vec4) {
127 self.f64(point.x);
128 self.f64(point.y);
129 self.f64(point.z);
130 self.f64(point.w);
131 }
132
133 fn finish(self) -> [u64; 2] {
134 [mix64(self.state[0]), mix64(self.state[1])]
135 }
136}
137
138fn hash_curve(lane: &mut TwoLane, curve: &NurbsCurve) {
139 lane.usize(curve.degree);
140 lane.floats(&curve.knots);
141 lane.usize(curve.control_points.len());
142 for point in &curve.control_points {
143 lane.control_point(point);
144 }
145}
146
147fn hash_surface(lane: &mut TwoLane, surface: &NurbsSurface) {
148 lane.usize(surface.degree_u);
149 lane.usize(surface.degree_v);
150 lane.floats(&surface.knots_u);
151 lane.floats(&surface.knots_v);
152 lane.usize(surface.control_points.len());
153 for row in &surface.control_points {
154 lane.usize(row.len());
155 for point in row {
156 lane.control_point(point);
157 }
158 }
159}
160
161fn hash_face(lane: &mut TwoLane, face: &FaceRecord) {
162 hash_surface(lane, &face.surface);
163 lane.bool(face.same_sense);
164 lane.usize(face.loops.len());
165 for loop_record in &face.loops {
166 lane.usize(loop_record.coedges.len());
167 for coedge in &loop_record.coedges {
168 lane.bool(coedge.forward);
169 hash_curve(lane, &coedge.pcurve);
170 }
171 }
172}
173
174/// Hash exactly what the integrator reads, in the order it reads it.
175pub fn face_set_identity(solid: &BrepSolid) -> FaceSetIdentity {
176 let mut lane = TwoLane::new();
177 let mut faces = 0u64;
178 lane.usize(solid.shells.len());
179 for shell in &solid.shells {
180 lane.usize(shell.faces.len());
181 for face in &shell.faces {
182 faces += 1;
183 hash_face(&mut lane, face);
184 }
185 }
186 FaceSetIdentity {
187 lanes: lane.finish(),
188 faces,
189 }
190}