frenet-serret-frames 3.1.1 → 4.0.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ # [4.0.0](https://github.com/dmnsgn/frenet-serret-frames/compare/v3.1.1...v4.0.0) (2026-09-29)
6
+
7
+ ### Features
8
+
9
+ * compute rotation minimizing frames with the double reflection method ([8b0a12b](https://github.com/dmnsgn/frenet-serret-frames/commit/8b0a12b7b89370310724d0fa65c91d344dae0c4e))
10
+
11
+ ### BREAKING CHANGES
12
+
13
+ * normals and binormals are computed with the double reflection method from chordal tangents, and closed paths spread the closing twist by arc length.
14
+
5
15
  ## [3.1.1](https://github.com/dmnsgn/frenet-serret-frames/compare/v3.1.0...v3.1.1) (2024-07-07)
6
16
 
7
17
 
package/README.md CHANGED
@@ -10,13 +10,13 @@
10
10
  [![linted with eslint](https://img.shields.io/badge/linted_with-ES_Lint-4B32C3.svg?logo=eslint)](https://github.com/eslint/eslint)
11
11
  [![license](https://img.shields.io/github/license/dmnsgn/frenet-serret-frames)](https://github.com/dmnsgn/frenet-serret-frames/blob/main/LICENSE.md)
12
12
 
13
- Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.
13
+ Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D positions.
14
14
 
15
15
  [![paypal](https://img.shields.io/badge/donate-paypal-informational?logo=paypal)](https://paypal.me/dmnsgn)
16
16
  [![coinbase](https://img.shields.io/badge/donate-coinbase-informational?logo=coinbase)](https://commerce.coinbase.com/checkout/56cbdf28-e323-48d8-9c98-7019e72c97f3)
17
17
  [![twitter](https://img.shields.io/twitter/follow/dmnsgn?style=social)](https://twitter.com/dmnsgn)
18
18
 
19
- ![](https://raw.githubusercontent.com/dmnsgn/frenet-serret-frames/main/screenshot.gif)
19
+ [![frenet-serret-frames screenshot](https://raw.githubusercontent.com/dmnsgn/frenet-serret-frames/main/screenshot.gif)](https://dmnsgn.github.io/frenet-serret-frames/)
20
20
 
21
21
  ## Installation
22
22
 
@@ -24,23 +24,36 @@ Compute Frenet-Serret frames for a geometry of 3D positions and optionally provi
24
24
  npm install frenet-serret-frames
25
25
  ```
26
26
 
27
+ ## Features
28
+
29
+ - **Bishop frames** (default): rotation minimizing frames, also known as parallel transport frames, computed with the [double reflection method](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf). Minimal twist around the path, suited for sweeping tubes and ribbons.
30
+ - **Frenet frames**: normal pointing towards the centre of curvature. Flips at inflection points; straight parts take the nearest frame.
31
+ - **Seamless closed paths**: the mismatch at the seam is spread along the arc length.
32
+ - **Constraints**: `initialNormal`, `finalNormal` on open paths, and an additional `twist` spread along the arc length.
33
+ - **Any layout**: flat or nested positions, normals and binormals out in the same layout, written into provided arrays.
34
+ - **Robust**: duplicated points, straight lines and reversed tangents handled without flips.
35
+
27
36
  ## Usage
28
37
 
29
38
  ```js
30
39
  import frenetSerretFrames from "frenet-serret-frames";
40
+ import { squirclePath } from "primitive-geometry";
31
41
 
32
- const geometry = { positions, cells };
42
+ const geometry = squirclePath();
33
43
  frenetSerretFrames(geometry, {
34
- closed: true,
35
- initialNormal: [0, 1, 0],
44
+ closed: false,
45
+ mode: "bishop",
46
+ initialNormal: null,
47
+ finalNormal: null,
48
+ twist: 0,
36
49
  });
37
50
  console.log(geometry);
38
51
  // {
39
- // positions: Float32Array [x, y, z, x, y, z, ...],
40
- // tangents: Float32Array [x, y, z, x, y, z, ...]
41
- // normals: Float32Array [x, y, z, x, y, z, ...]
42
- // binormals: Float32Array [x, y, z, x, y, z, ...]
43
- // cells: Uint8/16/32/Array [a, b, c, a, b, c, ...],
52
+ // positions: Float32Array [x, y, z, x, y, z, ...],
53
+ // cells: [[a, b, c, ...]],
54
+ // tangents: Float32Array [x, y, z, x, y, z, ...],
55
+ // normals: Float32Array [x, y, z, x, y, z, ...],
56
+ // binormals: Float32Array [x, y, z, x, y, z, ...],
44
57
  // }
45
58
  ```
46
59
 
@@ -52,22 +65,20 @@ console.log(geometry);
52
65
 
53
66
  <dl>
54
67
  <dt><a href="#frenetSerretFrames">frenetSerretFrames(geometry, [options])</a> ⇒ <code><a href="#SimplicialComplexWithTNB">SimplicialComplexWithTNB</a></code></dt>
55
- <dd><p>Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.</p>
68
+ <dd><p>Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D
69
+ positions.</p>
56
70
  </dd>
57
71
  </dl>
58
72
 
59
73
  ## Typedefs
60
74
 
61
75
  <dl>
62
- <dt><a href="#vec2">vec2</a> : <code>Array.&lt;number&gt;</code></dt>
63
- <dd></dd>
64
- <dt><a href="#vec3">vec3</a> : <code>Array.&lt;number&gt;</code></dt>
65
- <dd></dd>
66
76
  <dt><a href="#SimplicialComplex">SimplicialComplex</a> : <code>object</code></dt>
67
77
  <dd><p>Geometry definition.</p>
68
78
  </dd>
69
79
  <dt><a href="#SimplicialComplexWithTNB">SimplicialComplexWithTNB</a> : <code>object</code></dt>
70
- <dd><p>Geometry definition augmented with tangents, normals and binormals.</p>
80
+ <dd><p>Geometry definition augmented with
81
+ tangents, normals and binormals.</p>
71
82
  </dd>
72
83
  <dt><a href="#Options">Options</a> : <code>object</code></dt>
73
84
  <dd><p>Options for frames computation. All optional.</p>
@@ -78,26 +89,20 @@ console.log(geometry);
78
89
 
79
90
  ## frenetSerretFrames(geometry, [options]) ⇒ [<code>SimplicialComplexWithTNB</code>](#SimplicialComplexWithTNB)
80
91
 
81
- Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.
92
+ Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D
93
+ positions.
82
94
 
83
95
  **Kind**: global function
84
- **See**: [Frenet–Serret formulas](https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas)
96
+ **See**
97
+
98
+ - [Frenet–Serret formulas](https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas)
99
+ - [Computation of Rotation Minimizing Frames (Wang et al. 2008)](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf)
85
100
 
86
101
  | Param | Type | Default |
87
102
  | --------- | ---------------------------------------------------- | --------------- |
88
103
  | geometry | [<code>SimplicialComplex</code>](#SimplicialComplex) | |
89
104
  | [options] | [<code>Options</code>](#Options) | <code>{}</code> |
90
105
 
91
- <a name="vec2"></a>
92
-
93
- ## vec2 : <code>Array.&lt;number&gt;</code>
94
-
95
- **Kind**: global typedef
96
- <a name="vec3"></a>
97
-
98
- ## vec3 : <code>Array.&lt;number&gt;</code>
99
-
100
- **Kind**: global typedef
101
106
  <a name="SimplicialComplex"></a>
102
107
 
103
108
  ## SimplicialComplex : <code>object</code>
@@ -107,31 +112,33 @@ Geometry definition.
107
112
  **Kind**: global typedef
108
113
  **Properties**
109
114
 
110
- | Name | Type |
111
- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
112
- | positions | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
113
- | [tangents] | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
114
- | [normals] | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
115
- | [uvs] | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec2&gt;</code>](#vec2) |
116
- | [cells] | <code>Uint8Array</code> \| <code>Uint16Array</code> \| <code>Uint32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
115
+ | Name | Type |
116
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
+ | positions | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
118
+ | [tangents] | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
119
+ | [normals] | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
120
+ | [binormals] | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
121
+ | [uvs] | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec2&gt;</code> |
122
+ | [cells] | <code>Uint8Array</code> \| <code>Uint16Array</code> \| <code>Uint32Array</code> \| <code>Array</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
117
123
 
118
124
  <a name="SimplicialComplexWithTNB"></a>
119
125
 
120
126
  ## SimplicialComplexWithTNB : <code>object</code>
121
127
 
122
- Geometry definition augmented with tangents, normals and binormals.
128
+ Geometry definition augmented with
129
+ tangents, normals and binormals.
123
130
 
124
131
  **Kind**: global typedef
125
132
  **Properties**
126
133
 
127
- | Name | Type |
128
- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
129
- | positions | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
130
- | tangents | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
131
- | normals | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
132
- | binormals | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
133
- | [uvs] | <code>Float32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec2&gt;</code>](#vec2) |
134
- | [cells] | <code>Uint8Array</code> \| <code>Uint16Array</code> \| <code>Uint32Array</code> \| <code>Array</code> \| [<code>Array.&lt;vec3&gt;</code>](#vec3) |
134
+ | Name | Type |
135
+ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
+ | positions | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
137
+ | tangents | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
138
+ | normals | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
139
+ | binormals | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
140
+ | [uvs] | <code>module:pex-math~TypedArray</code> \| <code>Array.&lt;number&gt;</code> \| <code>Array.&lt;module:pex-math~Vec2&gt;</code> |
141
+ | [cells] | <code>Uint8Array</code> \| <code>Uint16Array</code> \| <code>Uint32Array</code> \| <code>Array</code> \| <code>Array.&lt;module:pex-math~Vec3&gt;</code> |
135
142
 
136
143
  <a name="Options"></a>
137
144
 
@@ -142,10 +149,13 @@ Options for frames computation. All optional.
142
149
  **Kind**: global typedef
143
150
  **Properties**
144
151
 
145
- | Name | Type | Default | Description |
146
- | --------------- | -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------- |
147
- | [closed] | <code>boolean</code> | <code>false</code> | Specify is the path is closed. |
148
- | [initialNormal] | [<code>vec3</code>](#vec3) | <code></code> | Specify a starting normal for the frames. Default to the direction of the minimum tangent component. |
152
+ | Name | Type | Default | Description |
153
+ | --------------- | ------------------------------------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
154
+ | [closed] | <code>boolean</code> | <code>false</code> | Specify if the path is closed. |
155
+ | [mode] | <code>&quot;bishop&quot;</code> \| <code>&quot;frenet&quot;</code> | <code>&quot;bishop&quot;</code> | Frame type: - "bishop": rotation minimizing (parallel transport) frames, computed with the double reflection method. Twist free, suited for sweeping. - "frenet": normal pointing towards the curvature centre. Flips at inflection points and is undefined on straight parts, where the nearest frame is transported instead. |
156
+ | [initialNormal] | <code>module:pex-math~Vec3</code> | <code></code> | Specify a starting normal for "bishop" frames, made orthogonal to the first tangent. Defaults to a coordinate axis not aligned with the first tangent, also used when the initial normal is parallel to it. |
157
+ | [finalNormal] | <code>module:pex-math~Vec3</code> | <code></code> | Specify an ending normal for "bishop" frames of open paths, made orthogonal to the last tangent. Reached with the smallest rotation, spread along the arc length. Ignored when parallel to the last tangent. |
158
+ | [twist] | <code>number</code> | <code>0</code> | Additional rotation of "bishop" frames around the tangent, in radians, spread along the arc length from the first frame to the last. Closed paths and paths with a final normal need a multiple of 2π to keep their end frame, or of π when the sign of the normal doesn't matter (eg. flat ribbons). |
149
159
 
150
160
  <!-- api-end -->
151
161
 
package/index.js CHANGED
@@ -1,145 +1,407 @@
1
- import { avec3, vec3, quat, utils } from "pex-math";
2
- import computePathTangents from "path-tangents";
1
+ import { avec3 } from "pex-math";
2
+ import computePathTangents, {
3
+ computeNeighbours,
4
+ setDelta,
5
+ } from "path-tangents";
3
6
 
4
- const X_UP = [1, 0, 0];
5
- const Y_UP = [0, 1, 0];
6
- const Z_UP = [0, 0, 1];
7
+ const X_AXIS = [1, 0, 0];
8
+ const Y_AXIS = [0, 1, 0];
9
+ const Z_AXIS = [0, 0, 1];
7
10
 
8
- /**
9
- * Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.
10
- *
11
- * @param {import("./types.js").SimplicialComplex} geometry
12
- * @param {import("./types.js").Options} [options={}]
13
- * @returns {import("./types.js").SimplicialComplexWithTNB}
14
- *
15
- * @see [Frenet–Serret formulas]{@link https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas}
16
- */
17
- function frenetSerretFrames(geometry, options) {
18
- const isFlatArray = !geometry.positions[0]?.length;
11
+ // v₂ = tᵢ₊₁ − R₁tᵢ has |v₂|² ≈ 4 on sampled curves and vanishes where R₂ is
12
+ // undefined (Wang et al. 2008, §4.4): its direction, hence the frame, is then
13
+ // set by rounding errors. Accuracy is unchanged from 1e-12 to 1e-2.
14
+ const DEGENERATE_V2_SQ = 1e-6;
19
15
 
20
- // Extends options
21
- const { closed = false, initialNormal = null } = { ...options };
16
+ // Turns below this many rounding errors of the positions are noise.
17
+ const FRENET_NOISE = 4;
18
+ const FLOAT32_EPSILON = 2 ** -23;
22
19
 
23
- const size = geometry.positions.length / (isFlatArray ? 3 : 1);
24
- geometry.tangents ||= computePathTangents(geometry.positions, closed);
25
- geometry.normals ||= isFlatArray ? new Float32Array(size * 3) : [];
26
- geometry.binormals ||= isFlatArray ? new Float32Array(size * 3) : [];
20
+ // Temporary vectors reused to avoid allocations.
21
+ // v₁, v₂ and tᴸ follow Wang et al. 2008.
22
+ const TEMP_AVEC3 = new Float64Array(5 * 3);
23
+ const [V1, V2, TANGENT_L, END_NORMAL, CROSS] = [0, 1, 2, 3, 4];
27
24
 
28
- let v = vec3.create();
25
+ function toFlatArray(array) {
26
+ if (!array[0]?.length) return array;
27
+ const flat = new Float64Array(array.length * 3);
28
+ array.forEach((v, i) => avec3.set(flat, i, v, 0));
29
+ return flat;
30
+ }
29
31
 
30
- // Compute initial frame
31
- let tangent = isFlatArray
32
- ? geometry.tangents.slice(0, 3)
33
- : geometry.tangents[0];
34
- let normal;
32
+ function fromFlatArray(flat, size, ArrayConstructor, out) {
33
+ const isFlatArray = ArrayConstructor !== Array;
34
+ out ||= new ArrayConstructor(isFlatArray ? size * 3 : size);
35
35
 
36
- if (initialNormal) {
37
- normal = [...initialNormal];
38
- } else {
39
- const atx = Math.abs(tangent[0]);
40
- const aty = Math.abs(tangent[1]);
41
- const atz = Math.abs(tangent[2]);
42
-
43
- if (aty > atx && aty >= atz) {
44
- v = vec3.cross([...tangent], X_UP);
45
- } else if (atz > atx && atz >= aty) {
46
- v = vec3.cross([...tangent], Y_UP);
36
+ for (let i = 0; i < size; i++) {
37
+ if (isFlatArray) {
38
+ avec3.set(out, i, flat, i);
47
39
  } else {
48
- v = vec3.cross([...tangent], Z_UP);
40
+ avec3.set((out[i] ||= [0, 0, 0]), 0, flat, i);
49
41
  }
50
-
51
- normal = vec3.normalize(vec3.cross([...tangent], v));
52
42
  }
43
+ return out;
44
+ }
53
45
 
54
- let binormal = vec3.normalize(vec3.cross([...tangent], normal));
46
+ // Remove the component of out[i] along the unit axes[j], leaving the part
47
+ // orthogonal to it.
48
+ function reject(out, i, axes, j) {
49
+ avec3.addScaled(out, i, axes, j, -avec3.dot(out, i, axes, j));
50
+ }
55
51
 
56
- if (isFlatArray) {
57
- geometry.normals.set(normal);
58
- geometry.binormals.set(binormal);
52
+ // Mirror a[i] across the plane orthogonal to v[j], which needs no normalizing.
53
+ function reflect(a, i, v, j) {
54
+ avec3.addScaled(
55
+ a,
56
+ i,
57
+ v,
58
+ j,
59
+ (-2 * avec3.dot(v, j, a, i)) / avec3.lengthSq(v, j),
60
+ );
61
+ }
62
+
63
+ // Any axis but the one most aligned with the tangent: at least 45° away.
64
+ function getReferenceAxis(tangents) {
65
+ const x = Math.abs(tangents[0]);
66
+ const y = Math.abs(tangents[1]);
67
+ const z = Math.abs(tangents[2]);
68
+
69
+ if (y > x && y >= z) return X_AXIS;
70
+ return z > x && z >= y ? Y_AXIS : Z_AXIS;
71
+ }
72
+
73
+ function setInitialNormal(normals, tangents, initialNormal) {
74
+ if (initialNormal) {
75
+ avec3.set(normals, 0, initialNormal, 0);
76
+ avec3.normalize(normals, 0);
77
+ reject(normals, 0, tangents, 0);
59
78
  } else {
60
- geometry.normals.push(normal);
61
- geometry.binormals.push(binormal);
79
+ // Clear leftovers of undefined Frenet normals
80
+ normals.fill(0, 0, 3);
62
81
  }
63
82
 
64
- // Compute the rest of the frames
65
- const rotation = quat.create();
66
- let previousTangent = vec3.create();
83
+ // Missing, zero or parallel to the tangent: t × (t × axis) = (t·axis)t − axis
84
+ if (avec3.lengthSq(normals, 0) <= Number.EPSILON) {
85
+ const axis = getReferenceAxis(tangents);
86
+ avec3.set(normals, 0, tangents, 0);
87
+ avec3.scale(normals, 0, avec3.dot(tangents, 0, axis, 0));
88
+ avec3.sub(normals, 0, axis, 0);
89
+ }
67
90
 
68
- for (let i = 1; i < size; i++) {
69
- if (isFlatArray) {
70
- avec3.set(previousTangent, 0, geometry.tangents, i - 1);
71
- avec3.set(tangent, 0, geometry.tangents, i);
72
- } else {
73
- previousTangent = geometry.tangents[i - 1];
74
- tangent = geometry.tangents[i];
75
- }
91
+ avec3.normalize(normals, 0);
92
+ }
76
93
 
77
- normal = vec3.copy(normal);
78
- binormal = vec3.copy(binormal);
94
+ // Double reflection of the normal at `from` onto `to` across v₁ then v₂,
95
+ // written at out[i]. Returns false where either reflection is undefined or
96
+ // ill-conditioned.
97
+ function doubleReflect(out, i, normals, tangents, from, to) {
98
+ if (!avec3.lengthSq(TEMP_AVEC3, V1)) return false;
79
99
 
80
- v = vec3.cross([...previousTangent], tangent);
100
+ avec3.set(TEMP_AVEC3, TANGENT_L, tangents, from);
101
+ reflect(TEMP_AVEC3, TANGENT_L, TEMP_AVEC3, V1);
102
+ avec3.set(TEMP_AVEC3, V2, tangents, to);
103
+ avec3.sub(TEMP_AVEC3, V2, TEMP_AVEC3, TANGENT_L);
81
104
 
82
- if (vec3.length(v) > Number.EPSILON) {
83
- vec3.normalize(v);
105
+ if (avec3.lengthSq(TEMP_AVEC3, V2) <= DEGENERATE_V2_SQ) return false;
84
106
 
85
- const theta = Math.acos(
86
- utils.clamp(vec3.dot(previousTangent, tangent), -1, 1),
87
- );
88
- quat.fromAxisAngle(rotation, v, theta);
89
- vec3.multQuat(normal, rotation);
90
- }
91
- binormal = vec3.cross([...tangent], normal);
107
+ avec3.set(out, i, normals, from);
108
+ reflect(out, i, TEMP_AVEC3, V1);
109
+ reflect(out, i, TEMP_AVEC3, V2);
92
110
 
93
- if (isFlatArray) {
94
- avec3.set(geometry.normals, i, normal, 0);
95
- avec3.set(geometry.binormals, i, binormal, 0);
96
- } else {
97
- geometry.normals.push(normal);
98
- geometry.binormals.push(binormal);
111
+ return true;
112
+ }
113
+
114
+ // Transport the normal at `from` onto `to`, written at out[i], with the double
115
+ // reflection method. Where it degenerates, fall back to the rotation method
116
+ // (v₁ set to the tangent at `from`, Wang et al. 2008, §2.1), and keep the
117
+ // normal on reversed tangents where no rotation is minimal.
118
+ function transport(out, i, normals, tangents, positions, from, to) {
119
+ setDelta(TEMP_AVEC3, V1, positions, from, to);
120
+
121
+ if (!doubleReflect(out, i, normals, tangents, from, to)) {
122
+ avec3.set(TEMP_AVEC3, V1, tangents, from);
123
+ if (!doubleReflect(out, i, normals, tangents, from, to)) {
124
+ avec3.set(out, i, normals, from);
99
125
  }
100
126
  }
101
127
 
128
+ avec3.normalize(out, i);
129
+ }
130
+
131
+ function rotateAroundTangent(normals, tangents, i, angle) {
132
+ avec3.set(TEMP_AVEC3, CROSS, tangents, i);
133
+ avec3.cross(TEMP_AVEC3, CROSS, normals, i);
134
+ avec3.scale(normals, i, Math.cos(angle));
135
+ avec3.addScaled(normals, i, TEMP_AVEC3, CROSS, Math.sin(angle));
136
+ avec3.normalize(normals, i);
137
+ }
138
+
139
+ // Angle from a[i] to b[j] around axes[k].
140
+ function signedAngle(a, i, b, j, axes, k) {
141
+ avec3.set(TEMP_AVEC3, CROSS, a, i);
142
+ avec3.cross(TEMP_AVEC3, CROSS, b, j);
143
+
144
+ return Math.atan2(
145
+ avec3.dot(TEMP_AVEC3, CROSS, axes, k),
146
+ avec3.dot(a, i, b, j),
147
+ );
148
+ }
149
+
150
+ // Minimal rotation from the last frame to the end condition (Wang et al. 2008,
151
+ // §6.3): the first frame for closed paths, transported back to the start, or
152
+ // the final normal for open ones.
153
+ function computeEndTwist(
154
+ normals,
155
+ tangents,
156
+ positions,
157
+ size,
158
+ closed,
159
+ finalNormal,
160
+ ) {
161
+ const last = size - 1;
162
+
102
163
  if (closed) {
103
- const firstNormal = isFlatArray
104
- ? geometry.normals.slice(0, 3)
105
- : geometry.normals[0];
106
- const lastNormal = isFlatArray
107
- ? geometry.normals.slice(-3)
108
- : geometry.normals.at(-1);
109
- const firstTangent = isFlatArray
110
- ? geometry.tangents.slice(0, 3)
111
- : geometry.tangents[0];
112
-
113
- let theta = Math.acos(
114
- utils.clamp(vec3.dot(firstNormal, lastNormal), -1, 1),
164
+ transport(TEMP_AVEC3, END_NORMAL, normals, tangents, positions, last, 0);
165
+ return signedAngle(TEMP_AVEC3, END_NORMAL, normals, 0, tangents, 0);
166
+ }
167
+
168
+ if (!finalNormal) return 0;
169
+
170
+ avec3.set(TEMP_AVEC3, END_NORMAL, finalNormal, 0);
171
+ avec3.normalize(TEMP_AVEC3, END_NORMAL);
172
+ reject(TEMP_AVEC3, END_NORMAL, tangents, last);
173
+
174
+ // Zero or parallel to the tangent: no end condition.
175
+ return avec3.lengthSq(TEMP_AVEC3, END_NORMAL) <= Number.EPSILON
176
+ ? 0
177
+ : signedAngle(normals, last, TEMP_AVEC3, END_NORMAL, tangents, last);
178
+ }
179
+
180
+ // Additional rotation proportional to arc length: minimal total squared
181
+ // angular speed (Wang et al. 2008, §6.3), even on uneven sampling.
182
+ function spreadTwist(normals, tangents, positions, size, closed, twist) {
183
+ const lengths = new Float64Array(size);
184
+
185
+ for (let i = 1; i < size; i++) {
186
+ lengths[i] =
187
+ lengths[i - 1] + avec3.distance(positions, i - 1, positions, i);
188
+ }
189
+
190
+ let totalLength = lengths[size - 1];
191
+
192
+ if (closed) totalLength += avec3.distance(positions, size - 1, positions, 0);
193
+ if (totalLength === 0) return;
194
+
195
+ for (let i = 1; i < size; i++) {
196
+ rotateAroundTangent(
197
+ normals,
198
+ tangents,
199
+ i,
200
+ (twist * lengths[i]) / totalLength,
115
201
  );
116
- theta /= size - 1;
202
+ }
203
+ }
117
204
 
118
- if (vec3.dot(firstTangent, vec3.cross([...firstNormal], lastNormal)) > 0) {
119
- theta = -theta;
120
- }
205
+ function computeBishopNormals(
206
+ normals,
207
+ tangents,
208
+ positions,
209
+ size,
210
+ closed,
211
+ initialNormal,
212
+ finalNormal,
213
+ twist,
214
+ ) {
215
+ if (!size) return;
216
+
217
+ setInitialNormal(normals, tangents, initialNormal);
218
+
219
+ for (let i = 1; i < size; i++) {
220
+ transport(normals, i, normals, tangents, positions, i - 1, i);
221
+ }
222
+
223
+ if (size < 2) return;
224
+
225
+ const totalTwist =
226
+ twist +
227
+ computeEndTwist(normals, tangents, positions, size, closed, finalNormal);
228
+
229
+ if (totalTwist) {
230
+ spreadTwist(normals, tangents, positions, size, closed, totalTwist);
231
+ }
232
+ }
233
+
234
+ const maxAbs = (a, i) =>
235
+ Math.max(Math.abs(a[i * 3]), Math.abs(a[i * 3 + 1]), Math.abs(a[i * 3 + 2]));
236
+
237
+ // Discrete curvature direction: how the unit segment directions turn at a
238
+ // point, between its distinct neighbours. Open path ends borrow the turn of
239
+ // their neighbour. Returns false where the turn is below the rounding noise of
240
+ // the positions, which grows with their magnitude over the segment lengths.
241
+ function computeFrenetNormal(
242
+ normals,
243
+ tangents,
244
+ positions,
245
+ { prev, next },
246
+ precision,
247
+ i,
248
+ ) {
249
+ let center = i;
250
+ if (prev[i] === -1) {
251
+ center = next[i];
252
+ } else if (next[i] === -1) {
253
+ center = prev[i];
254
+ }
255
+ if (center === -1 || prev[center] === -1 || next[center] === -1) {
256
+ return false;
257
+ }
258
+
259
+ setDelta(TEMP_AVEC3, V1, positions, prev[center], center);
260
+ setDelta(TEMP_AVEC3, V2, positions, center, next[center]);
261
+
262
+ const l1 = avec3.length(TEMP_AVEC3, V1);
263
+ const l2 = avec3.length(TEMP_AVEC3, V2);
264
+
265
+ avec3.scale(TEMP_AVEC3, V1, 1 / l1);
266
+ avec3.scale(TEMP_AVEC3, V2, 1 / l2);
267
+
268
+ avec3.set(normals, i, TEMP_AVEC3, V2);
269
+ avec3.sub(normals, i, TEMP_AVEC3, V1);
270
+ reject(normals, i, tangents, i);
271
+
272
+ const magnitude = Math.max(
273
+ maxAbs(positions, prev[center]),
274
+ maxAbs(positions, center),
275
+ maxAbs(positions, next[center]),
276
+ );
277
+ const noise = FRENET_NOISE * precision * magnitude * (1 / l1 + 1 / l2);
278
+ if (avec3.length(normals, i) <= noise) return false;
279
+
280
+ avec3.normalize(normals, i);
281
+
282
+ return true;
283
+ }
284
+
285
+ // Frenet frames are undefined where the curve is straight: fill those points
286
+ // by transporting the nearest defined frame.
287
+ function computeFrenetNormals(
288
+ normals,
289
+ tangents,
290
+ positions,
291
+ size,
292
+ closed,
293
+ precision,
294
+ ) {
295
+ const neighbours = computeNeighbours(positions, size, closed);
296
+ const defined = Array.from({ length: size }, (_, i) =>
297
+ computeFrenetNormal(normals, tangents, positions, neighbours, precision, i),
298
+ );
299
+ const first = defined.indexOf(true);
300
+ if (first === -1) {
301
+ computeBishopNormals(
302
+ normals,
303
+ tangents,
304
+ positions,
305
+ size,
306
+ closed,
307
+ null,
308
+ null,
309
+ 0,
310
+ );
311
+ return;
312
+ }
121
313
 
122
- for (let i = 0; i < size; i++) {
123
- if (isFlatArray) {
124
- avec3.set(tangent, 0, geometry.tangents, i);
125
- avec3.set(normal, 0, geometry.normals, i);
126
- avec3.set(binormal, 0, geometry.binormals, i);
127
-
128
- quat.fromAxisAngle(rotation, tangent, theta * i);
129
- vec3.multQuat(normal, rotation);
130
- binormal = vec3.cross([...tangent], normal);
131
-
132
- avec3.set(geometry.normals, i, normal, 0);
133
- avec3.set(geometry.binormals, i, binormal, 0);
134
- } else {
135
- const tangent = geometry.tangents[i];
136
- const normal = geometry.normals[i];
137
- quat.fromAxisAngle(rotation, tangent, theta * i);
138
- vec3.multQuat(normal, rotation);
139
- geometry.binormals[i] = vec3.cross([...tangent], normal);
140
- }
314
+ // Closed paths wrap around so frames stay continuous across the seam.
315
+ if (!closed) {
316
+ for (let i = first - 1; i >= 0; i--) {
317
+ transport(normals, i, normals, tangents, positions, i + 1, i);
318
+ }
319
+ }
320
+ const end = closed ? first + size : size;
321
+ for (let k = first + 1; k < end; k++) {
322
+ const i = k % size;
323
+ if (!defined[i]) {
324
+ transport(normals, i, normals, tangents, positions, (k - 1) % size, i);
141
325
  }
142
326
  }
327
+ }
328
+
329
+ /**
330
+ * Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D
331
+ * positions.
332
+ *
333
+ * @param {import("./types.js").SimplicialComplex} geometry
334
+ * @param {import("./types.js").Options} [options={}]
335
+ * @returns {import("./types.js").SimplicialComplexWithTNB}
336
+ * @see [Frenet–Serret formulas]{@link https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas}
337
+ * @see [Computation of Rotation Minimizing Frames (Wang et al. 2008)]{@link https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf}
338
+ */
339
+ function frenetSerretFrames(geometry, options) {
340
+ const {
341
+ closed = false,
342
+ mode = "bishop",
343
+ initialNormal = null,
344
+ finalNormal = null,
345
+ twist = 0,
346
+ } = { ...options };
347
+
348
+ const isFlatArray = !geometry.positions[0]?.length;
349
+ const size = geometry.positions.length / (isFlatArray ? 3 : 1);
350
+ const positions = toFlatArray(geometry.positions);
351
+
352
+ // Same layout as path-tangents output
353
+ let ArrayConstructor = Array;
354
+ if (isFlatArray) {
355
+ ArrayConstructor =
356
+ geometry.positions instanceof Float64Array ? Float64Array : Float32Array;
357
+ }
358
+
359
+ geometry.tangents ||= computePathTangents(geometry.positions, { closed });
360
+ const tangents = toFlatArray(geometry.tangents);
361
+ const normals = new Float64Array(size * 3);
362
+ const binormals = new Float64Array(size * 3);
363
+
364
+ if (mode === "frenet") {
365
+ computeFrenetNormals(
366
+ normals,
367
+ tangents,
368
+ positions,
369
+ size,
370
+ closed,
371
+ geometry.positions instanceof Float32Array
372
+ ? FLOAT32_EPSILON
373
+ : Number.EPSILON,
374
+ );
375
+ } else {
376
+ computeBishopNormals(
377
+ normals,
378
+ tangents,
379
+ positions,
380
+ size,
381
+ closed,
382
+ initialNormal,
383
+ finalNormal,
384
+ twist,
385
+ );
386
+ }
387
+
388
+ for (let i = 0; i < size; i++) {
389
+ avec3.set(binormals, i, tangents, i);
390
+ avec3.cross(binormals, i, normals, i);
391
+ }
392
+
393
+ geometry.normals = fromFlatArray(
394
+ normals,
395
+ size,
396
+ ArrayConstructor,
397
+ geometry.normals,
398
+ );
399
+ geometry.binormals = fromFlatArray(
400
+ binormals,
401
+ size,
402
+ ArrayConstructor,
403
+ geometry.binormals,
404
+ );
143
405
 
144
406
  return geometry;
145
407
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "frenet-serret-frames",
3
- "version": "3.1.1",
4
- "description": "Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.",
3
+ "version": "4.0.0",
4
+ "description": "Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D positions.",
5
5
  "keywords": [
6
6
  "path",
7
7
  "spline",
@@ -10,7 +10,10 @@
10
10
  "frenet",
11
11
  "serret",
12
12
  "frames",
13
- "parallel-transport"
13
+ "parallel-transport",
14
+ "rotation-minimizing",
15
+ "bishop",
16
+ "double-reflection"
14
17
  ],
15
18
  "homepage": "https://github.com/dmnsgn/frenet-serret-frames",
16
19
  "bugs": "https://github.com/dmnsgn/frenet-serret-frames/issues",
@@ -38,21 +41,23 @@
38
41
  "default": "./index.js"
39
42
  }
40
43
  },
41
- "main": "index.js",
42
- "types": "types/index.d.ts",
44
+ "main": "./index.js",
45
+ "types": "./types/index.d.ts",
43
46
  "dependencies": {
44
- "path-tangents": "^3.1.1",
45
- "pex-math": "^4.1.0"
47
+ "path-tangents": "^4.2.0",
48
+ "pex-math": "^4.4.0"
46
49
  },
47
50
  "devDependencies": {
48
- "es-module-shims": "^1.10.0",
49
- "pex-cam": "^3.0.0-alpha.1",
50
- "pex-context": "^3.1.1",
51
- "tweakpane": "^4.0.4"
51
+ "es-module-shims": "^2.8.4",
52
+ "pex-cam": "^3.0.1",
53
+ "pex-context": "^4.1.0",
54
+ "pex-random": "^2.1.2",
55
+ "primitive-geometry": "^3.1.0",
56
+ "tweakpane": "^4.0.5"
52
57
  },
58
+ "packageManager": "npm@10.5.1",
53
59
  "engines": {
54
60
  "node": ">=22.0.0",
55
- "npm": ">=10.5.1",
56
- "snowdev": ">=2.2.x"
61
+ "snowdev": ">=3.0.0-alpha.3"
57
62
  }
58
63
  }
package/types/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  export default frenetSerretFrames;
2
2
  export * from "./types.js";
3
3
  /**
4
- * Compute Frenet-Serret frames for a geometry of 3D positions and optionally provided tangents.
4
+ * Compute rotation minimizing (Bishop) or Frenet-Serret frames for a path of 3D
5
+ * positions.
5
6
  *
6
7
  * @param {import("./types.js").SimplicialComplex} geometry
7
8
  * @param {import("./types.js").Options} [options={}]
8
9
  * @returns {import("./types.js").SimplicialComplexWithTNB}
9
- *
10
10
  * @see [Frenet–Serret formulas]{@link https://en.wikipedia.org/wiki/Frenet%E2%80%93Serret_formulas}
11
+ * @see [Computation of Rotation Minimizing Frames (Wang et al. 2008)]{@link https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf}
11
12
  */
12
13
  declare function frenetSerretFrames(geometry: import("./types.js").SimplicialComplex, options?: import("./types.js").Options): import("./types.js").SimplicialComplexWithTNB;
package/types/types.d.ts CHANGED
@@ -1,36 +1,64 @@
1
- export type vec2 = number[];
2
- export type vec3 = number[];
3
1
  /**
4
2
  * Geometry definition.
5
3
  */
6
4
  export type SimplicialComplex = {
7
- positions: Float32Array | any[] | vec3[];
8
- tangents?: Float32Array | any[] | vec3[];
9
- normals?: Float32Array | any[] | vec3[];
10
- uvs?: Float32Array | any[] | vec2[];
11
- cells?: (Uint8Array | Uint16Array | Uint32Array | any[] | vec3[]);
5
+ positions: import("pex-math").TypedArray | number[] | import("pex-math").Vec3[];
6
+ tangents?: number[] | import("pex-math").TypedArray | import("pex-math").Vec3[] | undefined;
7
+ normals?: number[] | import("pex-math").TypedArray | import("pex-math").Vec3[] | undefined;
8
+ binormals?: number[] | import("pex-math").TypedArray | import("pex-math").Vec3[] | undefined;
9
+ uvs?: number[] | import("pex-math").TypedArray | import("pex-math").Vec2[] | undefined;
10
+ cells?: any[] | Uint8Array<ArrayBufferLike> | Uint16Array<ArrayBufferLike> | Uint32Array<ArrayBufferLike> | import("pex-math").Vec3[] | undefined;
12
11
  };
13
12
  /**
14
- * Geometry definition augmented with tangents, normals and binormals.
13
+ * Geometry definition augmented with
14
+ * tangents, normals and binormals.
15
15
  */
16
16
  export type SimplicialComplexWithTNB = {
17
- positions: Float32Array | any[] | vec3[];
18
- tangents: Float32Array | any[] | vec3[];
19
- normals: Float32Array | any[] | vec3[];
20
- binormals: Float32Array | any[] | vec3[];
21
- uvs?: Float32Array | any[] | vec2[];
22
- cells?: (Uint8Array | Uint16Array | Uint32Array | any[] | vec3[]);
17
+ positions: import("pex-math").TypedArray | number[] | import("pex-math").Vec3[];
18
+ tangents: import("pex-math").TypedArray | number[] | import("pex-math").Vec3[];
19
+ normals: import("pex-math").TypedArray | number[] | import("pex-math").Vec3[];
20
+ binormals: import("pex-math").TypedArray | number[] | import("pex-math").Vec3[];
21
+ uvs?: number[] | import("pex-math").TypedArray | import("pex-math").Vec2[] | undefined;
22
+ cells?: any[] | Uint8Array<ArrayBufferLike> | Uint16Array<ArrayBufferLike> | Uint32Array<ArrayBufferLike> | import("pex-math").Vec3[] | undefined;
23
23
  };
24
24
  /**
25
25
  * Options for frames computation. All optional.
26
26
  */
27
27
  export type Options = {
28
28
  /**
29
- * Specify is the path is closed.
29
+ * Specify if the path is closed.
30
30
  */
31
- closed?: boolean;
31
+ closed?: boolean | undefined;
32
32
  /**
33
- * Specify a starting normal for the frames. Default to the direction of the minimum tangent component.
33
+ * Frame type:
34
+ *
35
+ * - "bishop": rotation minimizing (parallel transport) frames, computed with the
36
+ * double reflection method. Twist free, suited for sweeping.
37
+ * - "frenet": normal pointing towards the curvature centre. Flips at inflection
38
+ * points and is undefined on straight parts, where the nearest frame is
39
+ * transported instead.
34
40
  */
35
- initialNormal?: vec3;
41
+ mode?: "bishop" | "frenet" | undefined;
42
+ /**
43
+ * Specify a starting
44
+ * normal for "bishop" frames, made orthogonal to the first tangent. Defaults
45
+ * to a coordinate axis not aligned with the first tangent, also used when the
46
+ * initial normal is parallel to it.
47
+ */
48
+ initialNormal?: import("pex-math").Vec3 | undefined;
49
+ /**
50
+ * Specify an ending
51
+ * normal for "bishop" frames of open paths, made orthogonal to the last
52
+ * tangent. Reached with the smallest rotation, spread along the arc length.
53
+ * Ignored when parallel to the last tangent.
54
+ */
55
+ finalNormal?: import("pex-math").Vec3 | undefined;
56
+ /**
57
+ * Additional rotation of "bishop" frames around
58
+ * the tangent, in radians, spread along the arc length from the first frame
59
+ * to the last. Closed paths and paths with a final normal need a multiple of
60
+ * 2π to keep their end frame, or of π when the sign of the normal doesn't
61
+ * matter (eg. flat ribbons).
62
+ */
63
+ twist?: number | undefined;
36
64
  };
package/types.js CHANGED
@@ -1,33 +1,48 @@
1
- /**
2
- * @typedef {number[]} vec2
3
- */
4
- /**
5
- * @typedef {number[]} vec3
6
- */
7
-
8
1
  /**
9
2
  * @typedef {object} SimplicialComplex Geometry definition.
10
- * @property {Float32Array | Array | vec3[]} positions
11
- * @property {Float32Array | Array | vec3[]} [tangents]
12
- * @property {Float32Array | Array | vec3[]} [normals]
13
- * @property {Float32Array | Array | vec2[]} [uvs]
14
- * @property {(Uint8Array | Uint16Array | Uint32Array | Array | vec3[])} [cells]
3
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} positions
4
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} [tangents]
5
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} [normals]
6
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} [binormals]
7
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec2[]} [uvs]
8
+ * @property {Uint8Array | Uint16Array | Uint32Array | Array | import("pex-math").Vec3[]} [cells]
15
9
  */
16
10
 
17
11
  /**
18
- * @typedef {object} SimplicialComplexWithTNB Geometry definition augmented with tangents, normals and binormals.
19
- * @property {Float32Array | Array | vec3[]} positions
20
- * @property {Float32Array | Array | vec3[]} tangents
21
- * @property {Float32Array | Array | vec3[]} normals
22
- * @property {Float32Array | Array | vec3[]} binormals
23
- * @property {Float32Array | Array | vec2[]} [uvs]
24
- * @property {(Uint8Array | Uint16Array | Uint32Array | Array | vec3[])} [cells]
12
+ * @typedef {object} SimplicialComplexWithTNB Geometry definition augmented with
13
+ * tangents, normals and binormals.
14
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} positions
15
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} tangents
16
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} normals
17
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec3[]} binormals
18
+ * @property {import("pex-math").TypedArray | number[] | import("pex-math").Vec2[]} [uvs]
19
+ * @property {Uint8Array | Uint16Array | Uint32Array | Array | import("pex-math").Vec3[]} [cells]
25
20
  */
26
21
 
27
22
  /**
28
23
  * @typedef {object} Options Options for frames computation. All optional.
29
- * @property {boolean} [closed=false] Specify is the path is closed.
30
- * @property {vec3} [initialNormal=null] Specify a starting normal for the frames. Default to the direction of the minimum tangent component.
24
+ * @property {boolean} [closed=false] Specify if the path is closed.
25
+ * @property {"bishop" | "frenet"} [mode="bishop"] Frame type:
26
+ *
27
+ * - "bishop": rotation minimizing (parallel transport) frames, computed with the
28
+ * double reflection method. Twist free, suited for sweeping.
29
+ * - "frenet": normal pointing towards the curvature centre. Flips at inflection
30
+ * points and is undefined on straight parts, where the nearest frame is
31
+ * transported instead.
32
+ *
33
+ * @property {import("pex-math").Vec3} [initialNormal=null] Specify a starting
34
+ * normal for "bishop" frames, made orthogonal to the first tangent. Defaults
35
+ * to a coordinate axis not aligned with the first tangent, also used when the
36
+ * initial normal is parallel to it.
37
+ * @property {import("pex-math").Vec3} [finalNormal=null] Specify an ending
38
+ * normal for "bishop" frames of open paths, made orthogonal to the last
39
+ * tangent. Reached with the smallest rotation, spread along the arc length.
40
+ * Ignored when parallel to the last tangent.
41
+ * @property {number} [twist=0] Additional rotation of "bishop" frames around
42
+ * the tangent, in radians, spread along the arc length from the first frame
43
+ * to the last. Closed paths and paths with a final normal need a multiple of
44
+ * 2π to keep their end frame, or of π when the sign of the normal doesn't
45
+ * matter (eg. flat ribbons).
31
46
  */
32
47
 
33
48
  export {};