@el4cteo/rbx-studio-mcp 0.4.2 → 0.4.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.
- package/README.md +211 -211
- package/dist/bridge/api.js +3 -0
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +13 -0
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/remote.js +15 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +35 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +55 -7
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +160 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/exec.js +53 -3
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/generate.js +120 -0
- package/dist/tools/generate.js.map +1 -0
- package/dist/tools/scripts.js +20 -1
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +203 -20
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +233 -9
- package/plugin/src/Transport.luau +6 -1
- package/plugin/src/Visuals.luau +52 -1
- package/plugin/src/handlers/Assets.luau +352 -145
- package/plugin/src/handlers/Generate.luau +386 -0
- package/plugin/src/handlers/Geometry.luau +170 -0
- package/plugin/src/handlers/Scripts.luau +79 -1
- package/plugin/src/handlers/Viewport.luau +416 -302
- package/plugin/src/handlers/World.luau +15 -5
- package/plugin/src/init.server.luau +725 -674
- package/scripts/test-bridge.mjs +56 -0
- package/scripts/test-failover.mjs +136 -95
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
3D generation: prompt to model, and cutting a mesh into named parts.
|
|
4
|
+
|
|
5
|
+
`GenerationService` is Roblox's Cube model. `GenerateModelAsync` is the one
|
|
6
|
+
to use -- `GenerateMeshAsync`, the older single-mesh call, is marked for
|
|
7
|
+
removal and is not exposed here.
|
|
8
|
+
|
|
9
|
+
Two things about it are easy to get wrong from the outside:
|
|
10
|
+
|
|
11
|
+
1. The **schema** is not decoration. It decides how the result is split. A
|
|
12
|
+
`Body1` generation is one MeshPart and cannot be articulated; `Car5`
|
|
13
|
+
returns a body and four wheels under known names, which is what makes a
|
|
14
|
+
retargetable driving script able to find the wheels. Asking for "a car"
|
|
15
|
+
under `Body1` gives a car-shaped rock.
|
|
16
|
+
2. It is **slow and metered**. Tens of seconds is normal, and the service
|
|
17
|
+
rejects prompts on moderation and rate limits its own throughput. Those
|
|
18
|
+
arrive as thrown strings, so every call here is wrapped and the reason is
|
|
19
|
+
handed back as a hint rather than a stack trace.
|
|
20
|
+
|
|
21
|
+
Generation happens *before* the undo recording opens. The call yields for a
|
|
22
|
+
long time and a ChangeHistoryService recording held open across it would sit
|
|
23
|
+
over the user's Studio for the whole wait; nothing is in the data model
|
|
24
|
+
until the model is parented, so there is nothing to protect until then.
|
|
25
|
+
]]
|
|
26
|
+
|
|
27
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
28
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
29
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
30
|
+
|
|
31
|
+
local Generate = {}
|
|
32
|
+
|
|
33
|
+
-- Names the engine accepts for `PredefinedSchema`, lowercased for matching.
|
|
34
|
+
local PREDEFINED: { [string]: string } = {
|
|
35
|
+
car5 = "Car5",
|
|
36
|
+
body1 = "Body1",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
--[[
|
|
40
|
+
Turns whatever the service threw into something an agent can act on.
|
|
41
|
+
|
|
42
|
+
The service reports content and quota problems as plain strings, and they are
|
|
43
|
+
the two failures a caller can actually do something about -- reword, or wait.
|
|
44
|
+
Everything else is passed through unchanged rather than guessed at.
|
|
45
|
+
]]
|
|
46
|
+
local function hintFor(reason: string): string?
|
|
47
|
+
local lower = string.lower(reason)
|
|
48
|
+
if string.find(lower, "moderat") then
|
|
49
|
+
return "The prompt was rejected by moderation. Rewrite it and try again."
|
|
50
|
+
elseif string.find(lower, "rate limit") or string.find(lower, "overload") then
|
|
51
|
+
return "The service is rate limited or busy. Wait a moment and retry."
|
|
52
|
+
elseif string.find(lower, "character limit") then
|
|
53
|
+
return "The prompt is too long. Shorten it."
|
|
54
|
+
elseif string.find(lower, "timeout") or string.find(lower, "timed out") then
|
|
55
|
+
return "Generation took longer than the service allows. Try a simpler prompt."
|
|
56
|
+
end
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
--[[
|
|
61
|
+
Appearance carried from a source MeshPart onto the pieces cut out of it.
|
|
62
|
+
|
|
63
|
+
`TextureContent` is the one that matters and the reason this list exists:
|
|
64
|
+
segmentation returns bare geometry, so a textured chest came back as a
|
|
65
|
+
flat grey chest -- the right shape, visibly wrong, and the sort of thing a
|
|
66
|
+
caller then has to notice and repair by hand. The mesh's UVs survive the
|
|
67
|
+
cut, so re-pointing each piece at the original texture maps correctly.
|
|
68
|
+
]]
|
|
69
|
+
local CARRIED = {
|
|
70
|
+
"TextureContent", "Color", "Material", "MaterialVariant", "Reflectance",
|
|
71
|
+
"Transparency", "CastShadow", "DoubleSided", "CanCollide", "CanTouch",
|
|
72
|
+
"CanQuery", "CollisionGroup",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
--[[
|
|
76
|
+
Guarded per property: the pieces and the source are both MeshParts today,
|
|
77
|
+
but a property one of them loses in a future engine version should cost that
|
|
78
|
+
property rather than the whole segmentation.
|
|
79
|
+
]]
|
|
80
|
+
local function carryLook(source: MeshPart, model: Model)
|
|
81
|
+
for _, descendant in model:GetDescendants() do
|
|
82
|
+
if descendant:IsA("MeshPart") then
|
|
83
|
+
for _, property in CARRIED do
|
|
84
|
+
pcall(function()
|
|
85
|
+
(descendant :: any)[property] = (source :: any)[property]
|
|
86
|
+
end)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local function parseVector(text: any): Vector3?
|
|
93
|
+
if typeof(text) ~= "string" or text == "" then
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
|
|
97
|
+
if x == nil then
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local function service(): any
|
|
104
|
+
local ok, found = pcall(function()
|
|
105
|
+
return game:GetService("GenerationService")
|
|
106
|
+
end)
|
|
107
|
+
if not ok or found == nil then
|
|
108
|
+
Dispatch.fail(
|
|
109
|
+
"NO_GENERATION",
|
|
110
|
+
"This Studio build has no GenerationService.",
|
|
111
|
+
"3D generation needs a current Studio. Update Studio and retry."
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
return found
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
--[[
|
|
118
|
+
Builds the `schema` argument, which takes exactly one key.
|
|
119
|
+
|
|
120
|
+
`groups` wins when both are given, because naming parts is the more specific
|
|
121
|
+
request: a caller who lists group names has a structure in mind that no
|
|
122
|
+
predefined schema is going to match.
|
|
123
|
+
]]
|
|
124
|
+
local function buildSchema(params: { [string]: any }): ({ [string]: any }, string)
|
|
125
|
+
local groups = params.groups
|
|
126
|
+
if typeof(groups) == "table" and #(groups :: { any }) > 0 then
|
|
127
|
+
local names: { string } = {}
|
|
128
|
+
for _, name in groups :: { any } do
|
|
129
|
+
if typeof(name) == "string" and name ~= "" then
|
|
130
|
+
table.insert(names, name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
if #names == 0 then
|
|
134
|
+
Dispatch.fail("BAD_PARAMS", "`groups` was given but held no usable names.")
|
|
135
|
+
end
|
|
136
|
+
return { SchemaDefinition = { Groups = names } }, string.format("%d custom group(s)", #names)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
local asked = string.lower(tostring(params.schema or "Body1"))
|
|
140
|
+
local predefined = PREDEFINED[asked]
|
|
141
|
+
if predefined == nil then
|
|
142
|
+
Dispatch.fail(
|
|
143
|
+
"BAD_PARAMS",
|
|
144
|
+
string.format("unknown schema %q", tostring(params.schema)),
|
|
145
|
+
'Use "Body1", "Car5", or pass `groups` to name the parts yourself.'
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
return { PredefinedSchema = predefined }, predefined
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
--[[
|
|
152
|
+
Parents, names, scales and places a freshly generated model.
|
|
153
|
+
|
|
154
|
+
Scaling happens before the pivot so the pivot lands where the caller asked
|
|
155
|
+
rather than where the unscaled model's centre happened to be. Anchoring is
|
|
156
|
+
on by default: the generated parts arrive unanchored and a multi-part result
|
|
157
|
+
dropped into an edit-mode workspace otherwise sags apart the moment physics
|
|
158
|
+
steps.
|
|
159
|
+
]]
|
|
160
|
+
local function place(model: Model, params: { [string]: any }): { [string]: any }
|
|
161
|
+
local parent = if typeof(params.parent) == "string" and params.parent ~= ""
|
|
162
|
+
then Paths.resolve(params.parent)
|
|
163
|
+
else workspace
|
|
164
|
+
|
|
165
|
+
local target = tonumber(params.scaleTo)
|
|
166
|
+
local position = parseVector(params.position)
|
|
167
|
+
local anchor = params.anchor ~= false
|
|
168
|
+
|
|
169
|
+
local path = ""
|
|
170
|
+
local parts: { string } = {}
|
|
171
|
+
|
|
172
|
+
local _, undoable = Undo.record("MCPGenerate", "MCP generate", function()
|
|
173
|
+
if typeof(params.name) == "string" and params.name ~= "" then
|
|
174
|
+
model.Name = params.name
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if target and target > 0 then
|
|
178
|
+
local extents = model:GetExtentsSize()
|
|
179
|
+
local largest = math.max(extents.X, extents.Y, extents.Z)
|
|
180
|
+
if largest > 0 then
|
|
181
|
+
model:ScaleTo(target / largest)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
for _, descendant in model:GetDescendants() do
|
|
186
|
+
if descendant:IsA("BasePart") then
|
|
187
|
+
if anchor then
|
|
188
|
+
(descendant :: BasePart).Anchored = true
|
|
189
|
+
end
|
|
190
|
+
if #parts < 40 then
|
|
191
|
+
table.insert(parts, descendant.Name)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
model.Parent = parent
|
|
197
|
+
|
|
198
|
+
if position then
|
|
199
|
+
-- After parenting, so the pivot is settled.
|
|
200
|
+
pcall(function()
|
|
201
|
+
model:PivotTo(CFrame.new(position :: Vector3))
|
|
202
|
+
end)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
path = Paths.of(model)
|
|
206
|
+
end)
|
|
207
|
+
|
|
208
|
+
local extents = model:GetExtentsSize()
|
|
209
|
+
return {
|
|
210
|
+
path = path,
|
|
211
|
+
parts = parts,
|
|
212
|
+
size = string.format("%.1f, %.1f, %.1f", extents.X, extents.Y, extents.Z),
|
|
213
|
+
anchored = anchor,
|
|
214
|
+
undoable = undoable,
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
--[[
|
|
219
|
+
Prompt (and/or reference image) to a model.
|
|
220
|
+
]]
|
|
221
|
+
function Generate.model(params: { [string]: any }): { [string]: any }
|
|
222
|
+
local prompt = if typeof(params.prompt) == "string" then params.prompt else ""
|
|
223
|
+
local imageId = tonumber(params.imageAssetId)
|
|
224
|
+
if prompt == "" and (imageId == nil or imageId <= 0) then
|
|
225
|
+
Dispatch.fail("BAD_PARAMS", "generate needs a `prompt`, an `imageAssetId`, or both.")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
local inputs: { [string]: any } = {}
|
|
229
|
+
if prompt ~= "" then
|
|
230
|
+
inputs.TextPrompt = prompt
|
|
231
|
+
end
|
|
232
|
+
if imageId and imageId > 0 then
|
|
233
|
+
local okImage, content = pcall(function()
|
|
234
|
+
return (Content :: any).fromAssetId(imageId)
|
|
235
|
+
end)
|
|
236
|
+
if not okImage then
|
|
237
|
+
Dispatch.fail(
|
|
238
|
+
"BAD_PARAMS",
|
|
239
|
+
string.format("Could not reference image asset %d.", imageId),
|
|
240
|
+
"Pass the numeric id of an image asset you own."
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
inputs.Image = content
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
local size = parseVector(params.size)
|
|
247
|
+
if size then
|
|
248
|
+
inputs.Size = size
|
|
249
|
+
end
|
|
250
|
+
local triangles = tonumber(params.maxTriangles)
|
|
251
|
+
if triangles and triangles > 0 then
|
|
252
|
+
inputs.MaxTriangles = math.floor(triangles)
|
|
253
|
+
end
|
|
254
|
+
if params.textures == false then
|
|
255
|
+
inputs.GenerateTextures = false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
local schema, described = buildSchema(params)
|
|
259
|
+
|
|
260
|
+
local ok, result, metadata = pcall(function()
|
|
261
|
+
return service():GenerateModelAsync(inputs, schema)
|
|
262
|
+
end)
|
|
263
|
+
if not ok then
|
|
264
|
+
local reason = tostring(result)
|
|
265
|
+
Dispatch.fail("GENERATE_FAILED", string.format("Generation refused: %s", reason), hintFor(reason))
|
|
266
|
+
end
|
|
267
|
+
if typeof(result) ~= "Instance" or not (result :: Instance):IsA("Model") then
|
|
268
|
+
Dispatch.fail(
|
|
269
|
+
"GENERATE_FAILED",
|
|
270
|
+
string.format("Generation returned a %s, not a Model.", typeof(result)),
|
|
271
|
+
"Nothing was added to the place."
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
local report = place(result :: Model, params)
|
|
276
|
+
report.schema = described
|
|
277
|
+
report.prompt = if prompt ~= "" then prompt else nil
|
|
278
|
+
report.uuid = if typeof(metadata) == "table" then tostring((metadata :: any).UUID or "") else nil
|
|
279
|
+
return report
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
--[[
|
|
283
|
+
Cuts an existing MeshPart into the parts a schema names.
|
|
284
|
+
|
|
285
|
+
This is the half of the service that has nothing to do with prompting: it
|
|
286
|
+
takes geometry that already exists -- an imported mesh, or an earlier `Body1`
|
|
287
|
+
generation -- and splits it. That is how a single-mesh car becomes a car with
|
|
288
|
+
four wheels that can turn.
|
|
289
|
+
|
|
290
|
+
Studio only. The engine refuses it at runtime, and refuses a published mesh
|
|
291
|
+
the signed-in user has no edit permission for.
|
|
292
|
+
]]
|
|
293
|
+
function Generate.segment(params: { [string]: any }): { [string]: any }
|
|
294
|
+
local path = tostring(params.path or "")
|
|
295
|
+
if path == "" then
|
|
296
|
+
Dispatch.fail("BAD_PARAMS", "segment needs a `path` to a MeshPart.")
|
|
297
|
+
end
|
|
298
|
+
local instance = Paths.resolve(path)
|
|
299
|
+
if not instance:IsA("MeshPart") then
|
|
300
|
+
Dispatch.fail(
|
|
301
|
+
"NOT_A_MESH",
|
|
302
|
+
string.format("%s is a %s, not a MeshPart.", path, instance.ClassName),
|
|
303
|
+
"Only MeshParts can be segmented. Generate one first, or import a mesh."
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
local schema, described = buildSchema(params)
|
|
308
|
+
|
|
309
|
+
local ok, result, metadata = pcall(function()
|
|
310
|
+
return service():SegmentMeshAsync(instance :: MeshPart, schema)
|
|
311
|
+
end)
|
|
312
|
+
if not ok then
|
|
313
|
+
local reason = tostring(result)
|
|
314
|
+
Dispatch.fail(
|
|
315
|
+
"SEGMENT_FAILED",
|
|
316
|
+
string.format("Segmentation refused: %s", reason),
|
|
317
|
+
hintFor(reason)
|
|
318
|
+
or "Segmenting needs edit permission on the mesh asset, and only works in Studio."
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
if typeof(result) ~= "Instance" or not (result :: Instance):IsA("Model") then
|
|
322
|
+
Dispatch.fail(
|
|
323
|
+
"SEGMENT_FAILED",
|
|
324
|
+
string.format("Segmentation returned a %s, not a Model.", typeof(result)),
|
|
325
|
+
"The original mesh is untouched."
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
local source = instance :: MeshPart
|
|
330
|
+
carryLook(source, result :: Model)
|
|
331
|
+
|
|
332
|
+
--[[
|
|
333
|
+
Land where the original stood, and default to its parent, so a segmented
|
|
334
|
+
mesh replaces the thing it came from instead of appearing at the origin.
|
|
335
|
+
Read before anything is parented or destroyed, because both change what
|
|
336
|
+
these answers would be.
|
|
337
|
+
]]
|
|
338
|
+
if params.parent == nil and source.Parent then
|
|
339
|
+
params.parent = Paths.of(source.Parent)
|
|
340
|
+
end
|
|
341
|
+
if params.position == nil then
|
|
342
|
+
local at = source.Position
|
|
343
|
+
params.position = string.format("%f, %f, %f", at.X, at.Y, at.Z)
|
|
344
|
+
end
|
|
345
|
+
if params.name == nil then
|
|
346
|
+
params.name = source.Name .. "_Segmented"
|
|
347
|
+
end
|
|
348
|
+
if params.scaleTo == nil then
|
|
349
|
+
--[[
|
|
350
|
+
Segmentation hands back the mesh at its own native scale, not at the
|
|
351
|
+
size the MeshPart was displayed at. Measured: a crate shown as 6 x
|
|
352
|
+
3.3 x 4.1 studs came back as 1.9 x 1.1 x 1.3 -- the same shape, a
|
|
353
|
+
third the size, sitting where the original was. Since the point of
|
|
354
|
+
this op is to replace a part with an articulated version of itself,
|
|
355
|
+
the size it was on screen is the size to keep.
|
|
356
|
+
]]
|
|
357
|
+
local extents = source.Size
|
|
358
|
+
params.scaleTo = math.max(extents.X, extents.Y, extents.Z)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
local report = place(result :: Model, params)
|
|
362
|
+
report.schema = described
|
|
363
|
+
report.uuid = if typeof(metadata) == "table" then tostring((metadata :: any).UUID or "") else nil
|
|
364
|
+
|
|
365
|
+
if params.keepOriginal ~= true then
|
|
366
|
+
local removed = Paths.of(source)
|
|
367
|
+
local _, dropped = Undo.record("MCPSegmentCleanup", "MCP segment cleanup", function()
|
|
368
|
+
source:Destroy()
|
|
369
|
+
end)
|
|
370
|
+
report.removed = removed
|
|
371
|
+
-- Two recordings, so two Ctrl+Z. Said plainly rather than claimed as one.
|
|
372
|
+
report.undoable = report.undoable == true and dropped
|
|
373
|
+
report.steps = 2
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
return report
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
function Generate.register()
|
|
380
|
+
Dispatch.registerAll("generate", {
|
|
381
|
+
model = Generate.model,
|
|
382
|
+
segment = Generate.segment,
|
|
383
|
+
})
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
return Generate
|
|
@@ -53,6 +53,17 @@ local function carryAppearance(source: BasePart, target: BasePart)
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
local function parseVector(text: any): Vector3?
|
|
57
|
+
if typeof(text) ~= "string" or text == "" then
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
|
|
61
|
+
if x == nil then
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
|
|
65
|
+
end
|
|
66
|
+
|
|
56
67
|
local function resolveParts(paths: any, what: string): { BasePart }
|
|
57
68
|
if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
|
|
58
69
|
Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
|
|
@@ -270,10 +281,169 @@ function Geometry.fragment(params: { [string]: any }): { [string]: any }
|
|
|
270
281
|
return { created = created, pieces = #produced, undoable = undoable }
|
|
271
282
|
end
|
|
272
283
|
|
|
284
|
+
--[[
|
|
285
|
+
Builds the motion to sweep along, as a list of CFrames.
|
|
286
|
+
|
|
287
|
+
Three ways to say it, in the order they are checked. `positions` is the
|
|
288
|
+
escape hatch; `to` is a slide; `spin` is a hinge, which is the case worth
|
|
289
|
+
having -- a door, a hatch, a drawbridge. All three are sampled rather than
|
|
290
|
+
solved, so `steps` trades accuracy for time: the volume is the hull of the
|
|
291
|
+
samples, and too few of them on a wide arc cuts the corners off the swing.
|
|
292
|
+
]]
|
|
293
|
+
local function motionOf(subject: BasePart, params: { [string]: any }): { CFrame }
|
|
294
|
+
local start = subject.CFrame
|
|
295
|
+
local steps = math.clamp(tonumber(params.steps) or 12, 2, 64)
|
|
296
|
+
|
|
297
|
+
local positions = params.positions
|
|
298
|
+
if typeof(positions) == "table" and #(positions :: { any }) > 0 then
|
|
299
|
+
local frames: { CFrame } = {}
|
|
300
|
+
for _, entry in positions :: { any } do
|
|
301
|
+
local at = parseVector(entry)
|
|
302
|
+
if at == nil then
|
|
303
|
+
Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" position.", tostring(entry)))
|
|
304
|
+
end
|
|
305
|
+
table.insert(frames, start.Rotation + (at :: Vector3))
|
|
306
|
+
end
|
|
307
|
+
return frames
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
local spin = tonumber(params.spin)
|
|
311
|
+
if spin ~= nil and spin ~= 0 then
|
|
312
|
+
local axis = parseVector(params.axis) or Vector3.yAxis
|
|
313
|
+
if axis.Magnitude == 0 then
|
|
314
|
+
Dispatch.fail("BAD_PARAMS", "`axis` cannot be zero.")
|
|
315
|
+
end
|
|
316
|
+
-- The pivot is the hinge. Defaulting it to the part's own centre makes
|
|
317
|
+
-- the part spin in place, which is right for a wheel and wrong for a
|
|
318
|
+
-- door -- so a door has to name its hinge edge.
|
|
319
|
+
local pivot = parseVector(params.pivot) or subject.Position
|
|
320
|
+
local frames: { CFrame } = {}
|
|
321
|
+
for index = 0, steps - 1 do
|
|
322
|
+
local angle = math.rad(spin) * (index / (steps - 1))
|
|
323
|
+
local turn = CFrame.fromAxisAngle(axis.Unit, angle)
|
|
324
|
+
table.insert(frames, CFrame.new(pivot) * turn * CFrame.new(-pivot) * start)
|
|
325
|
+
end
|
|
326
|
+
return frames
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
local destination = parseVector(params.to)
|
|
330
|
+
if destination == nil then
|
|
331
|
+
Dispatch.fail(
|
|
332
|
+
"BAD_PARAMS",
|
|
333
|
+
"sweep needs a motion.",
|
|
334
|
+
"Give `to` for a slide, `spin` (with `pivot`) for a hinge, or `positions` for a path."
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
local frames: { CFrame } = {}
|
|
339
|
+
for index = 0, steps - 1 do
|
|
340
|
+
local alpha = index / (steps - 1)
|
|
341
|
+
table.insert(frames, start:Lerp(start.Rotation + (destination :: Vector3), alpha))
|
|
342
|
+
end
|
|
343
|
+
return frames
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
--[[
|
|
347
|
+
The volume a part passes through as it moves.
|
|
348
|
+
|
|
349
|
+
The question this answers -- "does the door hit the wall when it opens" --
|
|
350
|
+
has no good answer from the data model. Positions and sizes describe where
|
|
351
|
+
things are, not where something will be on its way somewhere else, and the
|
|
352
|
+
usual workaround is to move the part in steps and test at each one, which
|
|
353
|
+
misses anything thin enough to sit between two samples.
|
|
354
|
+
|
|
355
|
+
`checkAgainst` is why this is worth calling rather than just building the
|
|
356
|
+
volume: the swept solid is put in the world for a moment and asked what it
|
|
357
|
+
overlaps. Left on (`keep`) it stays as a part; turned off it is destroyed
|
|
358
|
+
once measured, so a clearance check leaves nothing behind.
|
|
359
|
+
]]
|
|
360
|
+
function Geometry.sweep(params: { [string]: any }): { [string]: any }
|
|
361
|
+
local subject = resolveParts({ params.path }, "a `path`")[1]
|
|
362
|
+
local frames = motionOf(subject, params)
|
|
363
|
+
|
|
364
|
+
local ok, volume = pcall(function()
|
|
365
|
+
return (GeometryService :: any):SweepPartAsync(subject, frames, {
|
|
366
|
+
CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
|
|
367
|
+
or Enum.CollisionFidelity.Default,
|
|
368
|
+
})
|
|
369
|
+
end)
|
|
370
|
+
if not ok or typeof(volume) ~= "Instance" then
|
|
371
|
+
Dispatch.fail(
|
|
372
|
+
"GEOMETRY_FAILED",
|
|
373
|
+
string.format("SweepPartAsync refused: %s", tostring(volume)),
|
|
374
|
+
"The part must be a solid Studio can sweep. Terrain and some mesh shapes are refused."
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
local swept = volume :: BasePart
|
|
379
|
+
local parent = if typeof(params.parent) == "string" and params.parent ~= ""
|
|
380
|
+
then Paths.resolve(params.parent)
|
|
381
|
+
else subject.Parent
|
|
382
|
+
local keep = params.keep ~= false
|
|
383
|
+
|
|
384
|
+
local hits: { string } = {}
|
|
385
|
+
local path = ""
|
|
386
|
+
|
|
387
|
+
local _, undoable = Undo.record("MCPSweep", "MCP sweep", function()
|
|
388
|
+
carryAppearance(subject, swept)
|
|
389
|
+
swept.Name = if typeof(params.name) == "string" and params.name ~= ""
|
|
390
|
+
then params.name
|
|
391
|
+
else subject.Name .. "_Sweep"
|
|
392
|
+
swept.Anchored = true
|
|
393
|
+
swept.CanCollide = false
|
|
394
|
+
swept.Transparency = tonumber(params.transparency) or 0.5
|
|
395
|
+
swept.Parent = parent
|
|
396
|
+
|
|
397
|
+
--[[
|
|
398
|
+
Measured while the volume is in the world, because that is the only
|
|
399
|
+
state the spatial query can read. The subject is excluded: a part
|
|
400
|
+
always overlaps its own swept volume, and reporting that would bury
|
|
401
|
+
the answer under the one hit nobody asked about.
|
|
402
|
+
]]
|
|
403
|
+
local against = params.checkAgainst
|
|
404
|
+
if against ~= nil then
|
|
405
|
+
local overlap = OverlapParams.new()
|
|
406
|
+
overlap.MaxParts = 100
|
|
407
|
+
local named = if typeof(against) == "table" then against :: { any } else {}
|
|
408
|
+
if #named > 0 then
|
|
409
|
+
-- A named list is the question "does it hit THESE", so anything
|
|
410
|
+
-- else in the way is not an answer.
|
|
411
|
+
local only, _missing = Paths.resolveMany(named :: { string })
|
|
412
|
+
overlap.FilterType = Enum.RaycastFilterType.Include
|
|
413
|
+
overlap.FilterDescendantsInstances = only
|
|
414
|
+
else
|
|
415
|
+
overlap.FilterType = Enum.RaycastFilterType.Exclude
|
|
416
|
+
overlap.FilterDescendantsInstances = { subject, swept }
|
|
417
|
+
end
|
|
418
|
+
for _, found in workspace:GetPartsInPart(swept, overlap) do
|
|
419
|
+
if found ~= subject and found ~= swept then
|
|
420
|
+
table.insert(hits, Paths.of(found))
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
if keep then
|
|
426
|
+
path = Paths.of(swept)
|
|
427
|
+
else
|
|
428
|
+
swept:Destroy()
|
|
429
|
+
end
|
|
430
|
+
end)
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
created = if keep then { path } else {},
|
|
434
|
+
hits = hits,
|
|
435
|
+
checked = params.checkAgainst ~= nil,
|
|
436
|
+
frames = #frames,
|
|
437
|
+
kept = keep,
|
|
438
|
+
undoable = undoable,
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
|
|
273
442
|
function Geometry.register()
|
|
274
443
|
Dispatch.registerAll("geometry", {
|
|
275
444
|
combine = Geometry.combine,
|
|
276
445
|
fragment = Geometry.fragment,
|
|
446
|
+
sweep = Geometry.sweep,
|
|
277
447
|
})
|
|
278
448
|
end
|
|
279
449
|
|
|
@@ -61,6 +61,44 @@ end
|
|
|
61
61
|
inclusive, matching the numbers script_edit takes back, so a read and a write
|
|
62
62
|
need no off-by-one conversion between them.
|
|
63
63
|
]]
|
|
64
|
+
--[[
|
|
65
|
+
A short fingerprint of a script's source, for detecting that it moved.
|
|
66
|
+
|
|
67
|
+
Handed out by `read` and passed back to `edit`, which refuses to write when
|
|
68
|
+
the live source no longer matches. That is the only thing standing between
|
|
69
|
+
two agents on one place and a silent overwrite: a line-range edit computed
|
|
70
|
+
against source somebody has since changed still applies cleanly, it just
|
|
71
|
+
applies to the wrong lines, and nothing anywhere reports it.
|
|
72
|
+
|
|
73
|
+
FNV-1a over the whole string, with the length appended. Not a security
|
|
74
|
+
hash and does not need to be -- it is guarding against ordinary concurrent
|
|
75
|
+
editing, not against someone constructing a collision. The length is there
|
|
76
|
+
because it is free and rules out the whole class of same-length accidents.
|
|
77
|
+
|
|
78
|
+
The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
|
|
79
|
+
a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
|
|
80
|
+
so the low bits -- the ones that carry the mixing -- would be quietly
|
|
81
|
+
rounded away.
|
|
82
|
+
]]
|
|
83
|
+
local function fingerprint(source: string): string
|
|
84
|
+
local hash = 2166136261
|
|
85
|
+
local length = #source
|
|
86
|
+
local index = 1
|
|
87
|
+
while index <= length do
|
|
88
|
+
local last = math.min(index + 511, length)
|
|
89
|
+
local chunk = { string.byte(source, index, last) }
|
|
90
|
+
for _, byte in chunk do
|
|
91
|
+
hash = bit32.bxor(hash, byte)
|
|
92
|
+
local low = bit32.band(hash, 0xFFFF)
|
|
93
|
+
local high = bit32.rshift(hash, 16)
|
|
94
|
+
-- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
|
|
95
|
+
hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
|
|
96
|
+
end
|
|
97
|
+
index = last + 1
|
|
98
|
+
end
|
|
99
|
+
return string.format("%08x-%x", hash, length)
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
65
103
|
local paths = params.paths
|
|
66
104
|
if typeof(paths) ~= "table" or #paths == 0 then
|
|
@@ -98,7 +136,8 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
|
98
136
|
end
|
|
99
137
|
|
|
100
138
|
local target = resolved :: LuaSourceContainer
|
|
101
|
-
local
|
|
139
|
+
local whole = ScriptEdit.read(target)
|
|
140
|
+
local lines = TextEdit.toLines(whole)
|
|
102
141
|
local askedStart = if windowed and entry.startLine ~= nil
|
|
103
142
|
then tonumber(entry.startLine)
|
|
104
143
|
else tonumber(params.startLine)
|
|
@@ -118,6 +157,10 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
|
|
|
118
157
|
startLine = startLine,
|
|
119
158
|
endLine = askedEnd,
|
|
120
159
|
source = table.concat(window, "\n"),
|
|
160
|
+
-- Of the whole file, never of the window: `edit` compares it against
|
|
161
|
+
-- the live source, and a fingerprint of forty lines out of four
|
|
162
|
+
-- hundred would call an edit safe that it is not.
|
|
163
|
+
revision = fingerprint(whole),
|
|
121
164
|
})
|
|
122
165
|
end
|
|
123
166
|
|
|
@@ -171,6 +214,41 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
|
|
|
171
214
|
local pending: { Pending } = {}
|
|
172
215
|
for _, target in order do
|
|
173
216
|
local before = ScriptEdit.read(target)
|
|
217
|
+
--[[
|
|
218
|
+
Refuse before transforming, not after.
|
|
219
|
+
|
|
220
|
+
An edit that names the revision it was written against is asking to
|
|
221
|
+
be applied to that exact text. If the file has moved on, the honest
|
|
222
|
+
answer is to stop: a line range still applies cleanly to changed
|
|
223
|
+
source, it just lands on the wrong lines, and a `source` edit throws
|
|
224
|
+
away everything written since it was read. Both look like success.
|
|
225
|
+
|
|
226
|
+
Checked here so the whole batch fails with nothing written, which is
|
|
227
|
+
the promise every other phase-one failure already makes.
|
|
228
|
+
]]
|
|
229
|
+
local live: string? = nil
|
|
230
|
+
for _, edit in grouped[target] :: { TextEdit.Edit } do
|
|
231
|
+
local stated = (edit :: any).revision
|
|
232
|
+
if typeof(stated) ~= "string" or stated == "" then
|
|
233
|
+
continue
|
|
234
|
+
end
|
|
235
|
+
live = live or fingerprint(before)
|
|
236
|
+
if stated ~= live then
|
|
237
|
+
Dispatch.fail(
|
|
238
|
+
"STALE_SCRIPT",
|
|
239
|
+
string.format(
|
|
240
|
+
"%s changed since it was read (expected %s, found %s).",
|
|
241
|
+
target:GetFullName(),
|
|
242
|
+
stated,
|
|
243
|
+
live :: string
|
|
244
|
+
),
|
|
245
|
+
"Somebody else edited it -- another agent, or the user typing in the "
|
|
246
|
+
.. "editor. Read it again with script_read and rebuild the edit "
|
|
247
|
+
.. "against what is there now."
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
174
252
|
local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
|
|
175
253
|
table.insert(pending, { target = target, before = before, after = after })
|
|
176
254
|
end
|