@trioforge/spline 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,13 @@
1
+ # @trioforge/spline
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#6](https://github.com/Trio-Forge-Studio/packages/pull/6) [`dac820b`](https://github.com/Trio-Forge-Studio/packages/commit/dac820b44abb72c663f694c6125de44e1a1a8fcd) Thanks [@Hezaerd](https://github.com/Hezaerd)! - The source moved to [Trio-Forge-Studio/packages](https://github.com/Trio-Forge-Studio/packages), so the package's repository and issue links point there. The package now ships its `CHANGELOG.md`. The code is the same as the previous version.
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - First release. `catmullRom`, `cubicBezier` and `uniformBSpline` build curves through `Vector2` or `Vector3` points. A curve gives its point, tangent and rotation-minimizing frame at a parameter, converts between parameter and distance along its length, and finds the closest point to a position. Curves can be open or closed. ([Trio-Forge-Studio/spline#2](https://github.com/Trio-Forge-Studio/spline/pull/2))
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
- # Temporary Holding Version
1
+ # @trioforge/spline
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ pnpm run examples 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
@@ -0,0 +1,2 @@
1
+ export { catmullRom, cubicBezier, uniformBSpline } from "./curve";
2
+ export type { CatmullRomOptions, Curve, CurveFrame, CurveOptions, CurveProjection, CurveVector } from "./curve";
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
2
  "name": "@trioforge/spline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
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/packages/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Trio-Forge-Studio/packages.git",
18
+ "directory": "packages/spline"
19
+ },
20
+ "license": "MIT",
21
+ "main": "out/init.luau",
22
+ "types": "out/index.d.ts",
23
+ "files": [
24
+ "out",
25
+ "!**/*.tsbuildinfo",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "devDependencies": {
34
+ "@rbxts/compiler-types": "3.0.0-types.0",
35
+ "@rbxts/types": "^1.0.958",
36
+ "@trioforge/tooling": "0.0.0",
37
+ "roblox-ts": "^3.0.0",
38
+ "typescript": "5.5.3"
39
+ },
40
+ "scripts": {
41
+ "build": "rbxtsc --type package",
42
+ "test": "rbxtsc --type package && lune run test",
43
+ "check": "stylua --check test .lune && rbxtsc --type package && lune run test && pnpm run examples README.md",
44
+ "examples": "check-examples --run"
45
+ }
6
46
  }