import { describe, expect, it } from "vitest"; import { DEFAULT_TOOLCRAFT_ORIENTATION_POSE, TOOLCRAFT_ORIENTATION_AXES, TOOLCRAFT_TURNTABLE_RADIANS_PER_PIXEL, easeToolcraftOrientationSnap, getToolcraftOrientationCameraQuaternion, getToolcraftOrientationPoseFromPointerDelta, getToolcraftOrientationRadius, getToolcraftOrientationSnapDurationMs, interpolateToolcraftOrientationPose, projectToolcraftOrientationAxes, readToolcraftOrientationPose, snapToolcraftOrientationPose, type ToolcraftOrientationAxis, } from "./orientation-gizmo-math"; function expectTupleClose( received: readonly [number, number, number], expected: readonly [number, number, number], ): void { expected.forEach((value, index) => { expect(received[index]).toBeCloseTo(value, 10); }); } function normalizeForTest( vector: readonly [number, number, number], ): [number, number, number] { const length = Math.hypot(...vector); return [vector[0] / length, vector[1] / length, vector[2] / length]; } function crossForTest( left: readonly [number, number, number], right: readonly [number, number, number], ): [number, number, number] { return [ left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0], ]; } function distanceForTest( left: readonly [number, number, number], right: readonly [number, number, number], ): number { return Math.hypot( left[0] - right[0], left[1] - right[1], left[2] - right[2], ); } describe("Toolcraft orientation math", () => { it("reads valid poses defensively and rejects malformed state", () => { const source = { position: [1, 2, 3], up: [0, 1, 0] } as const; const read = readToolcraftOrientationPose(source); expect(read).toEqual(source); expect(read).not.toBe(source); expect(read.position).not.toBe(source.position); expect( readToolcraftOrientationPose({ position: [1, 2], up: [0, 1, 0] }), ).toEqual(DEFAULT_TOOLCRAFT_ORIENTATION_POSE); expect( readToolcraftOrientationPose({ position: [0, 2, 0], up: [0, 1, 0] }), ).toEqual(DEFAULT_TOOLCRAFT_ORIENTATION_POSE); }); it("uses Blender factory turntable sensitivity in CSS pixels", () => { expect(TOOLCRAFT_TURNTABLE_RADIANS_PER_PIXEL).toBeCloseTo( (0.4 * Math.PI) / 180, 12, ); const result = getToolcraftOrientationPoseFromPointerDelta( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, 90, 0, ); const angle = (36 * Math.PI) / 180; expectTupleClose(result.position, [ -5 * Math.sin(angle), 0, 5 * Math.cos(angle), ]); }); it("keeps a level horizon away from the poles", () => { const result = getToolcraftOrientationPoseFromPointerDelta( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, 37, 24, ); const back = normalizeForTest(result.position); const projectedWorldUp = normalizeForTest([ -back[1] * back[0], 1 - back[1] * back[1], -back[1] * back[2], ]); expectTupleClose(result.up, projectedWorldUp); expect(getToolcraftOrientationRadius(result)).toBeCloseTo(5, 10); }); it.each(["+y", "-y"] as const)( "leaves the %s pole continuously", (axis) => { const pole = snapToolcraftOrientationPose( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, axis, ); const first = getToolcraftOrientationPoseFromPointerDelta(pole, 0, 1); const second = getToolcraftOrientationPoseFromPointerDelta(first, 0, 1); expect(first).not.toEqual(pole); expect(getToolcraftOrientationRadius(first)).toBeCloseTo(5, 10); expect(getToolcraftOrientationRadius(second)).toBeCloseTo(5, 10); expect(Math.hypot(...crossForTest(first.up, first.position))).toBeGreaterThan( 1, ); expect(distanceForTest(first.position, second.position)).toBeLessThan(0.1); }, ); it("projects every signed world axis", () => { const projected = projectToolcraftOrientationAxes({ position: [0, 0, 10], up: [0, 1, 0], }); const byAxis = Object.fromEntries( projected.map((item) => [item.axis, item]), ); expect(byAxis["+x"]).toMatchObject({ x: 59.5, y: 35, depth: 0 }); expect(byAxis["-x"]).toMatchObject({ x: 10.5, y: 35, depth: 0 }); expect(byAxis["+y"]).toMatchObject({ x: 35, y: 10.5, depth: 0 }); expect(byAxis["-y"]).toMatchObject({ x: 35, y: 59.5, depth: 0 }); expect(byAxis["+z"].depth).toBeCloseTo(-1, 12); expect(byAxis["-z"].depth).toBeCloseTo(1, 12); }); it("snaps every axis while preserving radius", () => { const radius = getToolcraftOrientationRadius( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, ); const expectations: Record< ToolcraftOrientationAxis, { position: [number, number, number]; up: [number, number, number] } > = { "+x": { position: [radius, 0, 0], up: [0, 1, 0] }, "-x": { position: [-radius, 0, 0], up: [0, 1, 0] }, "+y": { position: [0, radius, 0], up: [0, 0, -1] }, "-y": { position: [0, -radius, 0], up: [0, 0, 1] }, "+z": { position: [0, 0, radius], up: [0, 1, 0] }, "-z": { position: [0, 0, -radius], up: [0, 1, 0] }, }; for (const axis of TOOLCRAFT_ORIENTATION_AXES) { const snapped = snapToolcraftOrientationPose( DEFAULT_TOOLCRAFT_ORIENTATION_POSE, axis, ); expectTupleClose(snapped.position, expectations[axis].position); expectTupleClose(snapped.up, expectations[axis].up); expect(getToolcraftOrientationRadius(snapped)).toBeCloseTo(radius, 10); } }); it("interpolates orientation without changing radius", () => { const start = DEFAULT_TOOLCRAFT_ORIENTATION_POSE; const end = snapToolcraftOrientationPose(start, "+x"); const middle = interpolateToolcraftOrientationPose(start, end, 0.5); expect(getToolcraftOrientationRadius(middle)).toBeCloseTo( getToolcraftOrientationRadius(start), 10, ); expect( getToolcraftOrientationCameraQuaternion(middle).length(), ).toBeCloseTo(1, 10); }); it("uses Blender cubic smoothstep", () => { expect(easeToolcraftOrientationSnap(-1)).toBe(0); expect(easeToolcraftOrientationSnap(0.25)).toBe(0.15625); expect(easeToolcraftOrientationSnap(0.5)).toBe(0.5); expect(easeToolcraftOrientationSnap(0.75)).toBe(0.84375); expect(easeToolcraftOrientationSnap(2)).toBe(1); }); it("scales the 200ms Smooth View duration by quaternion angle", () => { const front = DEFAULT_TOOLCRAFT_ORIENTATION_POSE; expect(getToolcraftOrientationSnapDurationMs(front, front)).toBeCloseTo( 0, 8, ); expect( getToolcraftOrientationSnapDurationMs( front, snapToolcraftOrientationPose(front, "+x"), ), ).toBeCloseTo(100, 8); expect( getToolcraftOrientationSnapDurationMs( front, snapToolcraftOrientationPose(front, "-z"), ), ).toBeCloseTo(200, 8); }); });