Repository navigation
S3: Frame3<T> + KinematicChain<T>; Pose3 is a wire format (Drawing 2.0.0.72, epic-129) - #5
Conversation
- Frame3<T> (position + unit quaternion): Multiply/*, Inverse, TransformPoint, TransformVector, Interpolate (slerp), Distance (position, angle), FromSurface (3 overloads, moved from Pose3), FromPose3/ToPose3 (the one Euler rule). - Quaternion<T>.FromRpy (Rz*Ry*Rx), FromTo, FromAxes; Rotation3.ToQuaternion now delegates to FromRpy. - ModelingEvolution.Drawing.Kinematics: JointType, Link, Joint<T>, ChainFrame<T>, KinematicChain<T> (Forward, FramePose, Jacobian, FrameIndex, Dof); spans in, spans out, no allocation. - Pose3<T>: TransformPoint, TransformVector, Inverse, Multiply, operator * / + / -, Lerp, Distance, FromSurface deleted (no forwarders; breaking, ADR-11 major). - Drawing's callers (PosePath3, Trajectory3, Trajectory3Builder, Triangle3, Polyline3) moved to Frame3 / Point3. - Tests TS-129-01..05 and TS-129-55; grep-gate CI job with rows G-17, G-18. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
rafalmaciag
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED: one change, decision A. Everything else checks out.
Requested (A). Transform(Pose3<T>) takes the wire type and composes pose · element, which is pose maths. The reason given, "rw2 changes fewer files", is a compatibility argument, and design.md rule 2 ("ALL maths composes Frame3; Pose3 is wire and edit only") and the "no compatibility" rule forbid it. The hidden lengthScale = 1 also makes a unit choice inside the library. Change these:
Sources/ModelingEvolution.Drawing/Polyline3.cs:328,PosePath3.cs:156,Trajectory3.cs:211: change the signature toTransform(in Frame3<T> frame)and delete the innerFromPose3(pose, T.One). The elements ofPosePath3andTrajectory3are stillPose3, so their per-elementFromPose3(·,1)/ToPose3(1)stays. Callers convert at their edge withFrame3.FromPose3(pose, 1).Triangle3.cs:87:ToPose()builds geometry, so it belongs withFromSurface, which design.md moved toFrame3. Replace it withFrame3<T> ToFrame()(new Frame3<T>(Centroid, Frame3<T>.FromSurface(_a,_b,_c).Rotation)) and update its test.
Verdicts on the other decisions:
- B. Confirmed. UR
base_link→base,wrist_3→flangeandflange→tool0are frames (ChainFrameon the moving link, with the fixed offsets composed intoInLink). A fixed body that carries a mesh, such asbase_link_inertia, folds into its parent: the next joint'sOriginbecomes the fixed offset ·Origin. This is lossless. The S7 importer must emit aChainFrameper folded body so TS-129-06 can still compare it. For a rail with an arm (1P+6R), the arm-mount offset goes into the first arm joint'sOrigin.ForwardandJacobianstay a single pass or walk (RailArm6is tested). Architecture page 04 (Link(Frame3 Origin, Joint? Joint)) must follow design.md. - C. Fine.
T?needswhere T : structto becomeNullable<T>. Without it,nullcannot be stored fordouble.Frame3andChainFramehave noT?, so keeping thePose3constraint is right. - D. Fine.
2·atan2(|v|,|w|)equals2·acos(|qa·qb|)for unit quaternions and is well conditioned near 0.Rotation3.ToQuaternion → FromRpyremoves a duplicate. ThePose3.From(...)factories andGetEnumerator/IsIdentityare wire/edit constructors and accessors, not maths. They are acceptable, but they go beyond the kept list in TS-129-55, so that list in the docs needs amending (lead).
Standard checks:
- The API matches design.md.
Multiplyisthis·child.Forwardcomputeslinks[c]=links[parent]·Origin·J(q)in one pass. The Jacobian is 6×Dof column-major (v, ω): revolutea×(p−p_joint), prismatic(a,0).FromRpy= Rz·Ry·Rx. Two additions beyond the sketch are fine:InterpolateandDistance, which the fate table calls for. - I ran the suite in Release twice: 1692/0/0 both times. Every random test is seeded with
new Random(129). Sample counts and tolerances match TS-129-01/02/04/05. TS-129-03 errors name the culprit. The reflection test for TS-129-55 is exact. - No
[Obsolete]and no forwarding members.Forward,FramePoseandJacobianuse spans andItemRefwith no LINQ, closures or arrays. The 0 B test warms up first and measures the right calls. grep-gate.sh: G-17 finds 0 lines and G-18 finds 0 (40 on master). It exits non-zero on any hit, a new check is one row inROWS, and.github/scripts/**is in the CIpathsfilter.- Release build: 0 warnings.
TreatWarningsAsErrorsis not set anywhere in this repo, before or after this PR. The tree is clean after the tests, apart from gitignoredskeleton-svg/andskeleton-performance-results.md, which existing Skeleton tests already wrote on master. The labelrelease:majoris present.
Follow-up (not blocking, lead's call). Rotation3 still has a full Euler rotation algebra: Combine/operator+, Inverse/operator-, Slerp, Rotate. Matrix3x3.RotationZYX/ToEulerZYX is a second ZYX path, and PosePath3Builder.Delta adds Euler angles component by component. design.md's fate table covers only Pose3. Because 2.0 is the breaking window (ADR-11), decide now whether rule 2 extends to Rotation3.
🤖 Generated with Claude Code
Polyline3/PosePath3/Trajectory3.Transform(Pose3) become Transform(in Frame3): the pose maths composes Frame3 and the hidden lengthScale=1 conversion of the argument is gone; PosePath3/Trajectory3 keep the per-element FromPose3/ToPose3 because their elements are wire Pose3. Triangle3.ToPose() becomes ToFrame(), built from Frame3.FromSurface's rotation at the centroid. One test per Transform added; Triangle3 tests assert the frame. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
…uaternion (epic-129) Rotation3 is wire/edit only: Combine, operator +, Inverse, operator -, Slerp, Rotate(Vector3), Rotate(Point3), ToMatrix3x3, Negate and the explicit cast to Vector3 are deleted (Rotation3SurfaceTests pins the kept list). PosePath3Builder.Delta (Euler components added) is replaced by Step(in Frame3), composing previous · step. Polyline3.Transform(Rotation3) is deleted. Trajectory3Builder's corner blend slerps quaternions from FromPose3(·,1). One Shepperd and one Euler formula each way: Quaternion.FromMatrix3x3 / ToMatrix3x3 added; FromAxes uses FromMatrix3x3; Matrix3x3.ToQuaternion deleted; RotationZYX = FromRpy(...).ToMatrix3x3(); ToEulerZYX and Rotation3.FromMatrix3x3 = Rotation3.FromQuaternion(Quaternion.FromMatrix3x3(m)); Vector3.RotationTo's private Euler extraction goes through FromQuaternion. Tests pin RotationZYX/ToEulerZYX to master's closed form over 10 000 random rotations and the matrix -> quaternion -> matrix round trip to 1e-12. Grep gate row G-19d added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
- Vector3<T>.RotationTo, the explicit Vector3 → Rotation3 cast (a second direction → Euler formula) and operator +(Vector3, Rotation3) → Pose3 are deleted; the rotation between two directions is Quaternion.FromTo. - Matrix3x3.SimilarityFromVectors = |t|/|s| · Quaternion.FromTo(s, t).ToMatrix3x3(). - EulerSurfaceTests: Vector3 and Matrix3x3 take or return no Rotation3/Pose3. - grep gate G-19d also rejects \bRotationTo\s*\( in Sources/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
rafalmaciag
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED (re-review at 30642b8). Item A is fixed, and the Q7 deletion is clean. One correctness regression remains, plus one hygiene item.
1. Blocking: the one Euler extraction is wrong by up to 0.08° near ry = ±90°.
Rotation3.FromQuaternion switches to its gimbal branch when |sinp| ≥ 1−1e-6, which is |ry| ≳ 89.92°. That branch then forces ry = ±90° and rz = 0. This is not "the same matrix, different rx/rz split": the rotation itself changes. At ry = 89.92° the error is 0.080°, at 89.95° it is 0.05°, and at 89.99° it is 0.01°. I checked this by reproducing the formulas.
The defect was already in FromQuaternion on master. This PR routes Matrix3x3.ToEulerZYX and Rotation3.FromMatrix3x3 through it as well, and master's ToEulerZYX was exact there. So the defect now reaches more callers:
ToPose3andPosePath3Builder.Step, which round-trips through Euler on every step;- rw2
Vision/PoseConversions.cs:48andNbvCalibrationService.cs:229; - simulator
Metrology/Core/Se3.cs:41andHandEyeGroundTruth.cs:56.
The tests miss it because TS-129-02 samples ry within ±89.9° and ToEulerZYX_EqualsMasterAngles_OutsideGimbalBand skips the band.
Fix in Sources/ModelingEvolution.Drawing/Rotation3.cs FromQuaternion:
var r00 = T.One - Two * (q.Y * q.Y + q.Z * q.Z);
var r10 = Two * (q.W * q.Z + q.X * q.Y);
var cosp = T.Sqrt(r00 * r00 + r10 * r10);
ry = T.Atan2(sinp, cosp);
if (cosp < T.CreateTruncating(1e-9)) { // true gimbal only
rz = T.Zero;
rx = T.Atan2(-Two * (q.Y * q.Z - q.W * q.X), T.One - Two * (q.X * q.X + q.Z * q.Z)); // R = Ry·Rx, exact for any ry
} else {
rx = T.Atan2(sinrCosp, cosrCosp);
rz = T.Atan2(r10, r00);
}I modelled this in Python on 200 000 random rotations with |ry| ∈ [89.9°, 90°]: the worst rotation error is 3e-6°, which is the limit of the measurement. Add a test of FromPose3→ToPose3 and RotationZYX→ToEulerZYX→RotationZYX over ry ∈ [89.9°, 90°]: every matrix element must agree within 1e-8. With that fix, 3(a) has no behaviour left to document.
2. Hygiene: line endings. 11 files went from CRLF to LF: README.md ×2, Matrix3x3.cs, Polyline3.cs, PosePath3.cs, Trajectory3.cs, Trajectory3Builder.cs and four test files. That inflates the round diff from about 540 to 5 100 lines and breaks blame for those files. Restore the original endings, so that git diff --stat matches --ignore-cr-at-eol.
Verified:
- Survivors of A. The wire members (
FromPose3/ToPose3, JSON, parse, equality) are correct. The containers (PosePath3ctors and indexers,Waypoint3,PoseEventArgs,Trajectory3.AtTime, buildersStartAt/Add/MoveTo) store or emit edit data and do their maths throughFrame3.AdaptToEndpoints(start, end)is acceptable: it uses the positions only and copies the taught rotations unchanged. No public member composes aPose3. - Q7.
- One
FromRpyand one extraction remain. FromMatrix3x3covers all four Shepperd branches, and its matrix→quaternion→matrix round trip passes within 1e-12.RotationZYXkeeps master's numbers.- The
Trajectory3Buildercorner blend is the same maths: quaternion slerp, thenFromQuaternion. - The
TransformVectorrewrites are the same maths, and the 0 B allocation test passes. EulerSurfaceTestsis sound.
- One
- 3(b), antiparallel
FromTo. Acceptable. It is a deterministic half-turn, but the axis differs from master'sRotationTo, soSimilarityFromVectorsandAdaptToEndpointsgive different (equally valid) results for reversed endpoints. State the axis rule in theFromToXML doc. - Grep gate. G-17, G-18 and G-19d each find 0 lines. A planted, tracked hit makes the script exit 1.
- Ledger. Release, run twice: 1698/0/0 both times. Every removed test covered a deleted member. The
Deltazero-capacity and collection-literal tests were merged intoStep_Chain_FromZeroCapacityAndLiteralStart_Growswith the same expected numbers. The tree is clean after the tests. - Minor, lead's call.
Point3.operator +(Point3, Rotation3) → Pose3survives although itsVector3twin was deleted.Stepcomposes in the previous pose's frame, whereDeltaadded a world-frame offset; neither rw2 nor the simulator callsDelta.
🤖 Generated with Claude Code
Content unchanged (git diff --ignore-cr-at-eol is empty); undoes the CRLF → LF conversion so blame and the PR diff show only real changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
…pic-129) - Rotation3.FromQuaternion: ry = atan2(sin, cos) with cos = sqrt(r00² + r10²); gimbal branch only when cos ry < 1e-9 (rz = 0, rx = atan2(-r12, r11), exact for any ry). The old branch (|sin ry| >= 1 - 1e-6) forced ry = ±90° and changed the rotation by up to 0.08°. Reaches ToEulerZYX, FromMatrix3x3, Frame3.ToPose3 and PosePath3Builder.Step. - Tests over |ry| in [89.9°, 90°] (10 000 seeded) and at exactly ±90°: matrices within 1e-8 (1e-12 at ±90°); ToEulerZYX compared with master's angles over the full ry range where master was exact. - Point3 operator +(Point3, Rotation3) → Pose3 deleted (use Pose3.From); EulerSurfaceTests also checks Point3. - Quaternion.FromTo doc states the half-turn axis for opposite directions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8
rafalmaciag
left a comment
There was a problem hiding this comment.
APPROVE (final check at bd027b8).
- Euler extraction fixed.
Rotation3.FromQuaternionnow computesry = atan2(sinp, sqrt(r00²+r10²)). It takes the gimbal branch only whencosp < 1e-9, and there setsrx = atan2(−r12, r11),rz = 0. This is the formula I modelled last round: worst error 3e-6° over 200 000 rotations with |ry| ∈ [89.9°, 90°].- The new tests pass:
FromPose3_ToPose3_KeepsTheRotationNearGimbal_10000Random,RotationZYX_ToEulerZYX_RoundTripsNearGimbal_10000Random,ToEulerZYX_EqualsMasterNearGimbal_10000Randomand the exact ±90° cases.
- The new tests pass:
- Line endings restored.
git diff origin/master...bd027b8 --statand--ignore-cr-at-eolagree: 46 files, +2797/−1745. No file's line endings differ from master. - Other items fixed:
operator +(Point3, Rotation3)is gone, andEulerSurfaceTestsnow coversPoint3.- The
FromToXML doc states the antiparallel axis rule (+X→−X about −Z, +Z→−Z about −Y), and it matches the code.
- Suite and gate. Release run: 1707/0/0. Grep gate G-17, G-18 and G-19d: 0 lines each. Tree clean after the tests.
🤖 Generated with Claude Code
Epic-129 (Matrix), slice S3 "one pose algebra" — the Drawing half. Design:
docs/epics/epic-129-matrix/design.md§ "Drawing: the kinematic chain (feature 001)"; ADR-11 (major version, no forwarding members). Merging withrelease:majorproduces 2.0.0.72; the lead publishes2.0.0.72-preview.1from this branch head.API as built
Constructor validation (
ArgumentException, message names the link/joint/frame): link 0 is the only root (Parent == -1); every other link has0 ≤ Parent < index; unique link names; every non-root link is moved by exactly one joint (Childin[1, Links.Length)); unit axis (±1e-6);Min ≤ Max; frames on existing links with unique names.Forward/FramePose/Jacobianvalidate span lengths and do not allocate (asserted byForwardTests.ForwardAndJacobian_DoNotAllocate, 0 bytes over 100 iterations of the 1P+6R chain).Pose3<T>members deleted / moved (no forwarders, no[Obsolete])TransformPoint,TransformVectorFrame3.TransformPoint/TransformVectorInverse()Frame3.Inverse()Multiply(Pose3),operator *(Pose3, Pose3)Frame3.Multiply/*operator +(Pose3, Rotation3)Frame3operator +/-(Pose3, Vector3)pose with { Position = pose.Position + v }or aFrame3Lerp(a, b, t)Frame3.InterpolateDistance(a, b)Frame3.Distance(orPoint3.Distancefor position only)FromSurface(3 overloads) + private helpersFrame3.FromSurfaceKept (asserted member-by-member by
Pose3SurfaceTests): the 3 constructors,Identity,From(Point3, Rotation3),From(Vector3, Rotation3), property getters (+ init setters ofPosition/Rotation),GetEnumerator/Enumerator(collection literals),Parse,TryParse,ToString, both tuple conversions, bothDeconstruct, equality (Equals×2,GetHashCode,==,!=),Truncating<U>().Drawing's own callers moved
PosePath3.LengthandTrajectory3Builder.MoveTo(×3):Point3.Distanceof the positions (they used the position-onlyPose3.Distance).PosePath3.Transform,Trajectory3.Transform: now takein Frame3<T>;frame * Frame3.FromPose3(p, 1)→ToPose3(1)per stored element (wire conversion of the elements only).PosePath3/Trajectory3operator +/-(…, Vector3):with { Position = … }.Trajectory3.AtTime:Frame3.InterpolatethroughFromPose3/ToPose3(scale 1).Triangle3.ToPose()→Frame3<T> ToFrame():Frame3.FromSurface(a, b, c).Rotationat the centroid (one construction).Polyline3.Transform: now takesin Frame3<T>;frame.TransformPointper point.Frame3<T>,KinematicChain<T>and the new quaternion factories documented; thePose3row says wire and edit format only.Test ledger —
ModelingEvolution.Drawing.Tests(native, net10.0, Release)Review A (5217f0e): +3 tests (
Polyline3Tests/PosePath3Tests/Trajectory3Tests.Transform_Frame_*: 90° about Z + (10,0,0) applied to two elements, rotation and time checked); the 2Triangle3Tests.Triangle3_ToPose_*tests renamed toTriangle3_ToFrame_*and now assert the frame (position at the centroid, rotation equal toFrame3.FromSurface, Z axis = normal, X along A→B). No test folded. Run twice in Release, 1695/0/0 both times.Q7 (5e2f1a3), 1695 → 1706 (run twice in Release, 1706/0/0 both times):
PosePath3BuilderTests.Delta_BeforeStartAt_Throws,Delta_AppendsTranslationToPreviousPosition,Delta_AppendsRotationToPreviousRotation,Chain_OfMultipleDeltas_AccumulatesCorrectly,Construct_WithZeroInitialCapacity_DoesNotInfiniteLoop,CollectionLiteralSyntax_CompilesAndProducesCorrectResult;Rotation3Tests.Rotation3_Negate_NegatesAngles.PosePath3BuilderTests.Step_BeforeStartAt_Throws,Step_ComposesInThePreviousPoseFrame,Step_Chain_FromZeroCapacityAndLiteralStart_Grows(keeps the zero-capacity and literal guards);Rotation3SurfaceTests(9 deleted-name cases + exact public member list);Matrix3x3Tests.QuaternionMatrix_RoundTrip_MatrixQuaternionMatrix_10000Random(1e-12),QuaternionMatrix_FromMatrix3x3_AllShepperdBranches,QuaternionMatrix_ToMatrix3x3_ColumnsAreRotatedAxes,RotationZYX_EqualsMasterClosedForm_10000Random(master's closed form copied into the test, 1e-12),ToEulerZYX_EqualsMasterAngles_OutsideGimbalBand_10000Random(master's atan2/asin extraction, 1e-9°).Quaternion/Frame3, same expected numbers:Rotation3Tests—Rotation3_ToQuaternion_Rotate_{XAxis90_RotatesYToZ, YAxis90_RotatesZToX, ZAxis90_RotatesXToY},Rotation3_ToQuaternion_FrameRotatesPoint,Rotation3_ToQuaternion_InverseReversesRotation,Rotation3_ToQuaternion_ProductOfTwoRotations,Rotation3_QuaternionSlerp_{AtZero_ReturnsFirst, AtOne_ReturnsSecond, AtHalf_ReturnsMidpoint},Rotation3_QuaternionConjugate_IsInverse(wasUnaryMinus_ReturnsInverse),Rotation3_QuaternionInverse_ComposesToIdentity, the 4Rotation3_ToVector3_*(cast →ToQuaternion().Rotate(EZ));TorchRotationTests(all 4;Rotate→ToQuaternion().Rotate,Slerp→ a test-local quaternion slerp);Vector3Tests5RotationTo_*+ 5(Vector3)rotationcases;Matrix3x3Tests.ToQuaternion_FromQuaternion_RoundTrip×4,ToQuaternion_Identity_IsUnitQuaternion,Rotation3_QuaternionMatrix_RoundTrip×3,Rotation3_QuaternionRotate_MatchesMatrix_Transform. The existingRotationZYX_*/ToEulerZYX_*expectations are unchanged and pass.Frame3SurfaceTests/Frame3TestsandKinematicChain.Jacobian:frame.Rotation.Rotate(v)→frame.TransformVector(v)(same maths), so G-19d's\.Rotation\.Rotate\(can be 0 lines; the zero-allocation test still passes.Round 3 (30642b8), 1706 → 1698 (run twice in Release, 1698/0/0 both times; 371 build warnings before and after):
Vector3Tests.Vector3_RotationTo_{EZtoEX_Rotates90DegreesAroundY, EZtoEY_Rotates90DegreesAroundX, ParallelVectors_ReturnsIdentity, AntiParallelVectors_Rotates180Degrees, ArbitraryVectors_AlignsCorrectly, NonUnitVectors_NormalizesFirst}(each case is covered byQuaternionFactoryTests.FromTo_*: random 1000 pairs incl. non-unit, shortest arc, same, opposite ×4);Vector3Tests.Vector3_ToRotation3_{ZAxisDirection_ReturnsZeroRotation, XAxisDirection, YAxisDirection, ArbitraryDirection_RoundTrips, NonUnitVector_NormalizesFirst, ZeroVector_ReturnsIdentity};Vector3Tests.Vector3_AddRotation_ReturnsPose3;Matrix3x3SimilarityTests.AntiParallel_HandledByRotationTo(replaced below).EulerSurfaceTests.Vector3_HasNoMemberTakingOrReturningRotation3OrPose3,Matrix3x3_HasNoMemberTakingOrReturningRotation3OrPose3(methods, operators, ctors, properties, fields; public and non-public);Matrix3x3SimilarityTests.AntiParallel_IsAHalfTurnMappingSourceToTarget×3 (+X, +Z, (0.3, −0.4, 0.5); target = −2·source; asserts M·source = target and trace(M/2) = −1, det = 1, within 1e-12 — not a specific axis;FromTopicks normalize(EX × source), or normalize(EY × source) when source ∥ EX, so +X → −X turns about −Z);Matrix3x3SimilarityTests.ZeroTarget_Throws(was already the behaviour; the doc said "zero matrix", now corrected).Rotation3Tests.Rotation3_ToVector3_RoundTrips_WithVector3→Rotation3_FromTo_RoundTrips_WithVector3(Rotation3.FromQuaternion(Quaternion.FromTo(EZ, dir)), thenToQuaternion().Rotate(EZ)= dir within 1e-6). The other 7Matrix3x3SimilarityTestsare unchanged and pass on theFromTopath.Round 4 (3f3b720 + bd027b8), 1698 → 1707 (run twice in Release, 1707/0/0 both times; build warnings 371 → 370, the deleted operator had no XML doc):
Point3Tests.Point3_AddRotation_ReturnsPose3(its only subject,operator +(Point3, Rotation3), is deleted).Matrix3x3Tests.ToEulerZYX_EqualsMasterAngles_OutsideGimbalBand_10000Random→ToEulerZYX_EqualsMasterAngles_10000Random: ry over the full [−90°, 90°]; master's angles (1e-9°) wherever master was exact (|cos ry| > 1e-6), the same matrix (1e-8) inside master's band.Matrix3x3Tests.ToEulerZYX_EqualsMasterNearGimbal_10000Random(|ry| ∈ [89.9°, 90°]; master's angles within 1e-6° where master was exact, otherwise the matrix),RotationZYX_ToEulerZYX_RoundTripsNearGimbal_10000Random(every element within 1e-8),RotationZYX_ToEulerZYX_AtExactly90_KeepsTheMatrixWithRzZero×2 (1e-12, rz = 0);Frame3EulerTests.FromPose3_ToPose3_KeepsTheRotationNearGimbal_10000Random(positions 1e-9 mm, every rotation element 1e-8; observed worst cases are about 4e-11),FromPose3_ToPose3_AtExactly90_KeepsTheRotationWithRzZero×4 (ry = ±90°, rz = 0 or not, 1e-12);EulerSurfaceTests.Point3_HasNoMemberTakingOrReturningRotation3OrPose3.Removed from
Pose3Tests(45 → 12): 7 maths tests (TransformPoint ×2, TransformVector, Lerp ×3, Distance), replaced byFrame3Tests; 26FromSurfacetests moved unchanged in substance toFrame3SurfaceTests.New / moved, by class:
Frame3Tests(13) — TS-129-01: identity, associativity, TransformPoint over 10 000 random frames (1e-12 m, 1e-9 rad); Interpolate t=0/1/0.5; Distance; plus the concrete transform cases fromPose3Tests.Frame3EulerTests(4) — TS-129-02:FromPose3(p, 0.001).ToPose3(1000)round trip over 10 000 poses (ry ±89.9°) within 1e-9 mm / 1e-9°;FromRpy== Rz·Ry·Rx within 1e-12 per element over 10 000 angle triples;FromRpyis the ruleRotation3uses.Frame3SurfaceTests(29) — the 26 movedFromSurfacetests + 3 TS-129-01 cases: each overload gives, within 1e-12, the master result converted throughFromPose3.QuaternionFactoryTests(10) —FromTo(random, shortest arc, same, opposite ×4, zero throws),FromAxes(10 000 random, identity).Pose3SurfaceTests(11) — TS-129-55: 10 deleted names absent (public and non-public), exact public member list.Kinematics.KinematicChainTests(12) — TS-129-03.Kinematics.ForwardTests(7) — TS-129-04 (2R and 1P vs closed form at 1 000 random q within 1e-12; wronglinks/qlength throws) + zero-allocation check.Kinematics.JacobianTests(6) — TS-129-05:test.arm6and 1P+6R vs central differences (h = 1e-7) at 1 000 random q within 1e-6; prismatic column = (1,0,0,0,0,0); base frame gives zeros; wrong lengths throw.Grep gate (TS-129-48), new
grep-gateCI job (.github/scripts/grep-gate.sh, one row per line)The script fails on any non-zero count (checked by planting a
Pose3<double>.Lerpline locally).Pose3<T>parameters that remain inSources/ModelingEvolution.Drawing(all wire or edit, none composes poses)Frame3.FromPose3(Pose3, T lengthScale)Pose3==/!=/Equals, tuple conversion,TryParse(out),Enumerator(Pose3)(internal)Pose3JsonConverter.Write(…, Pose3, …)PosePath3(params Pose3[]),(IList<Pose3>),(ReadOnlyMemory<Pose3>);Waypoint3(Pose3, T time);PoseEventArgs(Pose3, …)Pose3PosePath3Builder.StartAt(Pose3);Trajectory3Builder.Add(Pose3[, time]),MoveTo(Pose3, Speed[, Corner[, Speed]])×3Pose3buffer (edit). Note:MoveTowith aCornerblends rotations withRotation3.Slerp— Euler maths outsidePose3, the follow-up the lead is deciding, not aPose3operationPosePath3Extensions.AdaptToEndpoints(this PosePath3, Pose3 adaptedStart, Pose3 adaptedEnd)Matrix3x3similarity on the mid positions only, rotations are copied, no pose is composed. Borderline — flagged to the leadQ7: Euler rotation algebra deleted, matrix bridge kept through the quaternion
Rotation3<T>is a wire and edit format likePose3<T>; rotation maths usesQuaternion<T>orFrame3<T>. The matrix ⇄ Euler edge that the OpenCV and solver code in rw2 and the simulator need stays (Matrix3x3.RotationZYX,ToEulerZYX,Rotation3.FromMatrix3x3), routed through the quaternion so there is one Euler → rotation formula (Quaternion.FromRpy), one rotation → Euler formula (Rotation3.FromQuaternion) and one Shepperd (Quaternion.FromMatrix3x3). Round 3 deletesVector3.RotationToand theVector3 → Rotation3cast (its own direction → Euler formula);Matrix3x3.SimilarityFromVectorscomposesQuaternion.FromTo. Numbers:RotationZYXequals master's closed form within 1e-12;ToEulerZYXequals master's angles within 1e-9° wherever master was exact (|cos ry| > 1e-6).Behaviour note — gimbal fix (round 4, replaces the earlier 3(a) note). On master
Rotation3.FromQuaterniontook its gimbal branch at |sin ry| ≥ 1 − 1e-6 (|ry| ≳ 89.92°) and forced ry = ±90°, rz = 0, which changed the rotation itself by up to 0.08° (0.01° at 89.99°). This PR routesToEulerZYX,Rotation3.FromMatrix3x3,Frame3.ToPose3andPosePath3Builder.Stepthrough it, so it is fixed here: ry = atan2(sin ry, cos ry) with cos ry = √(r00² + r10²); the gimbal branch is taken only when cos ry < 1e-9, and there rz = 0, rx = atan2(−r12, r11), exact for any ry. Every Euler result now reproduces its rotation (all elements within 1e-8 over 10 000 samples with |ry| ∈ [89.9°, 90°], 1e-12 at exactly ±90°). Callers that pass ry within 89.92°..90° on master (e.g. rw2Vision/PoseConversions.cs:48,NbvCalibrationService.cs:229; simulatorMetrology/Core/Se3.cs:41,HandEyeGroundTruth.cs:56) now get the exact rotation back instead of the snapped one; at true gimbal lock the split is still rz = 0.Line endings (round 4, 3f3b720). Earlier rounds converted 11 files from CRLF (as on master) to LF: both READMEs,
Matrix3x3.cs,Polyline3.cs,PosePath3.cs,Trajectory3.cs,Trajectory3Builder.cs,Matrix3x3Tests.cs,Polyline3Tests.cs,PosePath3Tests.cs,Trajectory3Tests.cs. 3f3b720 restores CRLF on exactly those (content-only diff is empty).git diff --stat origin/master...HEAD: 13 510 lines (7 239 + / 6 271 −) before, 4 360 (2 664 + / 1 696 −) after — equal to--ignore-cr-at-eol; 4 542 with round 4's code. New files are LF; master itself mixes LF and CRLF files (e.g.Pose3.csLF,Matrix3x3.csCRLF).Consumer impact (Task 2 count, before this PR): rw2 161 sites in 50 files, simulator 5 in 4, SDK 4 in 2. No call to the deleted
Matrix3x3.ToQuaternion()found in the three consumers (rw2'sm.ToQuaternion()inRobot.Abb.Simulatoris on its ownMat4).Round 3 count (
git grep -non master: simulator 7c75764, rw2 307b2fdd, SDK 46ff3ac):RotationTo(— simulator 0, rw2 0, SDK 1 site in 1 file (csharp/RocketWelder.SDK.Vision/CameraProjector.cs:84,Vector3d.EZ.RotationTo(rayDir)→Quaternion<double>.FromTo(Vector3d.EZ, rayDir));(Rotation3<…>)cast on aVector3— 0 in all three (aliases checked:Vec3,SimVec3,Vector3d).operator +(Vector3, Rotation3)uses were not counted (no reliable grep for an operator).test.arm6(for Matrix.Fixtures to copy; metres, radians;Sources/ModelingEvolution.Drawing.Tests/Kinematics/TestChains.cs)Links:
base(-1),link1(0),link2(1),link3(2),link4(3),link5(4),link6(5). All joints revolute, MaxVelocity 3.14; Origin = xyz + rpy (Quaternion.FromRpy), Axis normalized.Frames:
baseonbase(identity);flangeonlink6, xyz (0, 0, 0.045), rpy (π, 0, 0).Rail variant (1P+6R):
base→carriageby prismaticrail(origin (0, 0, 0.25), axis +X, 0..3 m, MaxVelocity 1, no acceleration), then test.arm6 on the carriage with j1's origin xyz (0, 0.10, 0.230).Design points decided here
Joint<T>andKinematicChain<T>addstructto thePose3constraint: without itT? MaxAccelerationis only a nullable annotation (double, notdouble?) and could not be null.Frame3<T>andChainFrame<T>keep thePose3constraint exactly.Dof == Links.Length − 1); fixed offsets areChainFrames or joint origins (the design has noFixedjoint).Distancereturns(Position, Angle); the angle is computed as2·atan2(|v|, |w|)ofqa*·qb, equal to the Rules'2·acos(|qa·qb|)but well conditioned near 0.— reversed by review A:Transformkeeps itsPose3parameterTransform(in Frame3<T>); callers convert at their edge withFrame3.FromPose3(pose, scale).Pose3.From(...)factories kept as named constructors (not in the fate table either way).🤖 Generated with Claude Code
https://claude.ai/code/session_014wH5aL3TZhJNvLqa8YTku8