@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.5

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/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.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 +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -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
@@ -0,0 +1,304 @@
1
+ --!strict
2
+ --[[
3
+ Lattice -- the default.
4
+
5
+ A turning wireframe solid with a counter-rotating core, over a bar trace of
6
+ the last forty calls. This is the console's original look, and it stays the
7
+ default because it is the most legible of the eight: hard edges, one accent
8
+ colour, nothing moving that is not reporting something.
9
+
10
+ The solid is a real projection rather than a canned loop. Vertices rotate in
11
+ three dimensions and pass through a perspective divide every frame, so near
12
+ edges are brighter and thicker because they genuinely are nearer, and the
13
+ shape climbs a ladder -- tetrahedron, octahedron, cube, icosahedron -- as the
14
+ session gets busier. An idle plugin shows four vertices; a working one shows
15
+ twelve. The shape itself is the load meter.
16
+ ]]
17
+
18
+ local Draw = require(script.Parent.Draw)
19
+ local Theme = require(script.Parent.Theme)
20
+
21
+ local PHI = (1 + math.sqrt(5)) / 2
22
+
23
+ type Shape = {
24
+ name: string,
25
+ vertices: { Vector3 },
26
+ edges: { { number } },
27
+ }
28
+
29
+ --[[
30
+ Ordered by how busy they look. The session climbs this list as it works, so
31
+ the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
32
+ an icosahedron.
33
+ ]]
34
+ local SHAPES: { Shape } = {
35
+ {
36
+ name = "tetrahedron",
37
+ vertices = {
38
+ Vector3.new(1, 1, 1),
39
+ Vector3.new(1, -1, -1),
40
+ Vector3.new(-1, 1, -1),
41
+ Vector3.new(-1, -1, 1),
42
+ },
43
+ edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
44
+ },
45
+ {
46
+ name = "octahedron",
47
+ vertices = {
48
+ Vector3.new(1, 0, 0),
49
+ Vector3.new(-1, 0, 0),
50
+ Vector3.new(0, 1, 0),
51
+ Vector3.new(0, -1, 0),
52
+ Vector3.new(0, 0, 1),
53
+ Vector3.new(0, 0, -1),
54
+ },
55
+ edges = {
56
+ { 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
57
+ { 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
58
+ { 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
59
+ },
60
+ },
61
+ {
62
+ name = "cube",
63
+ vertices = {
64
+ Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
65
+ Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
66
+ Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
67
+ Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
68
+ },
69
+ edges = {
70
+ { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
71
+ { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
72
+ { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
73
+ },
74
+ },
75
+ {
76
+ name = "icosahedron",
77
+ vertices = {
78
+ Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
79
+ Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
80
+ Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
81
+ Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
82
+ Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
83
+ Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
84
+ },
85
+ edges = {
86
+ { 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
87
+ { 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
88
+ { 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
89
+ { 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
90
+ { 5, 6 }, { 5, 9 }, { 5, 10 },
91
+ { 6, 11 }, { 6, 12 },
92
+ { 7, 8 }, { 7, 9 }, { 7, 10 },
93
+ { 8, 11 }, { 8, 12 },
94
+ { 9, 10 }, { 11, 12 },
95
+ },
96
+ },
97
+ }
98
+
99
+ local MAX_EDGES = 30
100
+ local MAX_VERTICES = 12
101
+ local FOCAL = 3.4
102
+
103
+ -- The inner solid, counter-rotating inside the outer one. An octahedron
104
+ -- regardless of what is outside it: it reads as a core rather than as a second
105
+ -- object competing for attention, and its 12 edges are cheap.
106
+ local INNER_SHAPE = 2
107
+ local INNER_SCALE = 0.42
108
+
109
+ -- The solid's resting radius as a fraction of the cell. At full overshoot the
110
+ -- perspective divide pushes a near vertex to roughly 15px, which still clears
111
+ -- the cell's half-width.
112
+ local BASE_RADIUS = 0.185
113
+
114
+ local Lattice = {}
115
+
116
+ Lattice.id = "lattice"
117
+ Lattice.label = "Lattice"
118
+ Lattice.blurb = "Wireframe solid"
119
+
120
+ Lattice.palette = {
121
+ -- Violet-tinted near-black. The cast is slight but it stops the panel
122
+ -- reading as the same grey as every other Studio dock.
123
+ background = Color3.fromRGB(11, 12, 20),
124
+ surface = Color3.fromRGB(18, 20, 31),
125
+ rule = Color3.fromRGB(167, 139, 250),
126
+ text = Color3.fromRGB(206, 212, 226),
127
+ dim = Color3.fromRGB(90, 97, 120),
128
+ green = Color3.fromRGB(110, 231, 168),
129
+ red = Color3.fromRGB(251, 113, 133),
130
+ amber = Color3.fromRGB(251, 191, 36),
131
+ cyan = Color3.fromRGB(103, 232, 249),
132
+ violet = Color3.fromRGB(167, 139, 250),
133
+ }
134
+
135
+ local edges: { Frame } = {}
136
+ local innerEdges: { Frame } = {}
137
+ local dots: { Frame } = {}
138
+
139
+ function Lattice.mount(cell: Frame)
140
+ edges = Draw.pool(cell, MAX_EDGES, "Edge", false)
141
+ innerEdges = Draw.pool(cell, #SHAPES[INNER_SHAPE].edges, "Inner", false)
142
+ -- Built after the edges so the lit corners sit over the strokes they join.
143
+ dots = Draw.pool(cell, MAX_VERTICES, "Vertex", true)
144
+ end
145
+
146
+ function Lattice.unmount()
147
+ edges = {}
148
+ innerEdges = {}
149
+ dots = {}
150
+ end
151
+
152
+ function Lattice.paint(ctx: Theme.Context, _delta: number)
153
+ local shapeIndex = math.clamp(1 + math.floor(ctx.activity * (#SHAPES - 0.001)), 1, #SHAPES)
154
+ local shape = SHAPES[shapeIndex]
155
+ local radius = ctx.size * BASE_RADIUS * ctx.scale
156
+
157
+ -- Wobble the axis rather than the panel: shaking the strip would read as a
158
+ -- glitch, tilting the solid reads as it being knocked.
159
+ local wobble = if ctx.shake > 0
160
+ then Vector3.new(
161
+ math.sin(ctx.clock * 37) * ctx.shake * 0.12,
162
+ math.cos(ctx.clock * 29) * ctx.shake * 0.12,
163
+ 0
164
+ )
165
+ else Vector3.zero
166
+
167
+ local projected: { Vector2 } = {}
168
+ local depths: { number } = {}
169
+ for index, vertex in shape.vertices do
170
+ local point, depth = Draw.project(
171
+ Draw.rotate(vertex, ctx.angle + wobble),
172
+ FOCAL,
173
+ radius,
174
+ ctx.centre
175
+ )
176
+ projected[index] = point
177
+ depths[index] = depth
178
+ end
179
+
180
+ for index = 1, MAX_EDGES do
181
+ local frame = edges[index]
182
+ if frame == nil then
183
+ continue
184
+ end
185
+ local edge = shape.edges[index]
186
+ if edge == nil then
187
+ frame.Visible = false
188
+ continue
189
+ end
190
+ local a, b = edge[1], edge[2]
191
+ local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
192
+ Draw.edge(
193
+ frame,
194
+ projected[a],
195
+ projected[b],
196
+ math.max(1, math.floor(1 + depth * 1.6 + ctx.flare * 0.8)),
197
+ ctx.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + ctx.flare * 0.3),
198
+ math.max(0, 0.84 - depth * 0.74 - ctx.flare * 0.3)
199
+ )
200
+ end
201
+
202
+ --[[
203
+ Vertices as points, brighter than the edges they join.
204
+
205
+ Wireframes read as flat when every stroke is the same weight. Lighting
206
+ the corners gives the eye something to track as the solid turns, which is
207
+ what makes it read as rotating rather than merely flickering.
208
+ ]]
209
+ for index = 1, MAX_VERTICES do
210
+ local dot = dots[index]
211
+ if dot == nil then
212
+ continue
213
+ end
214
+ local point = projected[index]
215
+ if point == nil then
216
+ dot.Visible = false
217
+ continue
218
+ end
219
+ local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
220
+ -- The corners carry the flare. A pulse of light travelling through the
221
+ -- vertices is what makes a completed call land as an event rather than
222
+ -- as a bar quietly appearing somewhere off to the right.
223
+ Draw.dot(
224
+ dot,
225
+ point,
226
+ math.max(2, math.floor(1.5 + depth * 2.5 + ctx.flare * 2.5)),
227
+ ctx.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5),
228
+ math.max(0, 0.55 - depth * 0.5 - ctx.flare * 0.45)
229
+ )
230
+ end
231
+
232
+ --[[
233
+ A second solid inside the first, turning the other way.
234
+
235
+ One rotating outline is a loading spinner. Two, at different rates and
236
+ opposite directions, read as a mechanism -- and the counter-rotation
237
+ makes the outer solid's direction obvious, which a lone wireframe hides
238
+ whenever it passes through a symmetrical angle.
239
+ ]]
240
+ local inner = SHAPES[INNER_SHAPE]
241
+ local innerAngle = -ctx.angle * 1.6
242
+ local innerProjected: { Vector2 } = {}
243
+ local innerDepths: { number } = {}
244
+ for index, vertex in inner.vertices do
245
+ local point, depth = Draw.project(
246
+ Draw.rotate(vertex, innerAngle),
247
+ FOCAL,
248
+ radius * INNER_SCALE,
249
+ ctx.centre
250
+ )
251
+ innerProjected[index] = point
252
+ innerDepths[index] = depth
253
+ end
254
+ for index, frame in innerEdges do
255
+ local edge = inner.edges[index]
256
+ if edge == nil then
257
+ frame.Visible = false
258
+ continue
259
+ end
260
+ local a, b = edge[1], edge[2]
261
+ local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
262
+ Draw.edge(
263
+ frame,
264
+ innerProjected[a],
265
+ innerProjected[b],
266
+ 1,
267
+ ctx.tint:Lerp(Color3.new(1, 1, 1), 0.5),
268
+ 0.9 - depth * 0.35
269
+ )
270
+ end
271
+ end
272
+
273
+ --[[
274
+ A still cube, which is the shape people recognise the console by, drawn at a
275
+ three-quarter angle so all three dimensions are visible at once.
276
+ ]]
277
+ function Lattice.swatch(frame: Frame, palette: Theme.Palette)
278
+ local centre = Vector2.new(20, 12)
279
+ local pool = Draw.pool(frame, 12, "Edge", false)
280
+ local shape = SHAPES[3]
281
+ local angle = Vector3.new(0.5, 0.7, 0)
282
+
283
+ local projected: { Vector2 } = {}
284
+ local depths: { number } = {}
285
+ for index, vertex in shape.vertices do
286
+ local point, depth = Draw.project(Draw.rotate(vertex, angle), FOCAL, 5.5, centre)
287
+ projected[index] = point
288
+ depths[index] = depth
289
+ end
290
+ for index, edge in shape.edges do
291
+ local a, b = edge[1], edge[2]
292
+ local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
293
+ Draw.edge(
294
+ pool[index],
295
+ projected[a],
296
+ projected[b],
297
+ 1,
298
+ palette.violet:Lerp(Color3.new(1, 1, 1), depth * 0.4),
299
+ 0.75 - depth * 0.6
300
+ )
301
+ end
302
+ end
303
+
304
+ return Lattice