@@ -178,6 +178,71 @@ public void SmallestThree_OutOfRangeComponents_ClampToMaxRange()
178178 Assert . True ( Math . Abs ( norm - 1.0 ) < 1e-3 ) ;
179179 }
180180
181+ /// <summary>
182+ /// The clamp above is correct behaviour for an explicitly narrow range, but on the RIG it is a
183+ /// defect: a bone whose MAX_COMPONENT is narrower than the motion it actually sees does not lose
184+ /// precision gracefully, it reconstructs a different rotation, because the decoder rebuilds the
185+ /// dropped component from whatever survived the clamp. UpperChest, the shoulders and the feet
186+ /// each carried a narrowed range and each was 16-30° wrong in the band just above its clamp
187+ /// (v51). This pins the whole High rig against that returning: every slot must reproduce every
188+ /// rotation angle to within its own quantization step.
189+ ///
190+ /// Toes are excluded deliberately and are the one slot still narrow — see the note on the
191+ /// MAX_COMPONENT entry for why widening them costs more than it buys below High.
192+ /// </summary>
193+ [ Fact ]
194+ public void SmallestThree_EveryHighSlot_TracksRotationAcrossItsFullAngleRange ( )
195+ {
196+ byte [ ] bpc = BasisBoneRotationCompression . GetBpcTable ( BitQuality . High ) ;
197+ float [ ] maxComp = BasisBoneRotationCompression . MAX_COMPONENT ;
198+ int [ ] toeSlots = { 19 , 20 } ;
199+
200+ // Fixed seed: the axis set must be dense enough to find a clamp but must not be tuned to it.
201+ var rng = new Random ( 20260809 ) ;
202+
203+ for ( int slot = 0 ; slot < BasisBoneRotationCompression . WireBoneSlotCount ; slot ++ )
204+ {
205+ if ( Array . IndexOf ( toeSlots , slot ) >= 0 ) continue ;
206+
207+ // A component step is 2*range/(2^bpc - 1); the angular error it produces is a small
208+ // multiple of that in radians. 12x leaves ~4x headroom over the measured worst case
209+ // (0.06° at High) while still being ~100x under the 16-30° a clamp produces.
210+ double step = 2.0 * maxComp [ slot ] / ( ( 1 << bpc [ slot ] ) - 1 ) ;
211+ double tolDeg = 12.0 * step * 180.0 / Math . PI ;
212+
213+ for ( int deg = 0 ; deg <= 180 ; deg += 5 )
214+ {
215+ for ( int i = 0 ; i < 400 ; i ++ )
216+ {
217+ // Uniform axis on the sphere — a clamp is a property of the component that ends
218+ // up largest, so the axis has to be swept, not just the angle.
219+ double z0 = 2 * rng . NextDouble ( ) - 1 ;
220+ double r = Math . Sqrt ( Math . Max ( 0 , 1 - z0 * z0 ) ) ;
221+ double phi = 2 * Math . PI * rng . NextDouble ( ) ;
222+ float ax = ( float ) ( r * Math . Cos ( phi ) ) , ay = ( float ) ( r * Math . Sin ( phi ) ) , az = ( float ) z0 ;
223+
224+ double half = deg * Math . PI / 180.0 * 0.5 ;
225+ float s = ( float ) Math . Sin ( half ) ;
226+ float qx = ax * s , qy = ay * s , qz = az * s , qw = ( float ) Math . Cos ( half ) ;
227+
228+ ulong packed = BasisBoneRotationCompression . EncodeSmallestThree ( qx , qy , qz , qw , bpc [ slot ] , maxComp [ slot ] ) ;
229+ BasisBoneRotationCompression . DecodeSmallestThree ( packed , bpc [ slot ] ,
230+ out float dx , out float dy , out float dz , out float dw , maxComp [ slot ] ) ;
231+
232+ double dot = Math . Min ( 1.0 , Math . Abs ( ( double ) qx * dx + ( double ) qy * dy + ( double ) qz * dz + ( double ) qw * dw ) ) ;
233+ double errDeg = 2.0 * Math . Acos ( dot ) * 180.0 / Math . PI ;
234+
235+ Assert . True ( errDeg <= tolDeg ,
236+ $ "slot { slot } (bpc { bpc [ slot ] } , range { maxComp [ slot ] } ) is { errDeg : F2} ° off at " +
237+ $ "{ deg } ° about ({ ax : F3} , { ay : F3} , { az : F3} ); tolerance { tolDeg : F2} °. A narrowed " +
238+ $ "MAX_COMPONENT clamps at " +
239+ $ "{ 2 * Math . Asin ( Math . Min ( 1f , maxComp [ slot ] ) ) * 180 / Math . PI : F1} ° and reconstructs " +
240+ $ "a DIFFERENT rotation above it, not a less precise one.") ;
241+ }
242+ }
243+ }
244+ }
245+
181246 // ────────────────────────────────────────────────────────────
182247 // Bitstream
183248 // ────────────────────────────────────────────────────────────
@@ -247,34 +312,41 @@ public void WriteBits_IsLsbFirst_AndLeavesNeighborsUntouched()
247312 // Bone tables and packet sizing
248313 // ────────────────────────────────────────────────────────────
249314
315+ /// <summary>
316+ /// Replaces ComputeBitOffsets_MatchesRotationBytes_ForAllQualities, which pinned a helper that
317+ /// laid out all 51 bone slots as 2 + 3*bpc — the wire format until v47 moved the thirty finger
318+ /// joints to ten curl/splay channels. The helper had no callers but that test and the assertion
319+ /// was simply false (Medium: 78 bytes claimed against the real 52). Both are gone; this pins the
320+ /// same invariant against BuildRotationFieldOffsets, which is what the channel map actually uses.
321+ /// </summary>
250322 [ Fact ]
251- public void ComputeBitOffsets_MatchesRotationBytes_ForAllQualities ( )
323+ public void RotationFieldOffsets_AreContiguous_AndMatchRotationBytes_ForAllQualities ( )
252324 {
253325 foreach ( var q in AllQualities )
254326 {
255- byte [ ] bpc = BasisBoneRotationCompression . GetBpcTable ( q ) ;
256- var offsets = new int [ bpc . Length ] ;
257- int totalBits = BasisBoneRotationCompression . ComputeBitOffsets ( bpc , offsets ) ;
327+ int [ ] widths = BasisBoneRotationCompression . BuildRotationFieldWidths ( q ) ;
328+ Assert . Equal ( BasisBoneRotationCompression . RotationFieldCount , widths . Length ) ;
329+
330+ var offsets = new int [ BasisBoneRotationCompression . RotationFieldCount ] ;
331+ int totalBits = BasisBoneRotationCompression . BuildRotationFieldOffsets ( q , offsets ) ;
258332
259- Assert . Equal ( 0 , offsets [ 0 ] ) ;
333+ // Offsets must tile the region exactly: no gaps, no overlap.
260334 int expected = 0 ;
261- for ( int i = 0 ; i < bpc . Length ; i ++ )
335+ for ( int i = 0 ; i < widths . Length ; i ++ )
262336 {
263337 Assert . Equal ( expected , offsets [ i ] ) ;
264- expected += 2 + 3 * bpc [ i ] ;
338+ expected += widths [ i ] ;
265339 }
266340 Assert . Equal ( expected , totalBits ) ;
267-
268- // RotationBytes is NOT this total. ComputeBitOffsets walks all 51 legacy bone slots;
269- // since v47 the wire carries 21 of them plus a 10-channel finger block, so the wire size
270- // comes from the rotation FIELD widths instead. (Sizing anything off the 51-slot total
271- // overruns every sub-High payload — it is larger than the payload itself.)
272- var fieldWidths = BasisBoneRotationCompression . BuildRotationFieldWidths ( q ) ;
273- int wireBits = 0 ;
274- foreach ( int w in fieldWidths ) wireBits += w ;
275- Assert . Equal ( wireBits , BasisBoneRotationCompression . RotationBits ( q ) ) ;
276- Assert . Equal ( ( wireBits + 7 ) >> 3 , BasisBoneRotationCompression . RotationBytes ( q ) ) ;
277- Assert . True ( totalBits > wireBits , "legacy 51-slot total should exceed the v47 wire size" ) ;
341+ Assert . Equal ( totalBits , BasisBoneRotationCompression . RotationBits ( q ) ) ;
342+ Assert . Equal ( ( totalBits + 7 ) >> 3 , BasisBoneRotationCompression . RotationBytes ( q ) ) ;
343+
344+ // The explicit bone slots come first, then one field per finger channel.
345+ for ( int slot = 0 ; slot < BasisBoneRotationCompression . WireBoneSlotCount ; slot ++ )
346+ Assert . Equal ( 2 + 3 * BasisBoneRotationCompression . GetBpcTable ( q ) [ slot ] , widths [ slot ] ) ;
347+ for ( int f = 0 ; f < BasisBoneRotationCompression . FingerChannelCount ; f ++ )
348+ Assert . Equal ( BasisBoneRotationCompression . FingerFieldWidth ( q ) ,
349+ widths [ BasisBoneRotationCompression . WireBoneSlotCount + f ] ) ;
278350 }
279351 }
280352
0 commit comments