@trioforge/spline 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +191 -2
- package/out/curve.d.ts +34 -0
- package/out/curve.luau +855 -0
- package/out/index.d.ts +2 -0
- package/out/init.luau +8 -0
- package/package.json +45 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Trio Forge Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,192 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @trioforge/spline
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Catmull-Rom, cubic Bezier, and uniform B-spline curves for roblox-ts. Use `Vector2` or `Vector3` points to sample positions, travel by distance, orient objects, and find the closest point on a path.
|
|
4
|
+
|
|
5
|
+
Read the documentation at <https://website.trio-forge.workers.dev/spline/>.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @trioforge/spline
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
roblox-ts looks for packages in the scopes your project lists. Add `@trioforge` in two places.
|
|
14
|
+
|
|
15
|
+
1. In `tsconfig.json`, add it to `typeRoots`.
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
"typeRoots": ["node_modules/@rbxts", "node_modules/@trioforge"]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
2. In your Rojo project, map it beside `@rbxts` under `rbxts_include/node_modules`.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
"node_modules": {
|
|
25
|
+
"$className": "Folder",
|
|
26
|
+
"@rbxts": { "$path": "node_modules/@rbxts" },
|
|
27
|
+
"@trioforge": { "$path": "node_modules/@trioforge" }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
Create a path through three points and sample its middle joint.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { catmullRom } from "@trioforge/spline";
|
|
37
|
+
|
|
38
|
+
const path = catmullRom([new Vector3(0, 0, 0), new Vector3(10, 5, 0), new Vector3(20, 0, 0)]);
|
|
39
|
+
print(path.pointAt(0.5).Y);
|
|
40
|
+
// → 5
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Parameters run from 0 to 1. Each span gets an equal share of that range. Equal parameter steps need not cover equal distances. Open curves clamp parameters and distances at their ends.
|
|
44
|
+
|
|
45
|
+
All curve functions are arrow properties. Luau callers use `.`, such as `path.pointAt(0.5)` and `path.length()`. You can detach these functions without binding a receiver.
|
|
46
|
+
|
|
47
|
+
## Choose a curve
|
|
48
|
+
|
|
49
|
+
### Catmull-Rom
|
|
50
|
+
|
|
51
|
+
`catmullRom` passes through its points. `alpha` controls the knot spacing. The default is centripetal, `0.5`. Use `0` for uniform spacing or `1` for chordal spacing.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { catmullRom } from "@trioforge/spline";
|
|
55
|
+
|
|
56
|
+
const path = catmullRom([new Vector2(0, 0), new Vector2(4, 8), new Vector2(12, 0)], { alpha: 0.5 });
|
|
57
|
+
print(path.pointAt(0.5).Y);
|
|
58
|
+
// → 8
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Adjacent duplicate points coalesce. A single point makes a constant curve with zero length.
|
|
62
|
+
|
|
63
|
+
### Cubic Bezier
|
|
64
|
+
|
|
65
|
+
`cubicBezier` uses an anchor, two handles, and an anchor for each span. Open curves need `3n + 1` controls. Adjacent spans share their anchor.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { cubicBezier } from "@trioforge/spline";
|
|
69
|
+
|
|
70
|
+
const path = cubicBezier([new Vector3(0, 0, 0), new Vector3(0, 4, 0), new Vector3(6, 4, 0), new Vector3(6, 0, 0)]);
|
|
71
|
+
print(path.pointAt(0.5).Y);
|
|
72
|
+
// → 3
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Uniform B-spline
|
|
76
|
+
|
|
77
|
+
`uniformBSpline` approximates its controls. An open curve needs at least four controls and usually does not pass through its endpoints.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { uniformBSpline } from "@trioforge/spline";
|
|
81
|
+
|
|
82
|
+
const path = uniformBSpline([new Vector3(0, 0, 0), new Vector3(3, 0, 0), new Vector3(6, 0, 0), new Vector3(9, 0, 0)]);
|
|
83
|
+
print(path.pointAt(0).X);
|
|
84
|
+
// → 3
|
|
85
|
+
print(path.pointAt(1).X);
|
|
86
|
+
// → 6
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Move at an even speed
|
|
90
|
+
|
|
91
|
+
Convert elapsed time to a distance in studs. Pass that distance to `parameterAtDistance` before sampling. The distance table builds on its first use and stays cached.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { cubicBezier } from "@trioforge/spline";
|
|
95
|
+
|
|
96
|
+
const path = cubicBezier([new Vector3(0, 0, 0), new Vector3(0.5, 0, 0), new Vector3(1, 0, 0), new Vector3(10, 0, 0)]);
|
|
97
|
+
const speed = 5;
|
|
98
|
+
const elapsed = 1;
|
|
99
|
+
const parameter = path.parameterAtDistance(speed * elapsed);
|
|
100
|
+
const position = path.pointAt(parameter);
|
|
101
|
+
print(math.abs(position.X - 5) < 0.001);
|
|
102
|
+
// → true
|
|
103
|
+
print(math.abs(path.distanceAtParameter(parameter) - 5) < 0.001);
|
|
104
|
+
// → true
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Call this conversion with the current elapsed time on each update. `length()` returns the total distance along the curve.
|
|
108
|
+
|
|
109
|
+
## Orient along a path
|
|
110
|
+
|
|
111
|
+
`frameAt` returns a point, a unit tangent, and a unit normal. Spatial frames minimize rotation along the path. Set `initialNormal` to choose the starting roll. By default the first normal points toward world up. If the path starts straight up or down, or `initialNormal` is zero or parallel to the tangent, it falls back to the world axis least aligned with the tangent.
|
|
112
|
+
|
|
113
|
+
This matrix points the object's local negative Z axis along the tangent. Its local Y axis follows the normal.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { catmullRom } from "@trioforge/spline";
|
|
117
|
+
|
|
118
|
+
const path = catmullRom([new Vector3(0, 0, 0), new Vector3(10, 0, 0)], { initialNormal: new Vector3(0, 1, 0) });
|
|
119
|
+
const frame = path.frameAt(0.5);
|
|
120
|
+
if (frame !== undefined) {
|
|
121
|
+
const right = frame.tangent.Cross(frame.normal);
|
|
122
|
+
const pose = CFrame.fromMatrix(frame.point, right, frame.normal, frame.tangent.mul(-1));
|
|
123
|
+
print(pose.LookVector.Dot(frame.tangent) > 0.999);
|
|
124
|
+
// → true
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`tangentAt` and `frameAt` return `undefined` at stationary points. For `Vector2` curves, the normal is the tangent's left perpendicular. Frames build their spatial lookup table on first use.
|
|
129
|
+
|
|
130
|
+
## Snap to a path
|
|
131
|
+
|
|
132
|
+
`closest` searches the whole curve. Its `distance` is the distance along the curve. Its `separation` is the straight-line gap to your query.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { catmullRom } from "@trioforge/spline";
|
|
136
|
+
|
|
137
|
+
const path = catmullRom([new Vector3(0, 0, 0), new Vector3(10, 0, 0)]);
|
|
138
|
+
const found = path.closest(new Vector3(4, 3, 0));
|
|
139
|
+
print(math.abs(found.point.X - 4) < 0.001);
|
|
140
|
+
// → true
|
|
141
|
+
print(math.abs(found.separation - 3) < 0.001);
|
|
142
|
+
// → true
|
|
143
|
+
const ahead = path.pointAt(path.parameterAtDistance(found.distance + 2));
|
|
144
|
+
print(math.abs(ahead.X - 6) < 0.001);
|
|
145
|
+
// → true
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`CurveProjection` also returns the `parameter` at the closest point.
|
|
149
|
+
|
|
150
|
+
## Close a loop
|
|
151
|
+
|
|
152
|
+
Set `closed` to wrap parameters and distances. Do not repeat the first point at the end. Catmull-Rom drops a repeated last point, but Bezier and B-spline curves count it as another control, which changes the shape or fails the control count. Catmull-Rom needs at least three points after adjacent duplicates coalesce, unless the loop is constant. Closed B-splines need at least three controls. Closed Beziers need `3n` controls, with the last two handles leading back to the first anchor.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { catmullRom } from "@trioforge/spline";
|
|
156
|
+
|
|
157
|
+
const loop = catmullRom(
|
|
158
|
+
[new Vector3(0, 0, 0), new Vector3(10, 0, 0), new Vector3(10, 10, 0), new Vector3(0, 10, 0)],
|
|
159
|
+
{ closed: true },
|
|
160
|
+
);
|
|
161
|
+
print(loop.pointAt(1) === loop.pointAt(0));
|
|
162
|
+
// → true
|
|
163
|
+
print(loop.pointAt(-0.25) === loop.pointAt(0.75));
|
|
164
|
+
// → true
|
|
165
|
+
print(loop.parameterAtDistance(loop.length()));
|
|
166
|
+
// → 0
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Closed spatial frames distribute their roll correction along the loop to meet at the seam. Bezier handles determine whether a join's tangent is continuous.
|
|
170
|
+
|
|
171
|
+
## Types and validation
|
|
172
|
+
|
|
173
|
+
The package exports `Curve`, `CurveFrame`, `CurveProjection`, `CurveOptions`, `CatmullRomOptions`, and `CurveVector`. A curve preserves its input dimension. `initialNormal` applies only to `Vector3` curves.
|
|
174
|
+
|
|
175
|
+
Constructors reject empty inputs, mixed dimensions, non-finite points, invalid control counts, and `alpha` values outside 0 to 1. Queries reject non-finite parameters, distances, and points.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
Install Node dependencies with `pnpm install`. Install Lune and StyLua with `rokit install`.
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
pnpm run check
|
|
183
|
+
pnpm run test
|
|
184
|
+
node scripts/check-snippets.mjs README.md
|
|
185
|
+
npm pack --dry-run
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`check` runs oxfmt, rbxtsc, StyLua, the behavior tests, and every TypeScript example in this README. Tests load the compiled package in `out/` through roblox-ts's runtime. They compare behavior against dense sampling and hand-computed references. See [AGENTS.md](AGENTS.md) for contributor rules.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT. See [LICENSE](LICENSE).
|
package/out/curve.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type CurveVector = Vector2 | Vector3;
|
|
2
|
+
export interface CurveFrame<V extends CurveVector> {
|
|
3
|
+
readonly point: V;
|
|
4
|
+
readonly tangent: V;
|
|
5
|
+
readonly normal: V;
|
|
6
|
+
}
|
|
7
|
+
export interface CurveProjection<V extends CurveVector> {
|
|
8
|
+
readonly parameter: number;
|
|
9
|
+
readonly point: V;
|
|
10
|
+
readonly distance: number;
|
|
11
|
+
readonly separation: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CurveOptions<V extends CurveVector> {
|
|
14
|
+
readonly closed?: boolean;
|
|
15
|
+
readonly initialNormal?: V extends Vector3 ? Vector3 : never;
|
|
16
|
+
}
|
|
17
|
+
export interface CatmullRomOptions<V extends CurveVector> extends CurveOptions<V> {
|
|
18
|
+
readonly alpha?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface Curve<V extends CurveVector> {
|
|
21
|
+
readonly pointAt: (parameter: number) => V;
|
|
22
|
+
readonly tangentAt: (parameter: number) => V | undefined;
|
|
23
|
+
readonly frameAt: (parameter: number) => CurveFrame<V> | undefined;
|
|
24
|
+
readonly length: () => number;
|
|
25
|
+
readonly parameterAtDistance: (distance: number) => number;
|
|
26
|
+
readonly distanceAtParameter: (parameter: number) => number;
|
|
27
|
+
readonly closest: (query: V) => CurveProjection<V>;
|
|
28
|
+
}
|
|
29
|
+
export declare function catmullRom(points: readonly Vector2[], options?: CatmullRomOptions<Vector2>): Curve<Vector2>;
|
|
30
|
+
export declare function catmullRom(points: readonly Vector3[], options?: CatmullRomOptions<Vector3>): Curve<Vector3>;
|
|
31
|
+
export declare function cubicBezier(points: readonly Vector2[], options?: CurveOptions<Vector2>): Curve<Vector2>;
|
|
32
|
+
export declare function cubicBezier(points: readonly Vector3[], options?: CurveOptions<Vector3>): Curve<Vector3>;
|
|
33
|
+
export declare function uniformBSpline(points: readonly Vector2[], options?: CurveOptions<Vector2>): Curve<Vector2>;
|
|
34
|
+
export declare function uniformBSpline(points: readonly Vector3[], options?: CurveOptions<Vector3>): Curve<Vector3>;
|
package/out/curve.luau
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
-- Arrow properties compile to dot calls, so Luau callers write curve.pointAt(t) and can detach them.
|
|
3
|
+
local build, catmullRomSpans
|
|
4
|
+
local function catmullRom(points, options)
|
|
5
|
+
if options == nil then
|
|
6
|
+
options = {}
|
|
7
|
+
end
|
|
8
|
+
local _condition = options.alpha
|
|
9
|
+
if _condition == nil then
|
|
10
|
+
_condition = 0.5
|
|
11
|
+
end
|
|
12
|
+
local alpha = _condition
|
|
13
|
+
if not (alpha >= 0 and alpha <= 1) then
|
|
14
|
+
error(`a Catmull-Rom alpha must be from 0 to 1, got {alpha}`)
|
|
15
|
+
end
|
|
16
|
+
return build(points, options, function(lifted, closed)
|
|
17
|
+
return catmullRomSpans(lifted, closed, alpha)
|
|
18
|
+
end)
|
|
19
|
+
end
|
|
20
|
+
local bezierSpans
|
|
21
|
+
local function cubicBezier(points, options)
|
|
22
|
+
if options == nil then
|
|
23
|
+
options = {}
|
|
24
|
+
end
|
|
25
|
+
return build(points, options, bezierSpans)
|
|
26
|
+
end
|
|
27
|
+
local bSplineSpans
|
|
28
|
+
local function uniformBSpline(points, options)
|
|
29
|
+
if options == nil then
|
|
30
|
+
options = {}
|
|
31
|
+
end
|
|
32
|
+
return build(points, options, bSplineSpans)
|
|
33
|
+
end
|
|
34
|
+
local UP = Vector3.new(0, 1, 0)
|
|
35
|
+
local AXES = { Vector3.new(1, 0, 0), UP, Vector3.new(0, 0, 1) }
|
|
36
|
+
local LENGTH_DEPTH = 40
|
|
37
|
+
local FRAME_DEPTH = 10
|
|
38
|
+
local FRAME_TURN = math.cos(math.rad(5))
|
|
39
|
+
local SEARCH_BUDGET = 20000
|
|
40
|
+
local GAUSS_INNER = math.sqrt(5 - 2 * math.sqrt(10 / 7)) / 3
|
|
41
|
+
local GAUSS_OUTER = math.sqrt(5 + 2 * math.sqrt(10 / 7)) / 3
|
|
42
|
+
local GAUSS = { { 0, 128 / 225 }, { GAUSS_INNER, (322 + 13 * math.sqrt(70)) / 900 }, { -GAUSS_INNER, (322 + 13 * math.sqrt(70)) / 900 }, { GAUSS_OUTER, (322 - 13 * math.sqrt(70)) / 900 }, { -GAUSS_OUTER, (322 - 13 * math.sqrt(70)) / 900 } }
|
|
43
|
+
local function finite(value)
|
|
44
|
+
return math.abs(value) < math.huge
|
|
45
|
+
end
|
|
46
|
+
local function liftPlanar(point)
|
|
47
|
+
local _point = point
|
|
48
|
+
if not (typeof(_point) == "Vector2") then
|
|
49
|
+
error("a Vector2 curve takes only Vector2 points")
|
|
50
|
+
end
|
|
51
|
+
if not finite(point.X) or not finite(point.Y) then
|
|
52
|
+
error("curve points must be finite")
|
|
53
|
+
end
|
|
54
|
+
return Vector3.new(point.X, point.Y, 0)
|
|
55
|
+
end
|
|
56
|
+
local function lowerPlanar(point)
|
|
57
|
+
return Vector2.new(point.X, point.Y)
|
|
58
|
+
end
|
|
59
|
+
local function liftSpatial(point)
|
|
60
|
+
local _point = point
|
|
61
|
+
if not (typeof(_point) == "Vector3") then
|
|
62
|
+
error("a Vector3 curve takes only Vector3 points")
|
|
63
|
+
end
|
|
64
|
+
if not finite(point.X) or not finite(point.Y) or not finite(point.Z) then
|
|
65
|
+
error("curve points must be finite")
|
|
66
|
+
end
|
|
67
|
+
return point
|
|
68
|
+
end
|
|
69
|
+
local function keepSpatial(point)
|
|
70
|
+
return point
|
|
71
|
+
end
|
|
72
|
+
local createCurve, shape
|
|
73
|
+
function build(points, options, toSpans)
|
|
74
|
+
if #points == 0 then
|
|
75
|
+
error("a curve needs at least one point")
|
|
76
|
+
end
|
|
77
|
+
local _condition = options.closed
|
|
78
|
+
if _condition == nil then
|
|
79
|
+
_condition = false
|
|
80
|
+
end
|
|
81
|
+
local closed = _condition
|
|
82
|
+
local initialNormal = options.initialNormal
|
|
83
|
+
local _arg0 = points[1]
|
|
84
|
+
if typeof(_arg0) == "Vector2" then
|
|
85
|
+
if initialNormal ~= nil then
|
|
86
|
+
error("initialNormal applies only to Vector3 curves")
|
|
87
|
+
end
|
|
88
|
+
local _fn = toSpans
|
|
89
|
+
-- ▼ ReadonlyArray.map ▼
|
|
90
|
+
local _newValue = table.create(#points)
|
|
91
|
+
for _k, _v in points do
|
|
92
|
+
_newValue[_k] = liftPlanar(_v, _k - 1, points)
|
|
93
|
+
end
|
|
94
|
+
-- ▲ ReadonlyArray.map ▲
|
|
95
|
+
return createCurve(shape(_fn(_newValue, closed), closed, true), liftPlanar, lowerPlanar)
|
|
96
|
+
end
|
|
97
|
+
local seed = if initialNormal == nil then nil else liftSpatial(initialNormal)
|
|
98
|
+
local _fn = toSpans
|
|
99
|
+
-- ▼ ReadonlyArray.map ▼
|
|
100
|
+
local _newValue = table.create(#points)
|
|
101
|
+
for _k, _v in points do
|
|
102
|
+
_newValue[_k] = liftSpatial(_v, _k - 1, points)
|
|
103
|
+
end
|
|
104
|
+
-- ▲ ReadonlyArray.map ▲
|
|
105
|
+
return createCurve(shape(_fn(_newValue, closed), closed, false, seed), liftSpatial, keepSpatial)
|
|
106
|
+
end
|
|
107
|
+
function shape(spans, closed, planar, initialNormal)
|
|
108
|
+
local low = spans[1][1]
|
|
109
|
+
local high = low
|
|
110
|
+
for _, span in spans do
|
|
111
|
+
for _1, control in span do
|
|
112
|
+
if not finite(control.X) or not finite(control.Y) or not finite(control.Z) then
|
|
113
|
+
error("the curve's controls overflow")
|
|
114
|
+
end
|
|
115
|
+
low = low:Min(control)
|
|
116
|
+
high = high:Max(control)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
local _object = {
|
|
120
|
+
spans = spans,
|
|
121
|
+
closed = closed,
|
|
122
|
+
planar = planar,
|
|
123
|
+
initialNormal = initialNormal,
|
|
124
|
+
}
|
|
125
|
+
local _left = "tolerance"
|
|
126
|
+
local _high = high
|
|
127
|
+
local _low = low
|
|
128
|
+
_object[_left] = math.max(1e-4, 1e-5 * (_high - _low).Magnitude)
|
|
129
|
+
return _object
|
|
130
|
+
end
|
|
131
|
+
function catmullRomSpans(input, closed, alpha)
|
|
132
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
133
|
+
local _newValue = {}
|
|
134
|
+
local _callback = function(point, index)
|
|
135
|
+
return index == 0 or point ~= input[index]
|
|
136
|
+
end
|
|
137
|
+
local _length = 0
|
|
138
|
+
for _k, _v in input do
|
|
139
|
+
if _callback(_v, _k - 1, input) == true then
|
|
140
|
+
_length += 1
|
|
141
|
+
_newValue[_length] = _v
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
145
|
+
local points = _newValue
|
|
146
|
+
if closed and #points > 1 and points[#points] == points[1] then
|
|
147
|
+
points[#points] = nil
|
|
148
|
+
end
|
|
149
|
+
local count = #points
|
|
150
|
+
if count == 1 then
|
|
151
|
+
return { { points[1], points[1], points[1], points[1] } }
|
|
152
|
+
end
|
|
153
|
+
if closed and count < 3 then
|
|
154
|
+
error("a closed Catmull-Rom curve needs three distinct points")
|
|
155
|
+
end
|
|
156
|
+
local at = function(index)
|
|
157
|
+
if closed then
|
|
158
|
+
return points[(index + count) % count + 1]
|
|
159
|
+
end
|
|
160
|
+
if index < 0 then
|
|
161
|
+
local _exp = points[1] * 2
|
|
162
|
+
local _arg0 = points[2]
|
|
163
|
+
return _exp - _arg0
|
|
164
|
+
end
|
|
165
|
+
if index >= count then
|
|
166
|
+
local _exp = points[count] * 2
|
|
167
|
+
local _arg0 = points[count - 1]
|
|
168
|
+
return _exp - _arg0
|
|
169
|
+
end
|
|
170
|
+
return points[index + 1]
|
|
171
|
+
end
|
|
172
|
+
local span = function(index)
|
|
173
|
+
local p0, p1, p2, p3 = at(index - 1), at(index), at(index + 1), at(index + 2)
|
|
174
|
+
local d01 = (p1 - p0).Magnitude ^ alpha
|
|
175
|
+
local d12 = (p2 - p1).Magnitude ^ alpha
|
|
176
|
+
local d23 = (p3 - p2).Magnitude ^ alpha
|
|
177
|
+
local _exp = (p1 - p0) / d01
|
|
178
|
+
local _exp_1 = p2 - p0
|
|
179
|
+
local _arg0 = d01 + d12
|
|
180
|
+
local _arg0_1 = (p2 - p1) / d12
|
|
181
|
+
local m1 = _exp - (_exp_1 / _arg0) + _arg0_1
|
|
182
|
+
local _exp_2 = (p2 - p1) / d12
|
|
183
|
+
local _exp_3 = p3 - p1
|
|
184
|
+
local _arg0_2 = d12 + d23
|
|
185
|
+
local _arg0_3 = (p3 - p2) / d23
|
|
186
|
+
local m2 = _exp_2 - (_exp_3 / _arg0_2) + _arg0_3
|
|
187
|
+
local _arg0_4 = d12 / 3
|
|
188
|
+
local _arg0_5 = m1 * _arg0_4
|
|
189
|
+
local _exp_4 = p1 + _arg0_5
|
|
190
|
+
local _arg0_6 = d12 / 3
|
|
191
|
+
local _arg0_7 = m2 * _arg0_6
|
|
192
|
+
return { p1, _exp_4, p2 - _arg0_7, p2 }
|
|
193
|
+
end
|
|
194
|
+
local spans = { span(0) }
|
|
195
|
+
for index = 1, (if closed then count else count - 1) - 1 do
|
|
196
|
+
local _arg0 = span(index)
|
|
197
|
+
table.insert(spans, _arg0)
|
|
198
|
+
end
|
|
199
|
+
return spans
|
|
200
|
+
end
|
|
201
|
+
function bezierSpans(points, closed)
|
|
202
|
+
local count = #points
|
|
203
|
+
if if closed then count % 3 ~= 0 else count < 4 or count % 3 ~= 1 then
|
|
204
|
+
error(`a {if closed then "closed cubic Bezier needs 3n" else "cubic Bezier needs 3n + 1"} points, got {count}`)
|
|
205
|
+
end
|
|
206
|
+
local span = function(start)
|
|
207
|
+
return { points[start + 1], points[start + 2], points[start + 3], points[(start + 3) % count + 1] }
|
|
208
|
+
end
|
|
209
|
+
local spans = { span(0) }
|
|
210
|
+
for index = 1, math.floor(count / 3) - 1 do
|
|
211
|
+
local _arg0 = span(index * 3)
|
|
212
|
+
table.insert(spans, _arg0)
|
|
213
|
+
end
|
|
214
|
+
return spans
|
|
215
|
+
end
|
|
216
|
+
function bSplineSpans(points, closed)
|
|
217
|
+
local count = #points
|
|
218
|
+
local least = if closed then 3 else 4
|
|
219
|
+
if count < least then
|
|
220
|
+
error(`a {if closed then "closed " else ""}uniform B-spline needs {least} points, got {count}`)
|
|
221
|
+
end
|
|
222
|
+
local at = function(index)
|
|
223
|
+
return points[(if closed then (index + count - 1) % count else index) + 1]
|
|
224
|
+
end
|
|
225
|
+
local thirdOrSame = function(near, far)
|
|
226
|
+
local _result
|
|
227
|
+
if near == far then
|
|
228
|
+
_result = near
|
|
229
|
+
else
|
|
230
|
+
local _exp = near * 2
|
|
231
|
+
local _far = far
|
|
232
|
+
_result = (_exp + _far) / 3
|
|
233
|
+
end
|
|
234
|
+
return _result
|
|
235
|
+
end
|
|
236
|
+
local jointOrSame = function(a, b, c)
|
|
237
|
+
local _result
|
|
238
|
+
if a == b and b == c then
|
|
239
|
+
_result = b
|
|
240
|
+
else
|
|
241
|
+
local _a = a
|
|
242
|
+
local _arg0 = b * 4
|
|
243
|
+
local _c = c
|
|
244
|
+
_result = (_a + _arg0 + _c) / 6
|
|
245
|
+
end
|
|
246
|
+
return _result
|
|
247
|
+
end
|
|
248
|
+
local span = function(index)
|
|
249
|
+
local q0, q1, q2, q3 = at(index), at(index + 1), at(index + 2), at(index + 3)
|
|
250
|
+
return { jointOrSame(q0, q1, q2), thirdOrSame(q1, q2), thirdOrSame(q2, q1), jointOrSame(q1, q2, q3) }
|
|
251
|
+
end
|
|
252
|
+
local spans = { span(0) }
|
|
253
|
+
for index = 1, (if closed then count else count - 3) - 1 do
|
|
254
|
+
local _arg0 = span(index)
|
|
255
|
+
table.insert(spans, _arg0)
|
|
256
|
+
end
|
|
257
|
+
return spans
|
|
258
|
+
end
|
|
259
|
+
local function locate(geometry, parameter)
|
|
260
|
+
local count = #geometry.spans
|
|
261
|
+
local wrapped = if geometry.closed then parameter - math.floor(parameter) else math.clamp(parameter, 0, 1)
|
|
262
|
+
local scaled = wrapped * count
|
|
263
|
+
local index = math.min(math.floor(scaled), count - 1)
|
|
264
|
+
return {
|
|
265
|
+
index = index,
|
|
266
|
+
span = geometry.spans[index + 1],
|
|
267
|
+
u = scaled - index,
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
local function bernstein(_param, u)
|
|
271
|
+
local a = _param[1]
|
|
272
|
+
local b = _param[2]
|
|
273
|
+
local c = _param[3]
|
|
274
|
+
local d = _param[4]
|
|
275
|
+
local v = 1 - u
|
|
276
|
+
local _arg0 = v * v * v
|
|
277
|
+
local _exp = a * _arg0
|
|
278
|
+
local _arg0_1 = 3 * v * v * u
|
|
279
|
+
local _arg0_2 = b * _arg0_1
|
|
280
|
+
local _arg0_3 = 3 * v * u * u
|
|
281
|
+
local _arg0_4 = c * _arg0_3
|
|
282
|
+
local _arg0_5 = u * u * u
|
|
283
|
+
local _arg0_6 = d * _arg0_5
|
|
284
|
+
return _exp + _arg0_2 + _arg0_4 + _arg0_6
|
|
285
|
+
end
|
|
286
|
+
local function velocity(_param, u)
|
|
287
|
+
local a = _param[1]
|
|
288
|
+
local b = _param[2]
|
|
289
|
+
local c = _param[3]
|
|
290
|
+
local d = _param[4]
|
|
291
|
+
local v = 1 - u
|
|
292
|
+
local _exp = b - a
|
|
293
|
+
local _arg0 = 3 * v * v
|
|
294
|
+
local _exp_1 = c - b
|
|
295
|
+
local _arg0_1 = 6 * v * u
|
|
296
|
+
local _exp_2 = d - c
|
|
297
|
+
local _arg0_2 = 3 * u * u
|
|
298
|
+
return _exp * _arg0 + (_exp_1 * _arg0_1) + (_exp_2 * _arg0_2)
|
|
299
|
+
end
|
|
300
|
+
local function acceleration(_param, u)
|
|
301
|
+
local a = _param[1]
|
|
302
|
+
local b = _param[2]
|
|
303
|
+
local c = _param[3]
|
|
304
|
+
local d = _param[4]
|
|
305
|
+
local _arg0 = b * 2
|
|
306
|
+
local _exp = c - _arg0 + a
|
|
307
|
+
local _arg0_1 = 6 * (1 - u)
|
|
308
|
+
local _arg0_2 = c * 2
|
|
309
|
+
local _exp_1 = d - _arg0_2 + b
|
|
310
|
+
local _arg0_3 = 6 * u
|
|
311
|
+
return _exp * _arg0_1 + (_exp_1 * _arg0_3)
|
|
312
|
+
end
|
|
313
|
+
local function direction(vector)
|
|
314
|
+
local size = vector.Magnitude
|
|
315
|
+
return if size > 0 then vector / size else nil
|
|
316
|
+
end
|
|
317
|
+
local function travel(span, from, to)
|
|
318
|
+
local half = (to - from) / 2
|
|
319
|
+
local middle = (from + to) / 2
|
|
320
|
+
local sum = 0
|
|
321
|
+
for _, _binding in GAUSS do
|
|
322
|
+
local node = _binding[1]
|
|
323
|
+
local weight = _binding[2]
|
|
324
|
+
sum += weight * velocity(span, middle + half * node).Magnitude
|
|
325
|
+
end
|
|
326
|
+
return sum * half
|
|
327
|
+
end
|
|
328
|
+
local function measure(geometry)
|
|
329
|
+
local count = #geometry.spans
|
|
330
|
+
local length = 0
|
|
331
|
+
local _exp = geometry.spans
|
|
332
|
+
-- ▼ ReadonlyArray.map ▼
|
|
333
|
+
local _newValue = table.create(#_exp)
|
|
334
|
+
local _callback = function(span)
|
|
335
|
+
local stops = { 0 }
|
|
336
|
+
local distances = { length }
|
|
337
|
+
local visit
|
|
338
|
+
visit = function(from, to, whole, depth)
|
|
339
|
+
local middle = (from + to) / 2
|
|
340
|
+
local left = travel(span, from, middle)
|
|
341
|
+
local right = travel(span, middle, to)
|
|
342
|
+
local allowed = math.max((geometry.tolerance * (to - from)) / count, 1e-6 * whole)
|
|
343
|
+
if depth >= 2 and math.abs(left + right - whole) <= allowed then
|
|
344
|
+
length += left + right
|
|
345
|
+
local _to = to
|
|
346
|
+
table.insert(stops, _to)
|
|
347
|
+
local _length = length
|
|
348
|
+
table.insert(distances, _length)
|
|
349
|
+
return nil
|
|
350
|
+
end
|
|
351
|
+
if depth >= LENGTH_DEPTH then
|
|
352
|
+
error("a curve span is too irregular to measure")
|
|
353
|
+
end
|
|
354
|
+
visit(from, middle, left, depth + 1)
|
|
355
|
+
visit(middle, to, right, depth + 1)
|
|
356
|
+
end
|
|
357
|
+
visit(0, 1, travel(span, 0, 1), 0)
|
|
358
|
+
return {
|
|
359
|
+
stops = stops,
|
|
360
|
+
distances = distances,
|
|
361
|
+
}
|
|
362
|
+
end
|
|
363
|
+
for _k, _v in _exp do
|
|
364
|
+
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
365
|
+
end
|
|
366
|
+
-- ▲ ReadonlyArray.map ▲
|
|
367
|
+
local spans = _newValue
|
|
368
|
+
local _object = {
|
|
369
|
+
spans = spans,
|
|
370
|
+
}
|
|
371
|
+
local _left = "ends"
|
|
372
|
+
-- ▼ ReadonlyArray.map ▼
|
|
373
|
+
local _newValue_1 = table.create(#spans)
|
|
374
|
+
local _callback_1 = function(_param)
|
|
375
|
+
local distances = _param.distances
|
|
376
|
+
return distances[#distances]
|
|
377
|
+
end
|
|
378
|
+
for _k, _v in spans do
|
|
379
|
+
_newValue_1[_k] = _callback_1(_v, _k - 1, spans)
|
|
380
|
+
end
|
|
381
|
+
-- ▲ ReadonlyArray.map ▲
|
|
382
|
+
_object[_left] = _newValue_1
|
|
383
|
+
_object.length = length
|
|
384
|
+
return _object
|
|
385
|
+
end
|
|
386
|
+
local function lastAtMost(values, value)
|
|
387
|
+
local low = 0
|
|
388
|
+
local high = #values - 1
|
|
389
|
+
while low < high do
|
|
390
|
+
local middle = math.ceil((low + high) / 2)
|
|
391
|
+
if values[middle + 1] <= value then
|
|
392
|
+
low = middle
|
|
393
|
+
else
|
|
394
|
+
high = middle - 1
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
return low
|
|
398
|
+
end
|
|
399
|
+
local function firstAtLeast(values, value, from)
|
|
400
|
+
if from == nil then
|
|
401
|
+
from = 0
|
|
402
|
+
end
|
|
403
|
+
local low = from
|
|
404
|
+
local high = #values - 1
|
|
405
|
+
while low < high do
|
|
406
|
+
local middle = math.floor((low + high) / 2)
|
|
407
|
+
if values[middle + 1] >= value then
|
|
408
|
+
high = middle
|
|
409
|
+
else
|
|
410
|
+
low = middle + 1
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
return low
|
|
414
|
+
end
|
|
415
|
+
local function distanceAt(arc, at)
|
|
416
|
+
local _binding = arc.spans[at.index + 1]
|
|
417
|
+
local stops = _binding.stops
|
|
418
|
+
local distances = _binding.distances
|
|
419
|
+
local station = lastAtMost(stops, at.u)
|
|
420
|
+
if stops[station + 1] == at.u then
|
|
421
|
+
return distances[station + 1]
|
|
422
|
+
end
|
|
423
|
+
return distances[station + 1] + travel(at.span, stops[station + 1], at.u)
|
|
424
|
+
end
|
|
425
|
+
local function parameterAt(geometry, arc, distance)
|
|
426
|
+
local total = arc.length
|
|
427
|
+
if geometry.closed then
|
|
428
|
+
distance -= math.floor(distance / total) * total
|
|
429
|
+
end
|
|
430
|
+
if not (distance > 0 and distance < total) then
|
|
431
|
+
return if geometry.closed or distance <= 0 then 0 else 1
|
|
432
|
+
end
|
|
433
|
+
local count = #geometry.spans
|
|
434
|
+
local index = firstAtLeast(arc.ends, distance)
|
|
435
|
+
local span = geometry.spans[index + 1]
|
|
436
|
+
local _binding = arc.spans[index + 1]
|
|
437
|
+
local stops = _binding.stops
|
|
438
|
+
local distances = _binding.distances
|
|
439
|
+
local station = firstAtLeast(distances, distance, 1) - 1
|
|
440
|
+
local from = stops[station + 1]
|
|
441
|
+
local start = distances[station + 1]
|
|
442
|
+
local run = distances[station + 2] - start
|
|
443
|
+
local low = from
|
|
444
|
+
local high = stops[station + 2]
|
|
445
|
+
local u = if run > 0 then low + ((high - low) * (distance - start)) / run else low
|
|
446
|
+
do
|
|
447
|
+
local step = 0
|
|
448
|
+
local _shouldIncrement = false
|
|
449
|
+
while true do
|
|
450
|
+
if _shouldIncrement then
|
|
451
|
+
step += 1
|
|
452
|
+
else
|
|
453
|
+
_shouldIncrement = true
|
|
454
|
+
end
|
|
455
|
+
if not (step < 40 and high - low > 1e-12) then
|
|
456
|
+
break
|
|
457
|
+
end
|
|
458
|
+
local miss = start + travel(span, from, u) - distance
|
|
459
|
+
if math.abs(miss) <= geometry.tolerance * 1e-2 then
|
|
460
|
+
break
|
|
461
|
+
end
|
|
462
|
+
if miss > 0 then
|
|
463
|
+
high = u
|
|
464
|
+
else
|
|
465
|
+
low = u
|
|
466
|
+
end
|
|
467
|
+
local speed = velocity(span, u).Magnitude
|
|
468
|
+
local stepped = if speed > 0 then u - miss / speed else low
|
|
469
|
+
u = if stepped > low and stepped < high then stepped else (low + high) / 2
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
return (index + u) / count
|
|
473
|
+
end
|
|
474
|
+
local function perpendicular(vector, tangent)
|
|
475
|
+
local _vector = vector
|
|
476
|
+
local _tangent = tangent
|
|
477
|
+
local _arg0 = tangent:Dot(vector)
|
|
478
|
+
local flat = _vector - (_tangent * _arg0)
|
|
479
|
+
local size = flat.Magnitude
|
|
480
|
+
return if size > 1e-4 * vector.Magnitude then flat / size else nil
|
|
481
|
+
end
|
|
482
|
+
local function seed(tangent, preferred)
|
|
483
|
+
if preferred == nil then
|
|
484
|
+
preferred = UP
|
|
485
|
+
end
|
|
486
|
+
local normal = perpendicular(preferred, tangent)
|
|
487
|
+
if normal ~= nil then
|
|
488
|
+
return normal
|
|
489
|
+
end
|
|
490
|
+
local axis = AXES[1]
|
|
491
|
+
for _, candidate in AXES do
|
|
492
|
+
if math.abs(candidate:Dot(tangent)) < math.abs(axis:Dot(tangent)) then
|
|
493
|
+
axis = candidate
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
local _axis = axis
|
|
497
|
+
local _tangent = tangent
|
|
498
|
+
local _arg0 = tangent:Dot(axis)
|
|
499
|
+
return (_axis - (_tangent * _arg0)).Unit
|
|
500
|
+
end
|
|
501
|
+
local function minimalRotation(from, to, normal)
|
|
502
|
+
local _from = from
|
|
503
|
+
local _to = to
|
|
504
|
+
local sum = _from + _to
|
|
505
|
+
local size = sum:Dot(sum)
|
|
506
|
+
if size <= 1e-12 then
|
|
507
|
+
return normal
|
|
508
|
+
end
|
|
509
|
+
local _normal = normal
|
|
510
|
+
local _from_1 = from
|
|
511
|
+
local _arg0 = 2 * from:Dot(normal)
|
|
512
|
+
local flipped = _normal - (_from_1 * _arg0)
|
|
513
|
+
local _arg0_1 = (2 * sum:Dot(flipped)) / size
|
|
514
|
+
local _arg0_2 = sum * _arg0_1
|
|
515
|
+
return flipped - _arg0_2
|
|
516
|
+
end
|
|
517
|
+
local function transport(from, point, tangent, tolerance)
|
|
518
|
+
local _point = point
|
|
519
|
+
local _point_1 = from.point
|
|
520
|
+
local chord = _point - _point_1
|
|
521
|
+
local c1 = chord:Dot(chord)
|
|
522
|
+
local normal
|
|
523
|
+
if c1 <= (tolerance * 1e-3) ^ 2 then
|
|
524
|
+
normal = minimalRotation(from.tangent, tangent, from.normal)
|
|
525
|
+
else
|
|
526
|
+
local _normal = from.normal
|
|
527
|
+
local _arg0 = (2 * chord:Dot(from.normal)) / c1
|
|
528
|
+
local _arg0_1 = chord * _arg0
|
|
529
|
+
local reflectedNormal = _normal - _arg0_1
|
|
530
|
+
local _tangent = from.tangent
|
|
531
|
+
local _arg0_2 = (2 * chord:Dot(from.tangent)) / c1
|
|
532
|
+
local _arg0_3 = chord * _arg0_2
|
|
533
|
+
local reflectedTangent = _tangent - _arg0_3
|
|
534
|
+
local second = tangent - reflectedTangent
|
|
535
|
+
local c2 = second:Dot(second)
|
|
536
|
+
local _result
|
|
537
|
+
if c2 > 0 then
|
|
538
|
+
local _arg0_4 = (2 * second:Dot(reflectedNormal)) / c2
|
|
539
|
+
local _arg0_5 = second * _arg0_4
|
|
540
|
+
_result = reflectedNormal - _arg0_5
|
|
541
|
+
else
|
|
542
|
+
_result = reflectedNormal
|
|
543
|
+
end
|
|
544
|
+
normal = _result
|
|
545
|
+
end
|
|
546
|
+
return perpendicular(normal, tangent) or seed(tangent, from.normal)
|
|
547
|
+
end
|
|
548
|
+
local function frameStops(span)
|
|
549
|
+
local stops = { 0 }
|
|
550
|
+
local turns = function(a, b)
|
|
551
|
+
return if a == nil or b == nil then a ~= b else a:Dot(b) < FRAME_TURN
|
|
552
|
+
end
|
|
553
|
+
local visit
|
|
554
|
+
visit = function(from, fromTangent, to, toTangent, depth)
|
|
555
|
+
local middle = (from + to) / 2
|
|
556
|
+
local middleTangent = direction(velocity(span, middle))
|
|
557
|
+
if depth < FRAME_DEPTH and (depth < 2 or turns(fromTangent, middleTangent) or turns(middleTangent, toTangent)) then
|
|
558
|
+
visit(from, fromTangent, middle, middleTangent, depth + 1)
|
|
559
|
+
visit(middle, middleTangent, to, toTangent, depth + 1)
|
|
560
|
+
else
|
|
561
|
+
local _to = to
|
|
562
|
+
table.insert(stops, _to)
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
visit(0, direction(velocity(span, 0)), 1, direction(velocity(span, 1)), 0)
|
|
566
|
+
return stops
|
|
567
|
+
end
|
|
568
|
+
local function transportFrames(geometry)
|
|
569
|
+
local spanPositions = {}
|
|
570
|
+
local stations = {}
|
|
571
|
+
local _exp = geometry.spans
|
|
572
|
+
-- ▼ ReadonlyArray.forEach ▼
|
|
573
|
+
local _callback = function(span, index)
|
|
574
|
+
for _, u in frameStops(span) do
|
|
575
|
+
local tangent = direction(velocity(span, u))
|
|
576
|
+
if tangent == nil then
|
|
577
|
+
continue
|
|
578
|
+
end
|
|
579
|
+
local point = bernstein(span, u)
|
|
580
|
+
local normal = if #stations == 0 then seed(tangent, geometry.initialNormal) else transport(stations[#stations], point, tangent, geometry.tolerance)
|
|
581
|
+
local _arg0 = index + u
|
|
582
|
+
table.insert(spanPositions, _arg0)
|
|
583
|
+
local _arg0_1 = {
|
|
584
|
+
point = point,
|
|
585
|
+
tangent = tangent,
|
|
586
|
+
normal = normal,
|
|
587
|
+
}
|
|
588
|
+
table.insert(stations, _arg0_1)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
for _k, _v in _exp do
|
|
592
|
+
_callback(_v, _k - 1, _exp)
|
|
593
|
+
end
|
|
594
|
+
-- ▲ ReadonlyArray.forEach ▲
|
|
595
|
+
local count = #geometry.spans
|
|
596
|
+
local first = stations[1]
|
|
597
|
+
local last = stations[#stations]
|
|
598
|
+
local seamRoll = 0
|
|
599
|
+
if geometry.closed and #stations > 0 and spanPositions[1] == 0 and spanPositions[#spanPositions] == count then
|
|
600
|
+
local arrived = minimalRotation(last.tangent, first.tangent, last.normal)
|
|
601
|
+
seamRoll = math.atan2(first.tangent:Dot(arrived:Cross(first.normal)), arrived:Dot(first.normal))
|
|
602
|
+
end
|
|
603
|
+
return {
|
|
604
|
+
spanPositions = spanPositions,
|
|
605
|
+
stations = stations,
|
|
606
|
+
seamRoll = seamRoll,
|
|
607
|
+
}
|
|
608
|
+
end
|
|
609
|
+
local function spatialNormal(geometry, frames, at, point, tangent)
|
|
610
|
+
if #frames.stations == 0 then
|
|
611
|
+
return seed(tangent, geometry.initialNormal)
|
|
612
|
+
end
|
|
613
|
+
local station = frames.stations[lastAtMost(frames.spanPositions, at.index + at.u) + 1]
|
|
614
|
+
return transport(station, point, tangent, geometry.tolerance)
|
|
615
|
+
end
|
|
616
|
+
local function roll(normal, axis, angle)
|
|
617
|
+
local _normal = normal
|
|
618
|
+
local _arg0 = math.cos(angle)
|
|
619
|
+
local _exp = axis:Cross(normal)
|
|
620
|
+
local _arg0_1 = math.sin(angle)
|
|
621
|
+
return _normal * _arg0 + (_exp * _arg0_1)
|
|
622
|
+
end
|
|
623
|
+
local function halve(_param)
|
|
624
|
+
local a = _param[1]
|
|
625
|
+
local b = _param[2]
|
|
626
|
+
local c = _param[3]
|
|
627
|
+
local d = _param[4]
|
|
628
|
+
local ab = (a + b) * 0.5
|
|
629
|
+
local bc = (b + c) * 0.5
|
|
630
|
+
local cd = (c + d) * 0.5
|
|
631
|
+
local abc = (ab + bc) * 0.5
|
|
632
|
+
local bcd = (bc + cd) * 0.5
|
|
633
|
+
local middle = (abc + bcd) * 0.5
|
|
634
|
+
return { { a, ab, abc, middle }, { middle, bcd, cd, d } }
|
|
635
|
+
end
|
|
636
|
+
local function boxGap(_param, query)
|
|
637
|
+
local a = _param[1]
|
|
638
|
+
local b = _param[2]
|
|
639
|
+
local c = _param[3]
|
|
640
|
+
local d = _param[4]
|
|
641
|
+
local _query = query
|
|
642
|
+
local _arg0 = query:Max(a:Min(b, c, d)):Min(a:Max(b, c, d))
|
|
643
|
+
return (_query - _arg0).Magnitude
|
|
644
|
+
end
|
|
645
|
+
local refineClosest
|
|
646
|
+
local function nearest(geometry, query)
|
|
647
|
+
local tolerance = geometry.tolerance
|
|
648
|
+
local _object = {
|
|
649
|
+
index = 0,
|
|
650
|
+
u = 0,
|
|
651
|
+
}
|
|
652
|
+
local _left = "separation"
|
|
653
|
+
local _exp = geometry.spans[1][1]
|
|
654
|
+
local _query = query
|
|
655
|
+
_object[_left] = (_exp - _query).Magnitude
|
|
656
|
+
local best = _object
|
|
657
|
+
local offer = function(index, u, point)
|
|
658
|
+
local _point = point
|
|
659
|
+
local _query_1 = query
|
|
660
|
+
local separation = (_point - _query_1).Magnitude
|
|
661
|
+
if separation < best.separation or (separation == best.separation and index + u < best.index + best.u) then
|
|
662
|
+
best = {
|
|
663
|
+
index = index,
|
|
664
|
+
u = u,
|
|
665
|
+
separation = separation,
|
|
666
|
+
}
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
local pieces = {}
|
|
670
|
+
local _exp_1 = geometry.spans
|
|
671
|
+
-- ▼ ReadonlyArray.forEach ▼
|
|
672
|
+
local _callback = function(span, index)
|
|
673
|
+
offer(index, 1, span[4])
|
|
674
|
+
local _arg0 = {
|
|
675
|
+
index = index,
|
|
676
|
+
controls = span,
|
|
677
|
+
from = 0,
|
|
678
|
+
to = 1,
|
|
679
|
+
bound = boxGap(span, query),
|
|
680
|
+
}
|
|
681
|
+
table.insert(pieces, _arg0)
|
|
682
|
+
end
|
|
683
|
+
for _k, _v in _exp_1 do
|
|
684
|
+
_callback(_v, _k - 1, _exp_1)
|
|
685
|
+
end
|
|
686
|
+
-- ▲ ReadonlyArray.forEach ▲
|
|
687
|
+
do
|
|
688
|
+
local step = 0
|
|
689
|
+
local _shouldIncrement = false
|
|
690
|
+
while true do
|
|
691
|
+
if _shouldIncrement then
|
|
692
|
+
step += 1
|
|
693
|
+
else
|
|
694
|
+
_shouldIncrement = true
|
|
695
|
+
end
|
|
696
|
+
if not (#pieces > 0) then
|
|
697
|
+
break
|
|
698
|
+
end
|
|
699
|
+
local chosen = 0
|
|
700
|
+
-- ▼ ReadonlyArray.forEach ▼
|
|
701
|
+
local _callback_1 = function(piece, position)
|
|
702
|
+
if piece.bound < pieces[chosen + 1].bound then
|
|
703
|
+
chosen = position
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
for _k, _v in pieces do
|
|
707
|
+
_callback_1(_v, _k - 1, pieces)
|
|
708
|
+
end
|
|
709
|
+
-- ▲ ReadonlyArray.forEach ▲
|
|
710
|
+
local piece = pieces[chosen + 1]
|
|
711
|
+
pieces[chosen + 1] = pieces[#pieces]
|
|
712
|
+
pieces[#pieces] = nil
|
|
713
|
+
if piece.bound >= best.separation - tolerance then
|
|
714
|
+
break
|
|
715
|
+
end
|
|
716
|
+
if step >= SEARCH_BUDGET then
|
|
717
|
+
error("the closest point search did not converge")
|
|
718
|
+
end
|
|
719
|
+
local middle = (piece.from + piece.to) / 2
|
|
720
|
+
local _binding = halve(piece.controls)
|
|
721
|
+
local left = _binding[1]
|
|
722
|
+
local right = _binding[2]
|
|
723
|
+
offer(piece.index, middle, left[4])
|
|
724
|
+
for _, _binding_1 in { { left, piece.from, middle }, { right, middle, piece.to } } do
|
|
725
|
+
local controls = _binding_1[1]
|
|
726
|
+
local from = _binding_1[2]
|
|
727
|
+
local to = _binding_1[3]
|
|
728
|
+
local bound = boxGap(controls, query)
|
|
729
|
+
if bound < best.separation - tolerance then
|
|
730
|
+
local _arg0 = {
|
|
731
|
+
index = piece.index,
|
|
732
|
+
controls = controls,
|
|
733
|
+
from = from,
|
|
734
|
+
to = to,
|
|
735
|
+
bound = bound,
|
|
736
|
+
}
|
|
737
|
+
table.insert(pieces, _arg0)
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
return refineClosest(geometry.spans[best.index + 1], query, best)
|
|
743
|
+
end
|
|
744
|
+
function refineClosest(span, query, found)
|
|
745
|
+
local best = found
|
|
746
|
+
local u = best.u
|
|
747
|
+
for step = 0, 7 do
|
|
748
|
+
local _exp = bernstein(span, u)
|
|
749
|
+
local _query = query
|
|
750
|
+
local offset = _exp - _query
|
|
751
|
+
local speed = velocity(span, u)
|
|
752
|
+
local slope = speed:Dot(speed) + offset:Dot(acceleration(span, u))
|
|
753
|
+
if not (slope > 0) then
|
|
754
|
+
break
|
|
755
|
+
end
|
|
756
|
+
u = math.clamp(u - offset:Dot(speed) / slope, 0, 1)
|
|
757
|
+
local _exp_1 = bernstein(span, u)
|
|
758
|
+
local _query_1 = query
|
|
759
|
+
local separation = (_exp_1 - _query_1).Magnitude
|
|
760
|
+
if not (separation < best.separation) then
|
|
761
|
+
break
|
|
762
|
+
end
|
|
763
|
+
best = {
|
|
764
|
+
index = best.index,
|
|
765
|
+
u = u,
|
|
766
|
+
separation = separation,
|
|
767
|
+
}
|
|
768
|
+
end
|
|
769
|
+
return best
|
|
770
|
+
end
|
|
771
|
+
function createCurve(geometry, lift, lower)
|
|
772
|
+
local arc
|
|
773
|
+
local frames
|
|
774
|
+
local measured = function()
|
|
775
|
+
if arc == nil then
|
|
776
|
+
arc = measure(geometry)
|
|
777
|
+
end
|
|
778
|
+
return arc
|
|
779
|
+
end
|
|
780
|
+
local locateChecked = function(parameter)
|
|
781
|
+
if not finite(parameter) then
|
|
782
|
+
error("curve parameters must be finite")
|
|
783
|
+
end
|
|
784
|
+
return locate(geometry, parameter)
|
|
785
|
+
end
|
|
786
|
+
return {
|
|
787
|
+
pointAt = function(parameter)
|
|
788
|
+
local at = locateChecked(parameter)
|
|
789
|
+
return lower(bernstein(at.span, at.u))
|
|
790
|
+
end,
|
|
791
|
+
tangentAt = function(parameter)
|
|
792
|
+
local at = locateChecked(parameter)
|
|
793
|
+
local tangent = direction(velocity(at.span, at.u))
|
|
794
|
+
return if tangent == nil then nil else lower(tangent)
|
|
795
|
+
end,
|
|
796
|
+
frameAt = function(parameter)
|
|
797
|
+
local at = locateChecked(parameter)
|
|
798
|
+
local tangent = direction(velocity(at.span, at.u))
|
|
799
|
+
if tangent == nil then
|
|
800
|
+
return nil
|
|
801
|
+
end
|
|
802
|
+
local point = bernstein(at.span, at.u)
|
|
803
|
+
local normal
|
|
804
|
+
if geometry.planar then
|
|
805
|
+
normal = Vector3.new(-tangent.Y, tangent.X, 0)
|
|
806
|
+
else
|
|
807
|
+
if frames == nil then
|
|
808
|
+
frames = transportFrames(geometry)
|
|
809
|
+
end
|
|
810
|
+
normal = spatialNormal(geometry, frames, at, point, tangent)
|
|
811
|
+
if frames.seamRoll ~= 0 then
|
|
812
|
+
local share = distanceAt(measured(), at) / measured().length
|
|
813
|
+
normal = roll(normal, tangent, frames.seamRoll * share)
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
return {
|
|
817
|
+
point = lower(point),
|
|
818
|
+
tangent = lower(tangent),
|
|
819
|
+
normal = lower(normal),
|
|
820
|
+
}
|
|
821
|
+
end,
|
|
822
|
+
length = function()
|
|
823
|
+
return measured().length
|
|
824
|
+
end,
|
|
825
|
+
parameterAtDistance = function(distance)
|
|
826
|
+
if not finite(distance) then
|
|
827
|
+
error("curve distances must be finite")
|
|
828
|
+
end
|
|
829
|
+
local lengths = measured()
|
|
830
|
+
return if lengths.length > 0 then parameterAt(geometry, lengths, distance) else 0
|
|
831
|
+
end,
|
|
832
|
+
distanceAtParameter = function(parameter)
|
|
833
|
+
return distanceAt(measured(), locateChecked(parameter))
|
|
834
|
+
end,
|
|
835
|
+
closest = function(query)
|
|
836
|
+
local target = lift(query)
|
|
837
|
+
local found = nearest(geometry, target)
|
|
838
|
+
local count = #geometry.spans
|
|
839
|
+
local parameter = if geometry.closed and found.index + found.u >= count then 0 else (found.index + found.u) / count
|
|
840
|
+
local at = locate(geometry, parameter)
|
|
841
|
+
local point = bernstein(at.span, at.u)
|
|
842
|
+
return {
|
|
843
|
+
parameter = parameter,
|
|
844
|
+
point = lower(point),
|
|
845
|
+
distance = distanceAt(measured(), at),
|
|
846
|
+
separation = (point - target).Magnitude,
|
|
847
|
+
}
|
|
848
|
+
end,
|
|
849
|
+
}
|
|
850
|
+
end
|
|
851
|
+
return {
|
|
852
|
+
catmullRom = catmullRom,
|
|
853
|
+
cubicBezier = cubicBezier,
|
|
854
|
+
uniformBSpline = uniformBSpline,
|
|
855
|
+
}
|
package/out/index.d.ts
ADDED
package/out/init.luau
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local TS = _G[script]
|
|
3
|
+
local exports = {}
|
|
4
|
+
local _curve = TS.import(script, script, "curve")
|
|
5
|
+
exports.catmullRom = _curve.catmullRom
|
|
6
|
+
exports.cubicBezier = _curve.cubicBezier
|
|
7
|
+
exports.uniformBSpline = _curve.uniformBSpline
|
|
8
|
+
return exports
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@trioforge/spline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Catmull-Rom, cubic Bezier, and uniform B-spline curves for roblox-ts",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"roblox",
|
|
7
|
+
"roblox-ts",
|
|
8
|
+
"spline",
|
|
9
|
+
"catmull-rom",
|
|
10
|
+
"bezier",
|
|
11
|
+
"b-spline"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://website.trio-forge.workers.dev/spline/",
|
|
14
|
+
"bugs": "https://github.com/Trio-Forge-Studio/spline/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Trio-Forge-Studio/spline.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"main": "out/init.luau",
|
|
21
|
+
"types": "out/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"out",
|
|
24
|
+
"!**/*.tsbuildinfo",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"packageManager": "pnpm@12.15.0",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "rbxtsc --type package",
|
|
34
|
+
"test": "rbxtsc --type package && lune run test",
|
|
35
|
+
"fmt": "oxfmt src scripts",
|
|
36
|
+
"check": "oxfmt --check src scripts && rbxtsc --type package && stylua --check test .lune && lune run test && node scripts/check-snippets.mjs README.md",
|
|
37
|
+
"prepack": "rbxtsc --type package"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@rbxts/compiler-types": "3.0.0-types.0",
|
|
41
|
+
"@rbxts/types": "^1.0.958",
|
|
42
|
+
"oxfmt": "^0.74.0",
|
|
43
|
+
"roblox-ts": "^3.0.0",
|
|
44
|
+
"typescript": "5.5.3"
|
|
45
|
+
}
|
|
46
|
+
}
|