@wandelbots/nova-js 4.2.0 → 4.3.0-pr.318.6592c26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/experimental/math/index.d.mts +107 -0
- package/dist/experimental/math/index.d.mts.map +1 -0
- package/dist/experimental/math/index.mjs +226 -0
- package/dist/experimental/math/index.mjs.map +1 -0
- package/package.json +5 -1
- package/src/experimental/math/index.ts +24 -0
- package/src/lib/experimental/math/Pose.ts +119 -0
- package/src/lib/experimental/math/Quaternion.ts +79 -0
- package/src/lib/experimental/math/augmentMath.ts +175 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { bc as Pose$1, t as Nova } from "../../Nova-DdbUm145.mjs";
|
|
2
|
+
//#region src/lib/experimental/math/Pose.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A `Pose` (position + axis-angle orientation) with methods for composing
|
|
5
|
+
* and inverting transforms. Instances are plain-data compatible with the
|
|
6
|
+
* wire-format `PoseData` type (own `position`/`orientation` properties only,
|
|
7
|
+
* no enumerable methods), so they can be passed directly back into API calls
|
|
8
|
+
* that expect a pose.
|
|
9
|
+
*
|
|
10
|
+
* Poses returned from `NovaAPIClient`/`Nova` are automatically upgraded to
|
|
11
|
+
* `Pose` instances; construct one directly only when you have a pose from
|
|
12
|
+
* elsewhere (e.g. a websocket message).
|
|
13
|
+
*/
|
|
14
|
+
declare class Pose implements Pose$1 {
|
|
15
|
+
readonly position: number[];
|
|
16
|
+
readonly orientation: number[];
|
|
17
|
+
constructor(position?: number[], orientation?: number[]);
|
|
18
|
+
static from(pose: Pose$1): Pose;
|
|
19
|
+
static identity(): Pose;
|
|
20
|
+
/**
|
|
21
|
+
* Compose this pose with `other`, treating `other` as being expressed in
|
|
22
|
+
* this pose's coordinate frame. Equivalent to the homogeneous transform
|
|
23
|
+
* product `this * other`: applying the result to a point is the same as
|
|
24
|
+
* applying `other` first, then `this`.
|
|
25
|
+
*/
|
|
26
|
+
multiply(other: Pose$1): Pose;
|
|
27
|
+
/** The inverse transform, such that `pose.multiply(pose.inverse())` is the identity pose. */
|
|
28
|
+
inverse(): Pose;
|
|
29
|
+
/** Apply this pose's transform to a point, returning the transformed point. */
|
|
30
|
+
transformPoint(point: number[]): number[];
|
|
31
|
+
/** Compare to another pose within a tolerance, since floating-point pose math rarely produces exact equality. */
|
|
32
|
+
equals(other: Pose$1, epsilon?: number): boolean;
|
|
33
|
+
toJSON(): Pose$1;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/lib/experimental/math/augmentMath.d.ts
|
|
37
|
+
type PoseKeys = keyof Pose$1;
|
|
38
|
+
type IsExactlyPoseData<T> = [Exclude<keyof T, PoseKeys>] extends [never] ? [Exclude<PoseKeys, keyof T>] extends [never] ? true : false : false;
|
|
39
|
+
type MaxDepth = [unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown];
|
|
40
|
+
type AugmentPosesCore<T, Depth extends readonly unknown[]> = Depth["length"] extends MaxDepth["length"] ? T : IsExactlyPoseData<T> extends true ? Pose : T extends readonly (infer U)[] ? DeepPoseAugmented<U, [...Depth, unknown]>[] : T extends object ? { [K in keyof T]: DeepPoseAugmented<T[K], [...Depth, unknown]>; } : T;
|
|
41
|
+
/**
|
|
42
|
+
* Type-level counterpart to `augmentPoses`: recursively replaces any
|
|
43
|
+
* `PoseData`-shaped field with `Pose` (distributing over unions, e.g. so
|
|
44
|
+
* optional `Pose | undefined` fields keep the `| undefined`), so the static
|
|
45
|
+
* type matches what's actually returned at runtime.
|
|
46
|
+
*/
|
|
47
|
+
type DeepPoseAugmented<T, Depth extends readonly unknown[] = []> = T extends unknown ? AugmentPosesCore<T, Depth> : never;
|
|
48
|
+
/**
|
|
49
|
+
* Recursively walks a parsed API response/request body, upgrading any
|
|
50
|
+
* `Pose`-shaped objects in place to `Pose` instances so they gain math
|
|
51
|
+
* methods (`multiply`, `inverse`, ...) while remaining JSON/wire-compatible.
|
|
52
|
+
* Used by `augmentMath` for `nova.api.*` responses; exported so it can also
|
|
53
|
+
* be applied manually to e.g. websocket messages.
|
|
54
|
+
*/
|
|
55
|
+
declare function augmentPoses<T>(value: T): DeepPoseAugmented<T>;
|
|
56
|
+
type WithAugmentedPosesCore<T, Depth extends readonly unknown[]> = Depth["length"] extends MaxDepth["length"] ? T : T extends ((...args: infer A) => Promise<infer R>) ? (...args: A) => Promise<DeepPoseAugmented<R>> : T extends object ? { [K in keyof T]: WithAugmentedPosesCore<T[K], [...Depth, unknown]>; } : T;
|
|
57
|
+
/**
|
|
58
|
+
* Recursively maps every method on `Nova["api"]` (and its nested API groups)
|
|
59
|
+
* so calls are typed as returning `DeepPoseAugmented` results, matching what
|
|
60
|
+
* `augmentMath` does at runtime.
|
|
61
|
+
*/
|
|
62
|
+
type WithAugmentedPoses<T> = WithAugmentedPosesCore<T, []>;
|
|
63
|
+
type NovaWithMath = Omit<Nova, "api"> & {
|
|
64
|
+
readonly api: WithAugmentedPoses<Nova["api"]>;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Wraps a `Nova` instance so every `nova.api.*` call automatically upgrades
|
|
68
|
+
* `Pose`-shaped fields in its response to `Pose` instances (with math
|
|
69
|
+
* methods like `multiply`/`inverse`). Returns a new proxied view - does not
|
|
70
|
+
* mutate the original `nova` instance.
|
|
71
|
+
*/
|
|
72
|
+
declare function augmentMath(nova: Nova): NovaWithMath;
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/lib/experimental/math/Quaternion.d.ts
|
|
75
|
+
/**
|
|
76
|
+
* Internal quaternion helpers used to compose/invert the axis-angle
|
|
77
|
+
* `orientation` vectors used by the NOVA API's `Pose` type. Rotation vectors
|
|
78
|
+
* are converted to quaternions for composition (numerically stable, unlike
|
|
79
|
+
* composing axis-angle vectors directly) and converted back at the boundary.
|
|
80
|
+
*/
|
|
81
|
+
type Quaternion = {
|
|
82
|
+
w: number;
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
z: number;
|
|
86
|
+
};
|
|
87
|
+
/** Convert an axis-angle rotation vector [rx, ry, rz] to a unit quaternion. */
|
|
88
|
+
declare function axisAngleToQuaternion(r: number[]): Quaternion;
|
|
89
|
+
/**
|
|
90
|
+
* Convert a unit quaternion back to an axis-angle rotation vector [rx, ry, rz],
|
|
91
|
+
* matching wb-robotix's `Quaternion::toRotationVector()` (ported from
|
|
92
|
+
* QuaternionBasePlugin.h): using `atan2` rather than `acos` keeps the angle
|
|
93
|
+
* canonically within [0, pi] regardless of which of the two antipodal unit
|
|
94
|
+
* quaternions (q or -q) represents the rotation, rather than acos's [0, 2*pi]
|
|
95
|
+
* range - important since composed poses need to match the same canonical
|
|
96
|
+
* rotation vector the robot controller itself would report for a pose.
|
|
97
|
+
*/
|
|
98
|
+
declare function quaternionToAxisAngle(q: Quaternion): number[];
|
|
99
|
+
/** Hamilton product: composes rotations so that applying `multiplyQuaternions(a, b)` to a vector equals applying `b` then `a`. */
|
|
100
|
+
declare function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion;
|
|
101
|
+
/** Conjugate of a unit quaternion equals its inverse. */
|
|
102
|
+
declare function conjugateQuaternion(q: Quaternion): Quaternion;
|
|
103
|
+
/** Rotate a 3D vector by a unit quaternion. */
|
|
104
|
+
declare function rotateVectorByQuaternion(q: Quaternion, v: number[]): number[];
|
|
105
|
+
//#endregion
|
|
106
|
+
export { type DeepPoseAugmented, type NovaWithMath, Pose, type Pose$1 as PoseData, type Quaternion, augmentMath, augmentPoses, axisAngleToQuaternion, conjugateQuaternion, multiplyQuaternions, quaternionToAxisAngle, rotateVectorByQuaternion };
|
|
107
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/lib/experimental/math/Pose.ts","../../../src/lib/experimental/math/augmentMath.ts","../../../src/lib/experimental/math/Quaternion.ts"],"mappings":";;;;;;;;;;;;;cAkCa,gBAAgB;WAClB;WACA;EAGP,YAAA,qBACA;SAMK,KAAK,MAAM,SAAW;SAUtB,YAAY;;;;;;;EAUnB,SAAS,OAAO,SAAW;;EAc3B,WAAW;;EAeX,eAAe;;EAMf,OAAO,OAAO,QAAU;EAcxB,UAAU;;;;KC/GP,iBAAiB;KAEjB,kBAAkB,MAAM,cAAc,GAAG,8BACzC,QAAQ,gBAAgB;KAOxB;KAaA,iBACH,GACA,oCACE,wBAAwB,qBACxB,IACA,kBAAkB,kBAChB,OACA,0BAA0B,OACxB,kBAAkB,OAAO,qBACzB,sBACK,WAAW,IAAI,kBAAkB,EAAE,QAAQ,sBAC9C;;;;;;;KAQE,kBACV,GACA,yCACE,oBAAoB,iBAAiB,GAAG;;;;;;;;iBA2D5B,aAAa,GAAG,OAAO,IAAI,kBAAkB;KAKxD,uBACH,GACA,oCACE,wBAAwB,qBACxB,IACA,eAAc,YAAY,MAAM,cAAc,UACxC,MAAM,MAAM,QAAQ,kBAAkB,MAC1C,sBACK,WAAW,IAAI,uBAAuB,EAAE,QAAQ,sBACnD;;;;;;KAOH,mBAAmB,KAAK,uBAAuB;KAExC,eAAe,KAAK;WACrB,KAAK,mBAAmB;;;;;;;;iBA6BnB,YAAY,MAAM,OAAO;;;;;;;;;KC1J7B;EAAe;EAAW;EAAW;EAAW;;;iBAK5C,sBAAsB,cAAc;;;;;;;;;;iBAuBpC,sBAAsB,GAAG;;iBAWzB,oBAAoB,GAAG,YAAY,GAAG,aAAa;;iBAUnD,oBAAoB,GAAG,aAAa;;iBAKpC,yBAAyB,GAAG,YAAY"}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
//#region src/lib/experimental/math/Quaternion.ts
|
|
2
|
+
const EPSILON = 1e-12;
|
|
3
|
+
/** Convert an axis-angle rotation vector [rx, ry, rz] to a unit quaternion. */
|
|
4
|
+
function axisAngleToQuaternion(r) {
|
|
5
|
+
const rx = r[0] ?? 0;
|
|
6
|
+
const ry = r[1] ?? 0;
|
|
7
|
+
const rz = r[2] ?? 0;
|
|
8
|
+
const angle = Math.sqrt(rx * rx + ry * ry + rz * rz);
|
|
9
|
+
if (angle < EPSILON) return {
|
|
10
|
+
w: 1,
|
|
11
|
+
x: 0,
|
|
12
|
+
y: 0,
|
|
13
|
+
z: 0
|
|
14
|
+
};
|
|
15
|
+
const half = angle / 2;
|
|
16
|
+
const s = Math.sin(half) / angle;
|
|
17
|
+
return {
|
|
18
|
+
w: Math.cos(half),
|
|
19
|
+
x: rx * s,
|
|
20
|
+
y: ry * s,
|
|
21
|
+
z: rz * s
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert a unit quaternion back to an axis-angle rotation vector [rx, ry, rz],
|
|
26
|
+
* matching wb-robotix's `Quaternion::toRotationVector()` (ported from
|
|
27
|
+
* QuaternionBasePlugin.h): using `atan2` rather than `acos` keeps the angle
|
|
28
|
+
* canonically within [0, pi] regardless of which of the two antipodal unit
|
|
29
|
+
* quaternions (q or -q) represents the rotation, rather than acos's [0, 2*pi]
|
|
30
|
+
* range - important since composed poses need to match the same canonical
|
|
31
|
+
* rotation vector the robot controller itself would report for a pose.
|
|
32
|
+
*/
|
|
33
|
+
function quaternionToAxisAngle(q) {
|
|
34
|
+
const vecNorm = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z);
|
|
35
|
+
if (vecNorm < EPSILON) return [
|
|
36
|
+
0,
|
|
37
|
+
0,
|
|
38
|
+
0
|
|
39
|
+
];
|
|
40
|
+
const angle = 2 * Math.atan2(vecNorm, Math.abs(q.w));
|
|
41
|
+
const scale = (q.w >= 0 ? angle : -angle) / vecNorm;
|
|
42
|
+
return [
|
|
43
|
+
q.x * scale,
|
|
44
|
+
q.y * scale,
|
|
45
|
+
q.z * scale
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/** Hamilton product: composes rotations so that applying `multiplyQuaternions(a, b)` to a vector equals applying `b` then `a`. */
|
|
49
|
+
function multiplyQuaternions(a, b) {
|
|
50
|
+
return {
|
|
51
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
|
52
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
53
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
54
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Conjugate of a unit quaternion equals its inverse. */
|
|
58
|
+
function conjugateQuaternion(q) {
|
|
59
|
+
return {
|
|
60
|
+
w: q.w,
|
|
61
|
+
x: -q.x,
|
|
62
|
+
y: -q.y,
|
|
63
|
+
z: -q.z
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Rotate a 3D vector by a unit quaternion. */
|
|
67
|
+
function rotateVectorByQuaternion(q, v) {
|
|
68
|
+
const vx = v[0] ?? 0;
|
|
69
|
+
const vy = v[1] ?? 0;
|
|
70
|
+
const vz = v[2] ?? 0;
|
|
71
|
+
const { w, x, y, z } = q;
|
|
72
|
+
const tx = 2 * (y * vz - z * vy);
|
|
73
|
+
const ty = 2 * (z * vx - x * vz);
|
|
74
|
+
const tz = 2 * (x * vy - y * vx);
|
|
75
|
+
return [
|
|
76
|
+
vx + w * tx + (y * tz - z * ty),
|
|
77
|
+
vy + w * ty + (z * tx - x * tz),
|
|
78
|
+
vz + w * tz + (x * ty - y * tx)
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/lib/experimental/math/Pose.ts
|
|
83
|
+
const ZERO_VECTOR = [
|
|
84
|
+
0,
|
|
85
|
+
0,
|
|
86
|
+
0
|
|
87
|
+
];
|
|
88
|
+
function addVectors(a, b) {
|
|
89
|
+
return [
|
|
90
|
+
(a[0] ?? 0) + (b[0] ?? 0),
|
|
91
|
+
(a[1] ?? 0) + (b[1] ?? 0),
|
|
92
|
+
(a[2] ?? 0) + (b[2] ?? 0)
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
function negateVector(a) {
|
|
96
|
+
return [
|
|
97
|
+
-(a[0] ?? 0),
|
|
98
|
+
-(a[1] ?? 0),
|
|
99
|
+
-(a[2] ?? 0)
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A `Pose` (position + axis-angle orientation) with methods for composing
|
|
104
|
+
* and inverting transforms. Instances are plain-data compatible with the
|
|
105
|
+
* wire-format `PoseData` type (own `position`/`orientation` properties only,
|
|
106
|
+
* no enumerable methods), so they can be passed directly back into API calls
|
|
107
|
+
* that expect a pose.
|
|
108
|
+
*
|
|
109
|
+
* Poses returned from `NovaAPIClient`/`Nova` are automatically upgraded to
|
|
110
|
+
* `Pose` instances; construct one directly only when you have a pose from
|
|
111
|
+
* elsewhere (e.g. a websocket message).
|
|
112
|
+
*/
|
|
113
|
+
var Pose = class Pose {
|
|
114
|
+
position;
|
|
115
|
+
orientation;
|
|
116
|
+
constructor(position = ZERO_VECTOR, orientation = ZERO_VECTOR) {
|
|
117
|
+
this.position = position;
|
|
118
|
+
this.orientation = orientation;
|
|
119
|
+
}
|
|
120
|
+
static from(pose) {
|
|
121
|
+
if (pose instanceof Pose) return pose;
|
|
122
|
+
return new Pose(pose.position ?? ZERO_VECTOR, pose.orientation ?? ZERO_VECTOR);
|
|
123
|
+
}
|
|
124
|
+
static identity() {
|
|
125
|
+
return new Pose();
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Compose this pose with `other`, treating `other` as being expressed in
|
|
129
|
+
* this pose's coordinate frame. Equivalent to the homogeneous transform
|
|
130
|
+
* product `this * other`: applying the result to a point is the same as
|
|
131
|
+
* applying `other` first, then `this`.
|
|
132
|
+
*/
|
|
133
|
+
multiply(other) {
|
|
134
|
+
const q1 = axisAngleToQuaternion(this.orientation);
|
|
135
|
+
const q2 = axisAngleToQuaternion(other.orientation ?? ZERO_VECTOR);
|
|
136
|
+
const position = addVectors(this.position, rotateVectorByQuaternion(q1, other.position ?? ZERO_VECTOR));
|
|
137
|
+
const orientation = quaternionToAxisAngle(multiplyQuaternions(q1, q2));
|
|
138
|
+
return new Pose(position, orientation);
|
|
139
|
+
}
|
|
140
|
+
/** The inverse transform, such that `pose.multiply(pose.inverse())` is the identity pose. */
|
|
141
|
+
inverse() {
|
|
142
|
+
const position = rotateVectorByQuaternion(conjugateQuaternion(axisAngleToQuaternion(this.orientation)), negateVector(this.position));
|
|
143
|
+
const orientation = negateVector(this.orientation);
|
|
144
|
+
return new Pose(position, orientation);
|
|
145
|
+
}
|
|
146
|
+
/** Apply this pose's transform to a point, returning the transformed point. */
|
|
147
|
+
transformPoint(point) {
|
|
148
|
+
const q = axisAngleToQuaternion(this.orientation);
|
|
149
|
+
return addVectors(this.position, rotateVectorByQuaternion(q, point));
|
|
150
|
+
}
|
|
151
|
+
/** Compare to another pose within a tolerance, since floating-point pose math rarely produces exact equality. */
|
|
152
|
+
equals(other, epsilon = 1e-9) {
|
|
153
|
+
const otherPosition = other.position ?? ZERO_VECTOR;
|
|
154
|
+
const otherOrientation = other.orientation ?? ZERO_VECTOR;
|
|
155
|
+
return this.position.every((v, i) => Math.abs(v - (otherPosition[i] ?? 0)) <= epsilon) && this.orientation.every((v, i) => Math.abs(v - (otherOrientation[i] ?? 0)) <= epsilon);
|
|
156
|
+
}
|
|
157
|
+
toJSON() {
|
|
158
|
+
return {
|
|
159
|
+
position: this.position,
|
|
160
|
+
orientation: this.orientation
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/lib/experimental/math/augmentMath.ts
|
|
166
|
+
function isPlainObject(value) {
|
|
167
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Pose);
|
|
168
|
+
}
|
|
169
|
+
function isNumberTriple(value) {
|
|
170
|
+
return Array.isArray(value) && value.length === 3 && value.every((n) => typeof n === "number");
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Structural check for the wire shape of `Pose` (`{ position, orientation }`,
|
|
174
|
+
* each a 3-number array, and nothing else). This API has no other type with
|
|
175
|
+
* this exact shape, so it reliably identifies `Pose` values without needing
|
|
176
|
+
* per-endpoint knowledge of which fields are poses.
|
|
177
|
+
*/
|
|
178
|
+
function isPoseShape(value) {
|
|
179
|
+
return Object.keys(value).length === 2 && isNumberTriple(value.position) && isNumberTriple(value.orientation);
|
|
180
|
+
}
|
|
181
|
+
function augmentPosesInPlace(value) {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
for (const item of value) augmentPosesInPlace(item);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (isPlainObject(value)) if (isPoseShape(value)) Object.setPrototypeOf(value, Pose.prototype);
|
|
187
|
+
else for (const key of Object.keys(value)) augmentPosesInPlace(value[key]);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Recursively walks a parsed API response/request body, upgrading any
|
|
191
|
+
* `Pose`-shaped objects in place to `Pose` instances so they gain math
|
|
192
|
+
* methods (`multiply`, `inverse`, ...) while remaining JSON/wire-compatible.
|
|
193
|
+
* Used by `augmentMath` for `nova.api.*` responses; exported so it can also
|
|
194
|
+
* be applied manually to e.g. websocket messages.
|
|
195
|
+
*/
|
|
196
|
+
function augmentPoses(value) {
|
|
197
|
+
augmentPosesInPlace(value);
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function wrapWithPoseAugmentation(target) {
|
|
201
|
+
return new Proxy(target, { get(t, prop) {
|
|
202
|
+
const value = Reflect.get(t, prop, t);
|
|
203
|
+
if (typeof value === "function") return (...args) => {
|
|
204
|
+
const result = value.apply(t, args);
|
|
205
|
+
return result instanceof Promise ? result.then((data) => augmentPoses(data)) : result;
|
|
206
|
+
};
|
|
207
|
+
if (value !== null && typeof value === "object") return wrapWithPoseAugmentation(value);
|
|
208
|
+
return value;
|
|
209
|
+
} });
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Wraps a `Nova` instance so every `nova.api.*` call automatically upgrades
|
|
213
|
+
* `Pose`-shaped fields in its response to `Pose` instances (with math
|
|
214
|
+
* methods like `multiply`/`inverse`). Returns a new proxied view - does not
|
|
215
|
+
* mutate the original `nova` instance.
|
|
216
|
+
*/
|
|
217
|
+
function augmentMath(nova) {
|
|
218
|
+
return new Proxy(nova, { get(target, prop) {
|
|
219
|
+
if (prop === "api") return wrapWithPoseAugmentation(target.api);
|
|
220
|
+
return Reflect.get(target, prop, target);
|
|
221
|
+
} });
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
export { Pose, augmentMath, augmentPoses, axisAngleToQuaternion, conjugateQuaternion, multiplyQuaternions, quaternionToAxisAngle, rotateVectorByQuaternion };
|
|
225
|
+
|
|
226
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../../src/lib/experimental/math/Quaternion.ts","../../../src/lib/experimental/math/Pose.ts","../../../src/lib/experimental/math/augmentMath.ts"],"sourcesContent":["/**\n * Internal quaternion helpers used to compose/invert the axis-angle\n * `orientation` vectors used by the NOVA API's `Pose` type. Rotation vectors\n * are converted to quaternions for composition (numerically stable, unlike\n * composing axis-angle vectors directly) and converted back at the boundary.\n */\n\nexport type Quaternion = { w: number; x: number; y: number; z: number }\n\nconst EPSILON = 1e-12\n\n/** Convert an axis-angle rotation vector [rx, ry, rz] to a unit quaternion. */\nexport function axisAngleToQuaternion(r: number[]): Quaternion {\n const rx = r[0] ?? 0\n const ry = r[1] ?? 0\n const rz = r[2] ?? 0\n\n const angle = Math.sqrt(rx * rx + ry * ry + rz * rz)\n if (angle < EPSILON) {\n return { w: 1, x: 0, y: 0, z: 0 }\n }\n const half = angle / 2\n const s = Math.sin(half) / angle\n return { w: Math.cos(half), x: rx * s, y: ry * s, z: rz * s }\n}\n\n/**\n * Convert a unit quaternion back to an axis-angle rotation vector [rx, ry, rz],\n * matching wb-robotix's `Quaternion::toRotationVector()` (ported from\n * QuaternionBasePlugin.h): using `atan2` rather than `acos` keeps the angle\n * canonically within [0, pi] regardless of which of the two antipodal unit\n * quaternions (q or -q) represents the rotation, rather than acos's [0, 2*pi]\n * range - important since composed poses need to match the same canonical\n * rotation vector the robot controller itself would report for a pose.\n */\nexport function quaternionToAxisAngle(q: Quaternion): number[] {\n const vecNorm = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z)\n if (vecNorm < EPSILON) {\n return [0, 0, 0]\n }\n const angle = 2 * Math.atan2(vecNorm, Math.abs(q.w))\n const scale = (q.w >= 0 ? angle : -angle) / vecNorm\n return [q.x * scale, q.y * scale, q.z * scale]\n}\n\n/** Hamilton product: composes rotations so that applying `multiplyQuaternions(a, b)` to a vector equals applying `b` then `a`. */\nexport function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion {\n return {\n w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,\n x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,\n y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,\n z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,\n }\n}\n\n/** Conjugate of a unit quaternion equals its inverse. */\nexport function conjugateQuaternion(q: Quaternion): Quaternion {\n return { w: q.w, x: -q.x, y: -q.y, z: -q.z }\n}\n\n/** Rotate a 3D vector by a unit quaternion. */\nexport function rotateVectorByQuaternion(q: Quaternion, v: number[]): number[] {\n const vx = v[0] ?? 0\n const vy = v[1] ?? 0\n const vz = v[2] ?? 0\n const { w, x, y, z } = q\n\n // t = 2 * cross(q.xyz, v)\n const tx = 2 * (y * vz - z * vy)\n const ty = 2 * (z * vx - x * vz)\n const tz = 2 * (x * vy - y * vx)\n\n // v' = v + w*t + cross(q.xyz, t)\n return [\n vx + w * tx + (y * tz - z * ty),\n vy + w * ty + (z * tx - x * tz),\n vz + w * tz + (x * ty - y * tx),\n ]\n}\n","import type { Pose as PoseData } from \"@wandelbots/nova-api/v2\"\nimport {\n axisAngleToQuaternion,\n conjugateQuaternion,\n multiplyQuaternions,\n quaternionToAxisAngle,\n rotateVectorByQuaternion,\n} from \"./Quaternion.ts\"\n\nconst ZERO_VECTOR = [0, 0, 0]\n\nfunction addVectors(a: number[], b: number[]): number[] {\n return [\n (a[0] ?? 0) + (b[0] ?? 0),\n (a[1] ?? 0) + (b[1] ?? 0),\n (a[2] ?? 0) + (b[2] ?? 0),\n ]\n}\n\nfunction negateVector(a: number[]): number[] {\n return [-(a[0] ?? 0), -(a[1] ?? 0), -(a[2] ?? 0)]\n}\n\n/**\n * A `Pose` (position + axis-angle orientation) with methods for composing\n * and inverting transforms. Instances are plain-data compatible with the\n * wire-format `PoseData` type (own `position`/`orientation` properties only,\n * no enumerable methods), so they can be passed directly back into API calls\n * that expect a pose.\n *\n * Poses returned from `NovaAPIClient`/`Nova` are automatically upgraded to\n * `Pose` instances; construct one directly only when you have a pose from\n * elsewhere (e.g. a websocket message).\n */\nexport class Pose implements PoseData {\n readonly position: number[]\n readonly orientation: number[]\n\n constructor(\n position: number[] = ZERO_VECTOR,\n orientation: number[] = ZERO_VECTOR,\n ) {\n this.position = position\n this.orientation = orientation\n }\n\n static from(pose: PoseData): Pose {\n if (pose instanceof Pose) {\n return pose\n }\n return new Pose(\n pose.position ?? ZERO_VECTOR,\n pose.orientation ?? ZERO_VECTOR,\n )\n }\n\n static identity(): Pose {\n return new Pose()\n }\n\n /**\n * Compose this pose with `other`, treating `other` as being expressed in\n * this pose's coordinate frame. Equivalent to the homogeneous transform\n * product `this * other`: applying the result to a point is the same as\n * applying `other` first, then `this`.\n */\n multiply(other: PoseData): Pose {\n const q1 = axisAngleToQuaternion(this.orientation)\n const q2 = axisAngleToQuaternion(other.orientation ?? ZERO_VECTOR)\n\n const position = addVectors(\n this.position,\n rotateVectorByQuaternion(q1, other.position ?? ZERO_VECTOR),\n )\n const orientation = quaternionToAxisAngle(multiplyQuaternions(q1, q2))\n\n return new Pose(position, orientation)\n }\n\n /** The inverse transform, such that `pose.multiply(pose.inverse())` is the identity pose. */\n inverse(): Pose {\n const q = axisAngleToQuaternion(this.orientation)\n const qInverse = conjugateQuaternion(q)\n\n const position = rotateVectorByQuaternion(\n qInverse,\n negateVector(this.position),\n )\n // Negating an axis-angle vector gives the inverse rotation directly.\n const orientation = negateVector(this.orientation)\n\n return new Pose(position, orientation)\n }\n\n /** Apply this pose's transform to a point, returning the transformed point. */\n transformPoint(point: number[]): number[] {\n const q = axisAngleToQuaternion(this.orientation)\n return addVectors(this.position, rotateVectorByQuaternion(q, point))\n }\n\n /** Compare to another pose within a tolerance, since floating-point pose math rarely produces exact equality. */\n equals(other: PoseData, epsilon = 1e-9): boolean {\n const otherPosition = other.position ?? ZERO_VECTOR\n const otherOrientation = other.orientation ?? ZERO_VECTOR\n\n return (\n this.position.every(\n (v, i) => Math.abs(v - (otherPosition[i] ?? 0)) <= epsilon,\n ) &&\n this.orientation.every(\n (v, i) => Math.abs(v - (otherOrientation[i] ?? 0)) <= epsilon,\n )\n )\n }\n\n toJSON(): PoseData {\n return { position: this.position, orientation: this.orientation }\n }\n}\n","import type { Pose as PoseData } from \"@wandelbots/nova-api/v2\"\nimport type { Nova } from \"../../Nova.ts\"\nimport { Pose } from \"./Pose.ts\"\n\ntype PoseKeys = keyof PoseData\n\ntype IsExactlyPoseData<T> = [Exclude<keyof T, PoseKeys>] extends [never]\n ? [Exclude<PoseKeys, keyof T>] extends [never]\n ? true\n : false\n : false\n\n// Depth cap avoids \"type instantiation is excessively deep\" on nova-api's\n// large (and possibly cyclic, e.g. compound colliders) generated type graph.\ntype MaxDepth = [\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n unknown,\n]\n\ntype AugmentPosesCore<\n T,\n Depth extends readonly unknown[],\n> = Depth[\"length\"] extends MaxDepth[\"length\"]\n ? T\n : IsExactlyPoseData<T> extends true\n ? Pose\n : T extends readonly (infer U)[]\n ? DeepPoseAugmented<U, [...Depth, unknown]>[]\n : T extends object\n ? { [K in keyof T]: DeepPoseAugmented<T[K], [...Depth, unknown]> }\n : T\n\n/**\n * Type-level counterpart to `augmentPoses`: recursively replaces any\n * `PoseData`-shaped field with `Pose` (distributing over unions, e.g. so\n * optional `Pose | undefined` fields keep the `| undefined`), so the static\n * type matches what's actually returned at runtime.\n */\nexport type DeepPoseAugmented<\n T,\n Depth extends readonly unknown[] = [],\n> = T extends unknown ? AugmentPosesCore<T, Depth> : never\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n !Array.isArray(value) &&\n !(value instanceof Pose)\n )\n}\n\nfunction isNumberTriple(value: unknown): value is number[] {\n return (\n Array.isArray(value) &&\n value.length === 3 &&\n value.every((n) => typeof n === \"number\")\n )\n}\n\n/**\n * Structural check for the wire shape of `Pose` (`{ position, orientation }`,\n * each a 3-number array, and nothing else). This API has no other type with\n * this exact shape, so it reliably identifies `Pose` values without needing\n * per-endpoint knowledge of which fields are poses.\n */\nfunction isPoseShape(value: Record<string, unknown>): boolean {\n return (\n Object.keys(value).length === 2 &&\n isNumberTriple(value.position) &&\n isNumberTriple(value.orientation)\n )\n}\n\nfunction augmentPosesInPlace(value: unknown): void {\n if (Array.isArray(value)) {\n for (const item of value) {\n augmentPosesInPlace(item)\n }\n return\n }\n\n if (isPlainObject(value)) {\n if (isPoseShape(value)) {\n Object.setPrototypeOf(value, Pose.prototype)\n } else {\n for (const key of Object.keys(value)) {\n augmentPosesInPlace(value[key])\n }\n }\n }\n}\n\n/**\n * Recursively walks a parsed API response/request body, upgrading any\n * `Pose`-shaped objects in place to `Pose` instances so they gain math\n * methods (`multiply`, `inverse`, ...) while remaining JSON/wire-compatible.\n * Used by `augmentMath` for `nova.api.*` responses; exported so it can also\n * be applied manually to e.g. websocket messages.\n */\nexport function augmentPoses<T>(value: T): DeepPoseAugmented<T> {\n augmentPosesInPlace(value)\n return value as DeepPoseAugmented<T>\n}\n\ntype WithAugmentedPosesCore<\n T,\n Depth extends readonly unknown[],\n> = Depth[\"length\"] extends MaxDepth[\"length\"]\n ? T\n : T extends (...args: infer A) => Promise<infer R>\n ? (...args: A) => Promise<DeepPoseAugmented<R>>\n : T extends object\n ? { [K in keyof T]: WithAugmentedPosesCore<T[K], [...Depth, unknown]> }\n : T\n\n/**\n * Recursively maps every method on `Nova[\"api\"]` (and its nested API groups)\n * so calls are typed as returning `DeepPoseAugmented` results, matching what\n * `augmentMath` does at runtime.\n */\ntype WithAugmentedPoses<T> = WithAugmentedPosesCore<T, []>\n\nexport type NovaWithMath = Omit<Nova, \"api\"> & {\n readonly api: WithAugmentedPoses<Nova[\"api\"]>\n}\n\nfunction wrapWithPoseAugmentation<T extends object>(target: T): T {\n return new Proxy(target, {\n get(t, prop) {\n const value = Reflect.get(t, prop, t)\n if (typeof value === \"function\") {\n return (...args: unknown[]) => {\n const result = (value as (...a: unknown[]) => unknown).apply(t, args)\n return result instanceof Promise\n ? result.then((data) => augmentPoses(data))\n : result\n }\n }\n if (value !== null && typeof value === \"object\") {\n return wrapWithPoseAugmentation(value)\n }\n return value\n },\n }) as T\n}\n\n/**\n * Wraps a `Nova` instance so every `nova.api.*` call automatically upgrades\n * `Pose`-shaped fields in its response to `Pose` instances (with math\n * methods like `multiply`/`inverse`). Returns a new proxied view - does not\n * mutate the original `nova` instance.\n */\nexport function augmentMath(nova: Nova): NovaWithMath {\n const proxy = new Proxy(nova, {\n get(target, prop) {\n if (prop === \"api\") {\n return wrapWithPoseAugmentation(target.api)\n }\n // receiver is `target`, not the proxy, so `this` inside Nova's own\n // methods stays bound to the original (un-proxied) instance.\n return Reflect.get(target, prop, target)\n },\n })\n\n return proxy as unknown as NovaWithMath\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wandelbots/nova-js",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.3.0-pr.318.6592c26",
|
|
5
5
|
"description": "Official JS client for the Wandelbots API",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"./experimental/nats": {
|
|
21
21
|
"types": "./dist/experimental/nats/index.d.mts",
|
|
22
22
|
"default": "./dist/experimental/nats/index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./experimental/math": {
|
|
25
|
+
"types": "./dist/experimental/math/index.d.mts",
|
|
26
|
+
"default": "./dist/experimental/math/index.mjs"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"main": "./dist/index.mjs",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experimental pose math helpers (`Pose`, `augmentMath`) for working with
|
|
3
|
+
* position + axis-angle orientation values from the NOVA API.
|
|
4
|
+
*
|
|
5
|
+
* This API is experimental and may change without a major version bump.
|
|
6
|
+
*/
|
|
7
|
+
export type { Pose as PoseData } from "@wandelbots/nova-api/v2"
|
|
8
|
+
export {
|
|
9
|
+
augmentMath,
|
|
10
|
+
augmentPoses,
|
|
11
|
+
} from "../../lib/experimental/math/augmentMath.ts"
|
|
12
|
+
export type {
|
|
13
|
+
DeepPoseAugmented,
|
|
14
|
+
NovaWithMath,
|
|
15
|
+
} from "../../lib/experimental/math/augmentMath.ts"
|
|
16
|
+
export { Pose } from "../../lib/experimental/math/Pose.ts"
|
|
17
|
+
export {
|
|
18
|
+
axisAngleToQuaternion,
|
|
19
|
+
conjugateQuaternion,
|
|
20
|
+
multiplyQuaternions,
|
|
21
|
+
quaternionToAxisAngle,
|
|
22
|
+
rotateVectorByQuaternion,
|
|
23
|
+
} from "../../lib/experimental/math/Quaternion.ts"
|
|
24
|
+
export type { Quaternion } from "../../lib/experimental/math/Quaternion.ts"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Pose as PoseData } from "@wandelbots/nova-api/v2"
|
|
2
|
+
import {
|
|
3
|
+
axisAngleToQuaternion,
|
|
4
|
+
conjugateQuaternion,
|
|
5
|
+
multiplyQuaternions,
|
|
6
|
+
quaternionToAxisAngle,
|
|
7
|
+
rotateVectorByQuaternion,
|
|
8
|
+
} from "./Quaternion.ts"
|
|
9
|
+
|
|
10
|
+
const ZERO_VECTOR = [0, 0, 0]
|
|
11
|
+
|
|
12
|
+
function addVectors(a: number[], b: number[]): number[] {
|
|
13
|
+
return [
|
|
14
|
+
(a[0] ?? 0) + (b[0] ?? 0),
|
|
15
|
+
(a[1] ?? 0) + (b[1] ?? 0),
|
|
16
|
+
(a[2] ?? 0) + (b[2] ?? 0),
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function negateVector(a: number[]): number[] {
|
|
21
|
+
return [-(a[0] ?? 0), -(a[1] ?? 0), -(a[2] ?? 0)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A `Pose` (position + axis-angle orientation) with methods for composing
|
|
26
|
+
* and inverting transforms. Instances are plain-data compatible with the
|
|
27
|
+
* wire-format `PoseData` type (own `position`/`orientation` properties only,
|
|
28
|
+
* no enumerable methods), so they can be passed directly back into API calls
|
|
29
|
+
* that expect a pose.
|
|
30
|
+
*
|
|
31
|
+
* Poses returned from `NovaAPIClient`/`Nova` are automatically upgraded to
|
|
32
|
+
* `Pose` instances; construct one directly only when you have a pose from
|
|
33
|
+
* elsewhere (e.g. a websocket message).
|
|
34
|
+
*/
|
|
35
|
+
export class Pose implements PoseData {
|
|
36
|
+
readonly position: number[]
|
|
37
|
+
readonly orientation: number[]
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
position: number[] = ZERO_VECTOR,
|
|
41
|
+
orientation: number[] = ZERO_VECTOR,
|
|
42
|
+
) {
|
|
43
|
+
this.position = position
|
|
44
|
+
this.orientation = orientation
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static from(pose: PoseData): Pose {
|
|
48
|
+
if (pose instanceof Pose) {
|
|
49
|
+
return pose
|
|
50
|
+
}
|
|
51
|
+
return new Pose(
|
|
52
|
+
pose.position ?? ZERO_VECTOR,
|
|
53
|
+
pose.orientation ?? ZERO_VECTOR,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static identity(): Pose {
|
|
58
|
+
return new Pose()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compose this pose with `other`, treating `other` as being expressed in
|
|
63
|
+
* this pose's coordinate frame. Equivalent to the homogeneous transform
|
|
64
|
+
* product `this * other`: applying the result to a point is the same as
|
|
65
|
+
* applying `other` first, then `this`.
|
|
66
|
+
*/
|
|
67
|
+
multiply(other: PoseData): Pose {
|
|
68
|
+
const q1 = axisAngleToQuaternion(this.orientation)
|
|
69
|
+
const q2 = axisAngleToQuaternion(other.orientation ?? ZERO_VECTOR)
|
|
70
|
+
|
|
71
|
+
const position = addVectors(
|
|
72
|
+
this.position,
|
|
73
|
+
rotateVectorByQuaternion(q1, other.position ?? ZERO_VECTOR),
|
|
74
|
+
)
|
|
75
|
+
const orientation = quaternionToAxisAngle(multiplyQuaternions(q1, q2))
|
|
76
|
+
|
|
77
|
+
return new Pose(position, orientation)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The inverse transform, such that `pose.multiply(pose.inverse())` is the identity pose. */
|
|
81
|
+
inverse(): Pose {
|
|
82
|
+
const q = axisAngleToQuaternion(this.orientation)
|
|
83
|
+
const qInverse = conjugateQuaternion(q)
|
|
84
|
+
|
|
85
|
+
const position = rotateVectorByQuaternion(
|
|
86
|
+
qInverse,
|
|
87
|
+
negateVector(this.position),
|
|
88
|
+
)
|
|
89
|
+
// Negating an axis-angle vector gives the inverse rotation directly.
|
|
90
|
+
const orientation = negateVector(this.orientation)
|
|
91
|
+
|
|
92
|
+
return new Pose(position, orientation)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Apply this pose's transform to a point, returning the transformed point. */
|
|
96
|
+
transformPoint(point: number[]): number[] {
|
|
97
|
+
const q = axisAngleToQuaternion(this.orientation)
|
|
98
|
+
return addVectors(this.position, rotateVectorByQuaternion(q, point))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Compare to another pose within a tolerance, since floating-point pose math rarely produces exact equality. */
|
|
102
|
+
equals(other: PoseData, epsilon = 1e-9): boolean {
|
|
103
|
+
const otherPosition = other.position ?? ZERO_VECTOR
|
|
104
|
+
const otherOrientation = other.orientation ?? ZERO_VECTOR
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
this.position.every(
|
|
108
|
+
(v, i) => Math.abs(v - (otherPosition[i] ?? 0)) <= epsilon,
|
|
109
|
+
) &&
|
|
110
|
+
this.orientation.every(
|
|
111
|
+
(v, i) => Math.abs(v - (otherOrientation[i] ?? 0)) <= epsilon,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
toJSON(): PoseData {
|
|
117
|
+
return { position: this.position, orientation: this.orientation }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal quaternion helpers used to compose/invert the axis-angle
|
|
3
|
+
* `orientation` vectors used by the NOVA API's `Pose` type. Rotation vectors
|
|
4
|
+
* are converted to quaternions for composition (numerically stable, unlike
|
|
5
|
+
* composing axis-angle vectors directly) and converted back at the boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Quaternion = { w: number; x: number; y: number; z: number }
|
|
9
|
+
|
|
10
|
+
const EPSILON = 1e-12
|
|
11
|
+
|
|
12
|
+
/** Convert an axis-angle rotation vector [rx, ry, rz] to a unit quaternion. */
|
|
13
|
+
export function axisAngleToQuaternion(r: number[]): Quaternion {
|
|
14
|
+
const rx = r[0] ?? 0
|
|
15
|
+
const ry = r[1] ?? 0
|
|
16
|
+
const rz = r[2] ?? 0
|
|
17
|
+
|
|
18
|
+
const angle = Math.sqrt(rx * rx + ry * ry + rz * rz)
|
|
19
|
+
if (angle < EPSILON) {
|
|
20
|
+
return { w: 1, x: 0, y: 0, z: 0 }
|
|
21
|
+
}
|
|
22
|
+
const half = angle / 2
|
|
23
|
+
const s = Math.sin(half) / angle
|
|
24
|
+
return { w: Math.cos(half), x: rx * s, y: ry * s, z: rz * s }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a unit quaternion back to an axis-angle rotation vector [rx, ry, rz],
|
|
29
|
+
* matching wb-robotix's `Quaternion::toRotationVector()` (ported from
|
|
30
|
+
* QuaternionBasePlugin.h): using `atan2` rather than `acos` keeps the angle
|
|
31
|
+
* canonically within [0, pi] regardless of which of the two antipodal unit
|
|
32
|
+
* quaternions (q or -q) represents the rotation, rather than acos's [0, 2*pi]
|
|
33
|
+
* range - important since composed poses need to match the same canonical
|
|
34
|
+
* rotation vector the robot controller itself would report for a pose.
|
|
35
|
+
*/
|
|
36
|
+
export function quaternionToAxisAngle(q: Quaternion): number[] {
|
|
37
|
+
const vecNorm = Math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z)
|
|
38
|
+
if (vecNorm < EPSILON) {
|
|
39
|
+
return [0, 0, 0]
|
|
40
|
+
}
|
|
41
|
+
const angle = 2 * Math.atan2(vecNorm, Math.abs(q.w))
|
|
42
|
+
const scale = (q.w >= 0 ? angle : -angle) / vecNorm
|
|
43
|
+
return [q.x * scale, q.y * scale, q.z * scale]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Hamilton product: composes rotations so that applying `multiplyQuaternions(a, b)` to a vector equals applying `b` then `a`. */
|
|
47
|
+
export function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion {
|
|
48
|
+
return {
|
|
49
|
+
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
|
|
50
|
+
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
|
|
51
|
+
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
|
|
52
|
+
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Conjugate of a unit quaternion equals its inverse. */
|
|
57
|
+
export function conjugateQuaternion(q: Quaternion): Quaternion {
|
|
58
|
+
return { w: q.w, x: -q.x, y: -q.y, z: -q.z }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Rotate a 3D vector by a unit quaternion. */
|
|
62
|
+
export function rotateVectorByQuaternion(q: Quaternion, v: number[]): number[] {
|
|
63
|
+
const vx = v[0] ?? 0
|
|
64
|
+
const vy = v[1] ?? 0
|
|
65
|
+
const vz = v[2] ?? 0
|
|
66
|
+
const { w, x, y, z } = q
|
|
67
|
+
|
|
68
|
+
// t = 2 * cross(q.xyz, v)
|
|
69
|
+
const tx = 2 * (y * vz - z * vy)
|
|
70
|
+
const ty = 2 * (z * vx - x * vz)
|
|
71
|
+
const tz = 2 * (x * vy - y * vx)
|
|
72
|
+
|
|
73
|
+
// v' = v + w*t + cross(q.xyz, t)
|
|
74
|
+
return [
|
|
75
|
+
vx + w * tx + (y * tz - z * ty),
|
|
76
|
+
vy + w * ty + (z * tx - x * tz),
|
|
77
|
+
vz + w * tz + (x * ty - y * tx),
|
|
78
|
+
]
|
|
79
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Pose as PoseData } from "@wandelbots/nova-api/v2"
|
|
2
|
+
import type { Nova } from "../../Nova.ts"
|
|
3
|
+
import { Pose } from "./Pose.ts"
|
|
4
|
+
|
|
5
|
+
type PoseKeys = keyof PoseData
|
|
6
|
+
|
|
7
|
+
type IsExactlyPoseData<T> = [Exclude<keyof T, PoseKeys>] extends [never]
|
|
8
|
+
? [Exclude<PoseKeys, keyof T>] extends [never]
|
|
9
|
+
? true
|
|
10
|
+
: false
|
|
11
|
+
: false
|
|
12
|
+
|
|
13
|
+
// Depth cap avoids "type instantiation is excessively deep" on nova-api's
|
|
14
|
+
// large (and possibly cyclic, e.g. compound colliders) generated type graph.
|
|
15
|
+
type MaxDepth = [
|
|
16
|
+
unknown,
|
|
17
|
+
unknown,
|
|
18
|
+
unknown,
|
|
19
|
+
unknown,
|
|
20
|
+
unknown,
|
|
21
|
+
unknown,
|
|
22
|
+
unknown,
|
|
23
|
+
unknown,
|
|
24
|
+
unknown,
|
|
25
|
+
unknown,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
type AugmentPosesCore<
|
|
29
|
+
T,
|
|
30
|
+
Depth extends readonly unknown[],
|
|
31
|
+
> = Depth["length"] extends MaxDepth["length"]
|
|
32
|
+
? T
|
|
33
|
+
: IsExactlyPoseData<T> extends true
|
|
34
|
+
? Pose
|
|
35
|
+
: T extends readonly (infer U)[]
|
|
36
|
+
? DeepPoseAugmented<U, [...Depth, unknown]>[]
|
|
37
|
+
: T extends object
|
|
38
|
+
? { [K in keyof T]: DeepPoseAugmented<T[K], [...Depth, unknown]> }
|
|
39
|
+
: T
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Type-level counterpart to `augmentPoses`: recursively replaces any
|
|
43
|
+
* `PoseData`-shaped field with `Pose` (distributing over unions, e.g. so
|
|
44
|
+
* optional `Pose | undefined` fields keep the `| undefined`), so the static
|
|
45
|
+
* type matches what's actually returned at runtime.
|
|
46
|
+
*/
|
|
47
|
+
export type DeepPoseAugmented<
|
|
48
|
+
T,
|
|
49
|
+
Depth extends readonly unknown[] = [],
|
|
50
|
+
> = T extends unknown ? AugmentPosesCore<T, Depth> : never
|
|
51
|
+
|
|
52
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
!Array.isArray(value) &&
|
|
57
|
+
!(value instanceof Pose)
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isNumberTriple(value: unknown): value is number[] {
|
|
62
|
+
return (
|
|
63
|
+
Array.isArray(value) &&
|
|
64
|
+
value.length === 3 &&
|
|
65
|
+
value.every((n) => typeof n === "number")
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Structural check for the wire shape of `Pose` (`{ position, orientation }`,
|
|
71
|
+
* each a 3-number array, and nothing else). This API has no other type with
|
|
72
|
+
* this exact shape, so it reliably identifies `Pose` values without needing
|
|
73
|
+
* per-endpoint knowledge of which fields are poses.
|
|
74
|
+
*/
|
|
75
|
+
function isPoseShape(value: Record<string, unknown>): boolean {
|
|
76
|
+
return (
|
|
77
|
+
Object.keys(value).length === 2 &&
|
|
78
|
+
isNumberTriple(value.position) &&
|
|
79
|
+
isNumberTriple(value.orientation)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function augmentPosesInPlace(value: unknown): void {
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
for (const item of value) {
|
|
86
|
+
augmentPosesInPlace(item)
|
|
87
|
+
}
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isPlainObject(value)) {
|
|
92
|
+
if (isPoseShape(value)) {
|
|
93
|
+
Object.setPrototypeOf(value, Pose.prototype)
|
|
94
|
+
} else {
|
|
95
|
+
for (const key of Object.keys(value)) {
|
|
96
|
+
augmentPosesInPlace(value[key])
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Recursively walks a parsed API response/request body, upgrading any
|
|
104
|
+
* `Pose`-shaped objects in place to `Pose` instances so they gain math
|
|
105
|
+
* methods (`multiply`, `inverse`, ...) while remaining JSON/wire-compatible.
|
|
106
|
+
* Used by `augmentMath` for `nova.api.*` responses; exported so it can also
|
|
107
|
+
* be applied manually to e.g. websocket messages.
|
|
108
|
+
*/
|
|
109
|
+
export function augmentPoses<T>(value: T): DeepPoseAugmented<T> {
|
|
110
|
+
augmentPosesInPlace(value)
|
|
111
|
+
return value as DeepPoseAugmented<T>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type WithAugmentedPosesCore<
|
|
115
|
+
T,
|
|
116
|
+
Depth extends readonly unknown[],
|
|
117
|
+
> = Depth["length"] extends MaxDepth["length"]
|
|
118
|
+
? T
|
|
119
|
+
: T extends (...args: infer A) => Promise<infer R>
|
|
120
|
+
? (...args: A) => Promise<DeepPoseAugmented<R>>
|
|
121
|
+
: T extends object
|
|
122
|
+
? { [K in keyof T]: WithAugmentedPosesCore<T[K], [...Depth, unknown]> }
|
|
123
|
+
: T
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Recursively maps every method on `Nova["api"]` (and its nested API groups)
|
|
127
|
+
* so calls are typed as returning `DeepPoseAugmented` results, matching what
|
|
128
|
+
* `augmentMath` does at runtime.
|
|
129
|
+
*/
|
|
130
|
+
type WithAugmentedPoses<T> = WithAugmentedPosesCore<T, []>
|
|
131
|
+
|
|
132
|
+
export type NovaWithMath = Omit<Nova, "api"> & {
|
|
133
|
+
readonly api: WithAugmentedPoses<Nova["api"]>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function wrapWithPoseAugmentation<T extends object>(target: T): T {
|
|
137
|
+
return new Proxy(target, {
|
|
138
|
+
get(t, prop) {
|
|
139
|
+
const value = Reflect.get(t, prop, t)
|
|
140
|
+
if (typeof value === "function") {
|
|
141
|
+
return (...args: unknown[]) => {
|
|
142
|
+
const result = (value as (...a: unknown[]) => unknown).apply(t, args)
|
|
143
|
+
return result instanceof Promise
|
|
144
|
+
? result.then((data) => augmentPoses(data))
|
|
145
|
+
: result
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (value !== null && typeof value === "object") {
|
|
149
|
+
return wrapWithPoseAugmentation(value)
|
|
150
|
+
}
|
|
151
|
+
return value
|
|
152
|
+
},
|
|
153
|
+
}) as T
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wraps a `Nova` instance so every `nova.api.*` call automatically upgrades
|
|
158
|
+
* `Pose`-shaped fields in its response to `Pose` instances (with math
|
|
159
|
+
* methods like `multiply`/`inverse`). Returns a new proxied view - does not
|
|
160
|
+
* mutate the original `nova` instance.
|
|
161
|
+
*/
|
|
162
|
+
export function augmentMath(nova: Nova): NovaWithMath {
|
|
163
|
+
const proxy = new Proxy(nova, {
|
|
164
|
+
get(target, prop) {
|
|
165
|
+
if (prop === "api") {
|
|
166
|
+
return wrapWithPoseAugmentation(target.api)
|
|
167
|
+
}
|
|
168
|
+
// receiver is `target`, not the proxy, so `this` inside Nova's own
|
|
169
|
+
// methods stays bound to the original (un-proxied) instance.
|
|
170
|
+
return Reflect.get(target, prop, target)
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return proxy as unknown as NovaWithMath
|
|
175
|
+
}
|