Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: deterministic solidmesh hashing + bit-exact geometry serde

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 5, 2026, 3:08 PM +0300) commit 4c963664 parent d4557ced change-id rzymnyzs
+177 -6
+119 -3
crates/bone-kernel/src/brep/tessellate.rs
··· 1 1 use std::collections::HashMap; 2 2 use std::hash::{Hash, Hasher}; 3 3 4 + use serde::{Deserialize, Serialize}; 5 + 4 6 use bone_types::{ 5 7 AngleTolerance, BrepFaceId, ChordHeightTolerance, FaceLabel, MeshGeneration, Point3, Tolerance, 6 8 UnitVec3, ··· 15 17 16 18 const UNIT_NORMAL_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 17 19 18 - #[derive(Clone, Debug, PartialEq)] 20 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 21 + #[serde(deny_unknown_fields)] 19 22 pub struct FaceMesh { 20 23 face: BrepFaceId, 21 24 label: FaceLabel, ··· 51 54 } 52 55 } 53 56 54 - #[derive(Clone, Debug, PartialEq)] 57 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 58 + #[serde(deny_unknown_fields)] 55 59 pub struct SolidMesh { 56 60 faces: Vec<FaceMesh>, 57 61 generation: MeshGeneration, ··· 210 214 Ok(index) 211 215 } 212 216 217 + struct StableHasher(blake3::Hasher); 218 + 219 + impl Hasher for StableHasher { 220 + fn write(&mut self, bytes: &[u8]) { 221 + self.0.update(bytes); 222 + } 223 + 224 + fn write_u8(&mut self, i: u8) { 225 + self.0.update(&i.to_le_bytes()); 226 + } 227 + 228 + fn write_u16(&mut self, i: u16) { 229 + self.0.update(&i.to_le_bytes()); 230 + } 231 + 232 + fn write_u32(&mut self, i: u32) { 233 + self.0.update(&i.to_le_bytes()); 234 + } 235 + 236 + fn write_u64(&mut self, i: u64) { 237 + self.0.update(&i.to_le_bytes()); 238 + } 239 + 240 + fn write_u128(&mut self, i: u128) { 241 + self.0.update(&i.to_le_bytes()); 242 + } 243 + 244 + fn write_usize(&mut self, i: usize) { 245 + let Ok(widened) = u64::try_from(i) else { 246 + unreachable!("usize exceeds u64 only above 64-bit platforms"); 247 + }; 248 + self.0.update(&widened.to_le_bytes()); 249 + } 250 + 251 + fn write_i8(&mut self, i: i8) { 252 + self.0.update(&i.to_le_bytes()); 253 + } 254 + 255 + fn write_i16(&mut self, i: i16) { 256 + self.0.update(&i.to_le_bytes()); 257 + } 258 + 259 + fn write_i32(&mut self, i: i32) { 260 + self.0.update(&i.to_le_bytes()); 261 + } 262 + 263 + fn write_i64(&mut self, i: i64) { 264 + self.0.update(&i.to_le_bytes()); 265 + } 266 + 267 + fn write_i128(&mut self, i: i128) { 268 + self.0.update(&i.to_le_bytes()); 269 + } 270 + 271 + fn write_isize(&mut self, i: isize) { 272 + let Ok(widened) = i64::try_from(i) else { 273 + unreachable!("isize exceeds i64 only above 64-bit platforms"); 274 + }; 275 + self.0.update(&widened.to_le_bytes()); 276 + } 277 + 278 + fn finish(&self) -> u64 { 279 + let digest = self.0.finalize(); 280 + let Ok(head) = <[u8; 8]>::try_from(&digest.as_bytes()[..8]) else { 281 + unreachable!("blake3 digest is 32 bytes"); 282 + }; 283 + u64::from_le_bytes(head) 284 + } 285 + } 286 + 213 287 fn derive_generation( 214 288 faces: &[FaceMesh], 215 289 chord: ChordHeightTolerance, 216 290 angle: AngleTolerance, 217 291 ) -> MeshGeneration { 218 - let mut hasher = std::collections::hash_map::DefaultHasher::new(); 292 + let mut hasher = StableHasher(blake3::Hasher::new()); 219 293 chord.millimeters().to_bits().hash(&mut hasher); 220 294 angle.radians().to_bits().hash(&mut hasher); 221 295 faces.iter().for_each(|slab| { ··· 324 398 fn edge_key(a: u32, b: u32) -> (u32, u32) { 325 399 if a < b { (a, b) } else { (b, a) } 326 400 } 401 + 402 + #[cfg(test)] 403 + mod tests { 404 + use super::StableHasher; 405 + use std::hash::Hasher; 406 + 407 + fn digest_via(emit: impl FnOnce(&mut StableHasher)) -> u64 { 408 + let mut hasher = StableHasher(blake3::Hasher::new()); 409 + emit(&mut hasher); 410 + hasher.finish() 411 + } 412 + 413 + #[test] 414 + fn integer_writes_are_little_endian_not_native() { 415 + let value = 0x0102_0304_0506_0708_u64; 416 + let method = digest_via(|h| h.write_u64(value)); 417 + assert_eq!( 418 + method, 419 + digest_via(|h| h.write(&value.to_le_bytes())), 420 + "write_u64 must emit little-endian bytes on every host" 421 + ); 422 + assert_ne!( 423 + method, 424 + digest_via(|h| h.write(&value.to_be_bytes())), 425 + "a big-endian framing must not collide with the canonical one" 426 + ); 427 + } 428 + 429 + #[test] 430 + fn pointer_sized_writes_are_normalized_to_64_bit() { 431 + assert_eq!( 432 + digest_via(|h| h.write_usize(0x0102_0304)), 433 + digest_via(|h| h.write_u64(0x0102_0304_u64)), 434 + "usize must hash as a 64-bit value so 32-bit and 64-bit hosts agree" 435 + ); 436 + assert_eq!( 437 + digest_via(|h| h.write_isize(-0x0102_0304)), 438 + digest_via(|h| h.write_i64(-0x0102_0304_i64)), 439 + "isize must hash as a 64-bit value so 32-bit and 64-bit hosts agree" 440 + ); 441 + } 442 + }
+45 -1
crates/bone-types/src/lib.rs
··· 43 43 NonOrthogonalPlaneAxes(f64), 44 44 #[error("axis vector is zero-length")] 45 45 ZeroLengthAxis, 46 + #[error("axis vector is not unit length: norm {0}")] 47 + NonUnitAxis(f64), 46 48 #[error("oriented box half-extent must be finite and non-negative: {0}")] 47 49 InvalidHalfExtent(f64), 48 50 #[error("camera eye and target coincide: separation {0} mm")] ··· 259 261 } 260 262 } 261 263 262 - #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] 264 + #[derive( 265 + Copy, 266 + Clone, 267 + Debug, 268 + PartialEq, 269 + Eq, 270 + PartialOrd, 271 + Ord, 272 + Hash, 273 + Default, 274 + serde::Serialize, 275 + serde::Deserialize, 276 + )] 263 277 pub struct MeshGeneration(u64); 264 278 265 279 impl MeshGeneration { ··· 571 585 assert!(UnitVec3::try_from_components(0.0, 0.0, 0.0, tol).is_err()); 572 586 assert!(UnitVec3::try_from_components(1e-13, 0.0, 0.0, tol).is_err()); 573 587 assert!(UnitVec3::try_from_components(1.0, 0.0, 0.0, tol).is_ok()); 588 + } 589 + 590 + #[test] 591 + fn unit_vec3_deserialize_preserves_irrational_components_bit_exact() { 592 + let Ok(original) = UnitVec3::try_from_components(1.0, 2.0, 3.0, Tolerance::new(1e-9)) else { 593 + panic!("normalizable direction"); 594 + }; 595 + let Ok(text) = ron::to_string(&original) else { 596 + panic!("serialize"); 597 + }; 598 + let Ok(back) = ron::from_str::<UnitVec3>(&text) else { 599 + panic!("deserialize"); 600 + }; 601 + let (ox, oy, oz) = original.components(); 602 + let (bx, by, bz) = back.components(); 603 + assert_eq!(ox.to_bits(), bx.to_bits()); 604 + assert_eq!(oy.to_bits(), by.to_bits()); 605 + assert_eq!(oz.to_bits(), bz.to_bits()); 606 + } 607 + 608 + #[test] 609 + fn unit_vec3_deserialize_rejects_non_unit_components() { 610 + let probe = UnitVec3::new_unchecked(0.6, 0.8, 0.0); 611 + let Ok(text) = ron::to_string(&probe) else { 612 + panic!("serialize"); 613 + }; 614 + assert!(ron::from_str::<UnitVec3>(&text).is_ok()); 615 + let non_unit = text.replacen("0.6", "0.9", 1); 616 + assert_ne!(text, non_unit, "probe value present for mutation"); 617 + assert!(ron::from_str::<UnitVec3>(&non_unit).is_err()); 574 618 } 575 619 576 620 #[test]
+13 -2
crates/bone-types/src/space.rs
··· 244 244 } 245 245 } 246 246 247 + const WIRE_UNIT_TOLERANCE: f64 = 1.0e-9; 248 + 249 + fn wire_unit_norm(norm: f64) -> Result<()> { 250 + if !norm.is_finite() || (norm - 1.0).abs() > WIRE_UNIT_TOLERANCE { 251 + return Err(TypesError::NonUnitAxis(norm)); 252 + } 253 + Ok(()) 254 + } 255 + 247 256 #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 248 257 #[serde(try_from = "UnitVec2Wire", into = "UnitVec2Wire")] 249 258 pub struct UnitVec2(Unit<NVec2<f64>>); ··· 264 273 impl TryFrom<UnitVec2Wire> for UnitVec2 { 265 274 type Error = TypesError; 266 275 fn try_from(w: UnitVec2Wire) -> Result<Self> { 267 - Self::try_from_components(w.x, w.y, Tolerance::new(f64::EPSILON)) 276 + wire_unit_norm((w.x * w.x + w.y * w.y).sqrt())?; 277 + Ok(Self(Unit::new_unchecked(NVec2::new(w.x, w.y)))) 268 278 } 269 279 } 270 280 ··· 454 464 impl TryFrom<UnitVec3Wire> for UnitVec3 { 455 465 type Error = TypesError; 456 466 fn try_from(w: UnitVec3Wire) -> Result<Self> { 457 - Self::try_from_components(w.x, w.y, w.z, Tolerance::new(f64::EPSILON)) 467 + wire_unit_norm((w.x * w.x + w.y * w.y + w.z * w.z).sqrt())?; 468 + Ok(Self(Unit::new_unchecked(NVec3::new(w.x, w.y, w.z)))) 458 469 } 459 470 } 460 471