@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6

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.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. package/plugin/src/init.server.luau +209 -11
@@ -0,0 +1,161 @@
1
+ --!strict
2
+ --[[
3
+ Aurora -- load as an amplitude.
4
+
5
+ A curtain of vertical rays whose height follows a travelling wave. The wave
6
+ is always there; what the session changes is how violent it is. Idle, it is
7
+ a shallow ripple you have to look at to notice. Under load it becomes a
8
+ curtain that fills the cell. Nothing here counts or ranks anything, which is
9
+ the point: it answers "how hard is this working" continuously, where Lattice
10
+ answers it in four steps and Orbit in six.
11
+
12
+ Two waves at different rates are summed rather than one, because a single
13
+ sine is a loading bar lying on its side -- the interference is what makes it
14
+ read as something physical rather than as an animation on a loop.
15
+
16
+ Colour runs green at the base to violet at the tips, which is the actual
17
+ emission order of a real aurora and, more usefully here, means the height of
18
+ a ray is legible from its colour alone at four pixels wide.
19
+ ]]
20
+
21
+ local Draw = require(script.Parent.Draw)
22
+ local Theme = require(script.Parent.Theme)
23
+
24
+ local RAYS = 15
25
+
26
+ local Aurora = {}
27
+
28
+ Aurora.id = "aurora"
29
+ Aurora.label = "Aurora"
30
+ Aurora.blurb = "Rising curtain"
31
+
32
+ Aurora.palette = {
33
+ background = Color3.fromRGB(9, 14, 28),
34
+ surface = Color3.fromRGB(16, 23, 44),
35
+ rule = Color3.fromRGB(108, 226, 178),
36
+ text = Color3.fromRGB(206, 220, 238),
37
+ dim = Color3.fromRGB(86, 100, 130),
38
+ green = Color3.fromRGB(108, 232, 172),
39
+ red = Color3.fromRGB(246, 126, 146),
40
+ amber = Color3.fromRGB(244, 196, 128),
41
+ cyan = Color3.fromRGB(114, 214, 236),
42
+ violet = Color3.fromRGB(162, 146, 240),
43
+ }
44
+
45
+ local rays: { Frame } = {}
46
+
47
+ --[[
48
+ Accumulated wave phase, rather than wall time times a rate.
49
+
50
+ `ctx.clock * 1.6 * pace` looks harmless and is not: `pace` drops to 0.35 the
51
+ moment the session goes quiet, and multiplying a clock in the thousands by a
52
+ rate that changes RESHAPES the whole curtain in one frame -- the same class
53
+ of fault as Void's disc, just rarer, because this rate only moves twice per
54
+ idle cycle instead of every frame. Integrating it means going quiet slows the
55
+ curtain, which is what was meant.
56
+ ]]
57
+ local phase = 0
58
+
59
+ function Aurora.mount(cell: Frame)
60
+ rays = {}
61
+ for index = 1, RAYS do
62
+ local ray = Instance.new("Frame")
63
+ ray.Name = string.format("Ray%02d", index)
64
+ -- Anchored at the bottom so height grows upward from a fixed baseline,
65
+ -- which is what makes the curtain rise rather than expand about its
66
+ -- middle.
67
+ ray.AnchorPoint = Vector2.new(0.5, 1)
68
+ ray.BorderSizePixel = 0
69
+ ray.Parent = cell
70
+ local corner = Instance.new("UICorner")
71
+ corner.CornerRadius = UDim.new(0, 1)
72
+ corner.Parent = ray
73
+ rays[index] = ray
74
+ end
75
+ end
76
+
77
+ function Aurora.unmount()
78
+ phase = 0
79
+ rays = {}
80
+ end
81
+
82
+ function Aurora.paint(ctx: Theme.Context, delta: number)
83
+ local width = ctx.size
84
+ local spacing = width / (RAYS + 1)
85
+
86
+ --[[
87
+ The floor is what keeps an idle panel alive.
88
+
89
+ Amplitude driven purely by activity would flatten the curtain to nothing
90
+ between commands, and a flat line is indistinguishable from a plugin
91
+ that has crashed. A tenth of the range is always moving.
92
+ ]]
93
+ local amplitude = 0.1 + math.clamp(ctx.activity, 0, 1) * 0.62 + ctx.energy * 0.16
94
+ local pace = if ctx.quiet > 20 then 0.35 else 1
95
+ phase += delta * pace
96
+
97
+ for index = 1, RAYS do
98
+ local ray = rays[index]
99
+ if ray == nil then
100
+ continue
101
+ end
102
+ local across = index / RAYS
103
+
104
+ -- Two travelling waves, incommensurate rates. Their beat is slow enough
105
+ -- that the curtain visibly reshapes over several seconds instead of
106
+ -- repeating on a cycle the eye can learn.
107
+ local wave = math.sin(across * 5.2 - phase * 1.6)
108
+ + 0.6 * math.sin(across * 2.7 + phase * 0.9)
109
+ local normalised = (wave + 1.6) / 3.2
110
+
111
+ local height = math.max(2, math.floor(width * (0.12 + normalised * amplitude) * ctx.scale))
112
+ local reach = math.clamp(height / width, 0, 1)
113
+
114
+ ray.Size = UDim2.fromOffset(3, height)
115
+ ray.Position = UDim2.fromOffset(spacing * index, width - 2)
116
+ -- Green low, violet high: the ray's own height is readable from its
117
+ -- colour, which a three-pixel column badly needs.
118
+ ray.BackgroundColor3 = ctx.palette.green
119
+ :Lerp(ctx.palette.violet, reach)
120
+ :Lerp(Color3.new(1, 1, 1), ctx.flare * 0.45)
121
+ ray.BackgroundTransparency = math.clamp(0.74 - reach * 0.45 - ctx.flare * 0.3, 0, 1)
122
+ end
123
+ end
124
+
125
+ --[[
126
+ The trace as a continuation of the curtain: same rays, same colour rule, but
127
+ their heights are history rather than a wave.
128
+ ]]
129
+ function Aurora.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
130
+ local frame = slot.frame
131
+ local recency = 1 - slot.age
132
+ local height = math.max(2, math.floor(2 + slot.weight * 21))
133
+
134
+ frame.AnchorPoint = Vector2.new(0.5, 1)
135
+ frame.Size = UDim2.fromOffset(3, height)
136
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
137
+ frame.BackgroundColor3 = if slot.ok
138
+ then ctx.palette.green:Lerp(ctx.palette.violet, slot.weight)
139
+ else ctx.palette.red
140
+ frame.BackgroundTransparency = math.clamp(0.66 - recency * 0.56, 0, 1)
141
+ frame.Rotation = 0
142
+ end
143
+
144
+ function Aurora.swatch(frame: Frame, palette: Theme.Palette)
145
+ for index = 1, 12 do
146
+ local ray = Instance.new("Frame")
147
+ ray.AnchorPoint = Vector2.new(0.5, 1)
148
+ ray.BorderSizePixel = 0
149
+ local across = index / 12
150
+ local wave = math.sin(across * 5.2) + 0.6 * math.sin(across * 2.7)
151
+ local reach = (wave + 1.6) / 3.2
152
+ local height = math.max(2, math.floor(4 + reach * 17))
153
+ ray.Size = UDim2.fromOffset(2, height)
154
+ ray.Position = UDim2.fromOffset(3 + index * 3, 23)
155
+ ray.BackgroundColor3 = palette.green:Lerp(palette.violet, reach)
156
+ ray.BackgroundTransparency = 0.35
157
+ ray.Parent = frame
158
+ end
159
+ end
160
+
161
+ return Aurora
@@ -0,0 +1,213 @@
1
+ --!strict
2
+ --[[
3
+ Blueprint -- the only preset with no perspective.
4
+
5
+ An orthographic cube on a drafting grid, with tick marks on two edges. Every
6
+ other theme leans on depth to make the cell interesting; this one refuses it
7
+ on purpose, because a drawing that says "this is a measurement, not a
8
+ picture" is a genuinely different thing to look at, and it is the one preset
9
+ that reads as an engineering instrument rather than as an ornament.
10
+
11
+ Orthographic here means the projection divide is simply skipped: the cube
12
+ turns, but a far edge is exactly as long as a near one. That looks wrong for
13
+ a second and then reads as a plan view, which is the effect.
14
+
15
+ It is also the lightest preset -- pale lines on navy, no glow, no bloom. On
16
+ a bright monitor in a bright room it is the only one of the eight that is
17
+ comfortable to look at all day.
18
+ ]]
19
+
20
+ local Draw = require(script.Parent.Draw)
21
+ local Theme = require(script.Parent.Theme)
22
+
23
+ local Blueprint = {}
24
+
25
+ Blueprint.id = "blueprint"
26
+ Blueprint.label = "Blueprint"
27
+ Blueprint.blurb = "Drafting grid"
28
+
29
+ Blueprint.palette = {
30
+ background = Color3.fromRGB(12, 22, 42),
31
+ surface = Color3.fromRGB(18, 32, 58),
32
+ rule = Color3.fromRGB(126, 196, 240),
33
+ text = Color3.fromRGB(206, 226, 246),
34
+ dim = Color3.fromRGB(86, 122, 164),
35
+ green = Color3.fromRGB(128, 226, 190),
36
+ red = Color3.fromRGB(248, 138, 138),
37
+ amber = Color3.fromRGB(244, 202, 130),
38
+ cyan = Color3.fromRGB(140, 210, 246),
39
+ violet = Color3.fromRGB(170, 176, 240),
40
+ }
41
+
42
+ local CUBE = {
43
+ Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
44
+ Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
45
+ Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
46
+ Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
47
+ }
48
+
49
+ local EDGES = {
50
+ { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
51
+ { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
52
+ { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
53
+ }
54
+
55
+ local edges: { Frame } = {}
56
+ local corners: { Frame } = {}
57
+ local grid: { Frame } = {}
58
+ local ticks: { Frame } = {}
59
+
60
+ --[[
61
+ Builds the grid the drawing sits on.
62
+
63
+ Drawn once and never touched again -- it does not react to anything, which
64
+ is the point of graph paper. Kept out of `paint` entirely so the per-frame
65
+ loop stays proportional to the cube.
66
+ ]]
67
+ local function drawGrid(cell: Frame, palette: Theme.Palette, size: number)
68
+ local spacing = 8
69
+ local lines: { Frame } = {}
70
+ for offset = spacing, size - 1, spacing do
71
+ local vertical = Instance.new("Frame")
72
+ vertical.BorderSizePixel = 0
73
+ vertical.BackgroundColor3 = palette.dim
74
+ vertical.BackgroundTransparency = 0.86
75
+ vertical.Size = UDim2.new(0, 1, 1, 0)
76
+ vertical.Position = UDim2.fromOffset(offset, 0)
77
+ vertical.Parent = cell
78
+ table.insert(lines, vertical)
79
+
80
+ local horizontal = Instance.new("Frame")
81
+ horizontal.BorderSizePixel = 0
82
+ horizontal.BackgroundColor3 = palette.dim
83
+ horizontal.BackgroundTransparency = 0.86
84
+ horizontal.Size = UDim2.new(1, 0, 0, 1)
85
+ horizontal.Position = UDim2.fromOffset(0, offset)
86
+ horizontal.Parent = cell
87
+ table.insert(lines, horizontal)
88
+ end
89
+ return lines
90
+ end
91
+
92
+ function Blueprint.mount(cell: Frame)
93
+ local size = cell.AbsoluteSize.X
94
+ if size < 8 then
95
+ size = 40
96
+ end
97
+ grid = drawGrid(cell, Blueprint.palette, size)
98
+ ticks = Draw.pool(cell, 8, "Tick", false)
99
+ edges = Draw.pool(cell, #EDGES, "Edge", false)
100
+ corners = Draw.pool(cell, #CUBE, "Corner", false)
101
+ end
102
+
103
+ function Blueprint.unmount()
104
+ edges = {}
105
+ corners = {}
106
+ grid = {}
107
+ ticks = {}
108
+ end
109
+
110
+ function Blueprint.paint(ctx: Theme.Context, _delta: number)
111
+ local radius = ctx.size * 0.2 * ctx.scale
112
+ -- Slower than Lattice and on two axes rather than three. A plan view that
113
+ -- tumbles is not a plan view.
114
+ local angle = Vector3.new(ctx.angle.X * 0.5, ctx.angle.Y * 0.5, 0)
115
+
116
+ local projected: { Vector2 } = {}
117
+ for index, vertex in CUBE do
118
+ local turned = Draw.rotate(vertex, angle)
119
+ -- No perspective divide. This is the whole theme.
120
+ projected[index] = ctx.centre + Vector2.new(turned.X * radius, turned.Y * radius)
121
+ end
122
+
123
+ for index, edge in EDGES do
124
+ local frame = edges[index]
125
+ if frame == nil then
126
+ continue
127
+ end
128
+ Draw.edge(
129
+ frame,
130
+ projected[edge[1]],
131
+ projected[edge[2]],
132
+ 1,
133
+ ctx.tint:Lerp(Color3.new(1, 1, 1), ctx.flare * 0.5),
134
+ math.clamp(0.55 - ctx.flare * 0.35, 0, 1)
135
+ )
136
+ end
137
+
138
+ --[[
139
+ Corners as small open squares rather than filled dots -- a draughtsman's
140
+ node. Uniform weight, because in an orthographic drawing no corner is
141
+ nearer than another and shading one would be a lie the rest of the theme
142
+ is built on refusing.
143
+ ]]
144
+ for index = 1, #CUBE do
145
+ local frame = corners[index]
146
+ if frame == nil then
147
+ continue
148
+ end
149
+ Draw.dot(frame, projected[index], 2, ctx.palette.cyan, 0.35)
150
+ end
151
+
152
+ --[[
153
+ Dimension ticks along the bottom and left, marching with the rotation.
154
+
155
+ They carry no data. They are there because a technical drawing has them,
156
+ and their slow travel is what gives an idle Blueprint something moving
157
+ to look at now that nothing is glowing or blooming.
158
+ ]]
159
+ for index = 1, 8 do
160
+ local frame = ticks[index]
161
+ if frame == nil then
162
+ continue
163
+ end
164
+ local along = ((index / 8) + ctx.angle.Y * 0.05) % 1
165
+ if index <= 4 then
166
+ local x = along * ctx.size
167
+ Draw.edge(
168
+ frame,
169
+ Vector2.new(x, ctx.size - 4),
170
+ Vector2.new(x, ctx.size - 1),
171
+ 1,
172
+ ctx.palette.dim,
173
+ 0.5
174
+ )
175
+ else
176
+ local y = along * ctx.size
177
+ Draw.edge(frame, Vector2.new(1, y), Vector2.new(4, y), 1, ctx.palette.dim, 0.5)
178
+ end
179
+ end
180
+ end
181
+
182
+ --[[
183
+ History as a section drawing: hairline columns with a tick at the top of
184
+ each, so a bar's height is marked as well as drawn.
185
+ ]]
186
+ function Blueprint.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
187
+ local frame = slot.frame
188
+ local recency = 1 - slot.age
189
+ local height = math.max(1, math.floor(1 + slot.weight * 21))
190
+
191
+ frame.AnchorPoint = Vector2.new(0.5, 1)
192
+ frame.Size = UDim2.fromOffset(1, height)
193
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
194
+ frame.BackgroundColor3 = if slot.ok then ctx.palette.cyan else ctx.palette.red
195
+ frame.BackgroundTransparency = math.clamp(0.6 - recency * 0.5, 0, 1)
196
+ frame.Rotation = 0
197
+ end
198
+
199
+ function Blueprint.swatch(frame: Frame, palette: Theme.Palette)
200
+ drawGrid(frame, palette, 24)
201
+ local centre = Vector2.new(20, 12)
202
+ local pool = Draw.pool(frame, #EDGES, "Edge", false)
203
+ local projected: { Vector2 } = {}
204
+ for index, vertex in CUBE do
205
+ local turned = Draw.rotate(vertex, Vector3.new(0.45, 0.6, 0))
206
+ projected[index] = centre + Vector2.new(turned.X * 6, turned.Y * 6)
207
+ end
208
+ for index, edge in EDGES do
209
+ Draw.edge(pool[index], projected[edge[1]], projected[edge[2]], 1, palette.cyan, 0.4)
210
+ end
211
+ end
212
+
213
+ return Blueprint
@@ -0,0 +1,177 @@
1
+ --!strict
2
+ --[[
3
+ Drawing primitives shared by the presets.
4
+
5
+ Roblox has no line primitive and no point primitive, so both are Frames --
6
+ a segment is a thin rectangle turned about its midpoint, a point is a small
7
+ square with a full corner radius. Every preset that draws either would
8
+ otherwise carry its own copy, and they did not stay identical: the pooling
9
+ rule (build once in `mount`, never allocate in `paint`) is easy to state and
10
+ easy to forget, so it lives here where forgetting it is not an option.
11
+ ]]
12
+
13
+ local Draw = {}
14
+
15
+ --[[
16
+ Turns a point about all three axes.
17
+
18
+ Applied X, then Y, then Z. The order is arbitrary but must not change: the
19
+ presets were tuned against this one, and swapping it silently re-aims every
20
+ spin axis in the project.
21
+ ]]
22
+ function Draw.rotate(point: Vector3, angle: Vector3): Vector3
23
+ local sinX, cosX = math.sin(angle.X), math.cos(angle.X)
24
+ local sinY, cosY = math.sin(angle.Y), math.cos(angle.Y)
25
+ local sinZ, cosZ = math.sin(angle.Z), math.cos(angle.Z)
26
+
27
+ local y1 = point.Y * cosX - point.Z * sinX
28
+ local z1 = point.Y * sinX + point.Z * cosX
29
+ local x2 = point.X * cosY + z1 * sinY
30
+ local z2 = -point.X * sinY + z1 * cosY
31
+ local x3 = x2 * cosZ - y1 * sinZ
32
+ local y3 = x2 * sinZ + y1 * cosZ
33
+
34
+ return Vector3.new(x3, y3, z2)
35
+ end
36
+
37
+ --[[
38
+ A perspective divide. Returns the screen point and the depth it came from,
39
+ because depth is what makes a wireframe read as solid -- near edges are
40
+ brighter and thicker because they genuinely are nearer.
41
+ ]]
42
+ function Draw.project(
43
+ turned: Vector3,
44
+ focal: number,
45
+ radius: number,
46
+ centre: Vector2
47
+ ): (Vector2, number)
48
+ local scale = focal / (focal - turned.Z)
49
+ return centre + Vector2.new(turned.X * scale * radius, turned.Y * scale * radius), turned.Z
50
+ end
51
+
52
+ --[[
53
+ One segment as a rectangle turned about its midpoint. `Rotation` pivots a
54
+ GuiObject around its centre, which is exactly what a segment needs.
55
+ ]]
56
+ function Draw.edge(
57
+ frame: Frame,
58
+ a: Vector2,
59
+ b: Vector2,
60
+ thickness: number,
61
+ color: Color3,
62
+ transparency: number
63
+ )
64
+ local delta = b - a
65
+ local length = delta.Magnitude
66
+ if length < 0.5 then
67
+ frame.Visible = false
68
+ return
69
+ end
70
+ frame.Visible = true
71
+ frame.Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2)
72
+ frame.Size = UDim2.fromOffset(math.ceil(length), thickness)
73
+ frame.Rotation = math.deg(math.atan2(delta.Y, delta.X))
74
+ frame.BackgroundColor3 = color
75
+ frame.BackgroundTransparency = transparency
76
+ end
77
+
78
+ --[[
79
+ A point. Square frames with a full corner radius, so `size` is a diameter.
80
+ ]]
81
+ function Draw.dot(frame: Frame, at: Vector2, size: number, color: Color3, transparency: number)
82
+ frame.Visible = true
83
+ frame.Position = UDim2.fromOffset(at.X, at.Y)
84
+ frame.Size = UDim2.fromOffset(size, size)
85
+ frame.BackgroundColor3 = color
86
+ frame.BackgroundTransparency = transparency
87
+ frame.Rotation = 0
88
+ end
89
+
90
+ --[[
91
+ Builds a pool of centred frames. `round` gives them a full corner radius,
92
+ which is the difference between a point and a tiny square.
93
+ ]]
94
+ function Draw.pool(parent: Instance, count: number, name: string, round: boolean): { Frame }
95
+ local frames: { Frame } = {}
96
+ for index = 1, count do
97
+ local frame = Instance.new("Frame")
98
+ frame.Name = string.format("%s%02d", name, index)
99
+ frame.AnchorPoint = Vector2.new(0.5, 0.5)
100
+ frame.BorderSizePixel = 0
101
+ frame.Visible = false
102
+ frame.Parent = parent
103
+ if round then
104
+ local corner = Instance.new("UICorner")
105
+ corner.CornerRadius = UDim.new(1, 0)
106
+ corner.Parent = frame
107
+ end
108
+ frames[index] = frame
109
+ end
110
+ return frames
111
+ end
112
+
113
+ --[[
114
+ Empties a cell between presets.
115
+
116
+ Themes are singletons -- only one is ever mounted -- so `unmount` could just
117
+ destroy the frames it made. It goes through the cell instead so a preset
118
+ that returns early on an error still leaves nothing behind for the next one
119
+ to draw on top of.
120
+ ]]
121
+ function Draw.clear(cell: Frame)
122
+ for _, child in cell:GetChildren() do
123
+ child:Destroy()
124
+ end
125
+ end
126
+
127
+ local function fract(value: number): number
128
+ return value - math.floor(value)
129
+ end
130
+
131
+ --[[
132
+ Deterministic noise for a given index.
133
+
134
+ Presets that scatter things -- stars, dust, motes -- must scatter them the
135
+ same way every frame, and `math.random` in a paint loop would make the scene
136
+ boil. Seeding per element and reading a fixed sequence gives a stable
137
+ arrangement that still looks unplanned.
138
+
139
+ This was `fract(sin(index * 12.9898 + salt * 78.233) * 43758.5453)`, the
140
+ usual shader one-liner, and it is wrong for the way it is used here. The
141
+ salt only shifts the PHASE of a single sequence, so two draws for the same
142
+ index -- a longitude and a latitude, say -- stay correlated: the points fall
143
+ near a curve instead of filling the space, and at twenty-odd samples two of
144
+ them land almost exactly on top of each other. Measured over 26 points, the
145
+ closest pair sat 0.017 apart on a unit sphere where an even spread gives
146
+ 0.6.
147
+
148
+ Hoskins' hash mixes the two inputs through each other instead of adding
149
+ them, which decorrelates the draws.
150
+ ]]
151
+ function Draw.hash(index: number, salt: number): number
152
+ local x = fract(index * 0.1031)
153
+ local y = fract(salt * 0.1031)
154
+ local z = fract(index * 0.0973)
155
+ local mixed = x * (y + 33.33) + y * (z + 33.33) + z * (x + 33.33)
156
+ return fract((x + mixed + y + mixed) * (z + mixed))
157
+ end
158
+
159
+ --[[
160
+ The `index`-th of `count` points spread evenly over a unit sphere.
161
+
162
+ For a starfield, "unplanned" is not the requirement -- "no visible clumps
163
+ and no visible gaps" is, and no hash gives that. The Fibonacci lattice does:
164
+ walking the golden angle while stepping evenly down the z axis puts the
165
+ points at a measured centroid offset of 0.004 from the origin, against 0.23
166
+ for the hash it replaced, which is the difference between a sky and a smear
167
+ in one corner.
168
+ ]]
169
+ function Draw.sphere(index: number, count: number): Vector3
170
+ local z = 1 - (2 * index - 1) / count
171
+ local radius = math.sqrt(math.max(0, 1 - z * z))
172
+ -- The golden angle. Any other turn per step lines the points up into arms.
173
+ local turn = (index - 1) * math.pi * (3 - math.sqrt(5))
174
+ return Vector3.new(radius * math.cos(turn), radius * math.sin(turn), z)
175
+ end
176
+
177
+ return Draw