@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

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 (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -1,450 +1,722 @@
1
- --!strict
2
- --[[
3
- Solid modelling: union, subtract, intersect, fragment.
4
-
5
- `GeometryService` is the modern replacement for the old `BasePart:UnionAsync`
6
- pair, and nothing else in this space exposes it. It is how a shape that is
7
- not a box gets built without importing a mesh: cut a doorway out of a wall,
8
- round a corner off a platform, punch windows through a facade.
9
-
10
- Every operation returns *new* parts rather than editing in place, and the
11
- originals are left alone unless the caller asks for them to go. That is the
12
- engine's design and it is worth preserving: a subtraction that silently ate
13
- its inputs would make an unwanted result unrecoverable.
14
-
15
- The results carry the source part's appearance. Roblox hands back bare
16
- MeshParts with default material and colour, which for a wall cut from brick
17
- produces a grey slab where a brick wall was -- correct geometry that looks
18
- like a mistake, and the sort of thing a caller then has to notice and fix.
19
- ]]
20
-
21
- local GeometryService = game:GetService("GeometryService")
22
-
23
- local Dispatch = require(script.Parent.Parent.Dispatch)
24
- local Paths = require(script.Parent.Parent.Paths)
25
- local Undo = require(script.Parent.Parent.Undo)
26
-
27
- local Geometry = {}
28
-
29
- -- Appearance carried from the source part onto every result.
30
- local CARRIED = {
31
- "Material", "Color", "Transparency", "Reflectance", "CastShadow",
32
- "Anchored", "CanCollide", "CanTouch", "CanQuery", "CollisionGroup",
33
- }
34
-
35
- local OPERATIONS: { [string]: string } = {
36
- union = "UnionAsync",
37
- subtract = "SubtractAsync",
38
- intersect = "IntersectAsync",
39
- }
40
-
41
- --[[
42
- Copies the look of the original onto a result.
43
-
44
- Guarded per property: the results are MeshParts and the source may be any
45
- BasePart, so a property that does not exist on one of them should cost that
46
- property rather than the whole operation.
47
- ]]
48
- local function carryAppearance(source: BasePart, target: BasePart)
49
- for _, property in CARRIED do
50
- pcall(function()
51
- (target :: any)[property] = (source :: any)[property]
52
- end)
53
- end
54
- end
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
-
67
- local function resolveParts(paths: any, what: string): { BasePart }
68
- if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
69
- Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
70
- end
71
- local parts: { BasePart } = {}
72
- for _, path in paths :: { string } do
73
- local instance = Paths.resolve(path)
74
- if not instance:IsA("BasePart") then
75
- Dispatch.fail(
76
- "NOT_A_PART",
77
- string.format("%s is a %s, not a part.", path, instance.ClassName)
78
- )
79
- end
80
- table.insert(parts, instance :: BasePart)
81
- end
82
- return parts
83
- end
84
-
85
- function Geometry.combine(params: { [string]: any }): { [string]: any }
86
- local op = tostring(params.op or "union")
87
- local method = OPERATIONS[op]
88
- if method == nil then
89
- Dispatch.fail("BAD_PARAMS", string.format("unknown geometry op %q", op))
90
- end
91
-
92
- local subject = resolveParts({ params.path }, "a `path`")[1]
93
- local others = resolveParts(params.with, "a `with` list of parts")
94
-
95
- -- CollisionFidelity defaults to Default rather than Precise: precise
96
- -- collision on a heavily-cut mesh is expensive, and a caller who needs it
97
- -- for a walkable surface can say so.
98
- local options = {
99
- CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
100
- or Enum.CollisionFidelity.Default,
101
- RenderFidelity = Enum.RenderFidelity[tostring(params.renderFidelity or "Automatic")]
102
- or Enum.RenderFidelity.Automatic,
103
- SplitApart = params.splitApart == true,
104
- }
105
-
106
- local ok, results = pcall(function()
107
- return (GeometryService :: any)[method](GeometryService, subject, others, options)
108
- end)
109
- if not ok then
110
- Dispatch.fail(
111
- "GEOMETRY_FAILED",
112
- string.format("%s refused: %s", method, tostring(results)),
113
- "Parts must overlap for subtract and intersect to produce anything."
114
- )
115
- end
116
-
117
- -- Same unwrapping guard as fragment, for the same reason: these three do
118
- -- return parts directly today, but nothing documents that, and the cost of
119
- -- being wrong is destroying the inputs and creating nothing.
120
- local produced: { BasePart } = {}
121
- for _, entry in results :: { any } do
122
- local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
123
- if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
124
- table.insert(produced, instance :: BasePart)
125
- end
126
- end
127
-
128
- if #produced == 0 then
129
- -- Not an error at the API level, but almost never what was wanted: it
130
- -- means the solids did not overlap the way the caller assumed.
131
- Dispatch.fail(
132
- "EMPTY_RESULT",
133
- string.format("%s produced no parts.", op),
134
- "The parts probably do not overlap. Check their positions with `inspect`."
135
- )
136
- end
137
-
138
- local parent = if typeof(params.parent) == "string" and params.parent ~= ""
139
- then Paths.resolve(params.parent)
140
- else subject.Parent
141
-
142
- local created: { string } = {}
143
- local removed: { string } = {}
144
-
145
- local _, undoable = Undo.record("MCPGeometry", "MCP " .. op, function()
146
- for index, part in produced do
147
- carryAppearance(subject, part)
148
- part.Name = if typeof(params.name) == "string" and params.name ~= ""
149
- then (if #produced == 1 then params.name else string.format("%s%d", params.name, index))
150
- else subject.Name
151
- part.Parent = parent
152
- end
153
-
154
- -- Removing the inputs is opt-in, and only once the results exist: an
155
- -- operation that destroyed them first and then failed would leave
156
- -- nothing to recover.
157
- if params.keepOriginals ~= true then
158
- for _, part in { subject, table.unpack(others) } do
159
- table.insert(removed, Paths.of(part))
160
- part:Destroy()
161
- end
162
- end
163
-
164
- --[[
165
- Paths for the produced parts are read only now, after the originals
166
- are gone rather than in the loop above that named and parented them.
167
-
168
- A result keeps the subject's name by default, so for the entire
169
- window before the destroy above runs, the new part and the subject
170
- it was built from are two same-named siblings in the same parent --
171
- and Paths.of correctly, but uselessly, disambiguates that with a
172
- `[2]` suffix that stops being true the instant the destroy runs a
173
- few lines later. The caller reads this path after the call returns,
174
- by which point the disambiguation was already wrong. Reading it here
175
- instead describes where the part actually is once nothing further
176
- in this function is going to move or remove anything -- correct
177
- whether or not `keepOriginals` left a real, permanent collision for
178
- it to still be disambiguating.
179
- ]]
180
- for _, part in produced do
181
- table.insert(created, Paths.of(part))
182
- end
183
- end)
184
-
185
- return { created = created, removed = removed, undoable = undoable }
186
- end
187
-
188
- --[[
189
- Breaks a part into pieces, for destruction and debris.
190
-
191
- Sites are where the fractures radiate from. Left unspecified the engine
192
- picks them, which is what most callers want; naming them is for aiming a
193
- break at a point of impact.
194
- ]]
195
- function Geometry.fragment(params: { [string]: any }): { [string]: any }
196
- local subject = resolveParts({ params.path }, "a `path`")[1]
197
- local count = math.clamp(tonumber(params.pieces) or 8, 2, 100)
198
-
199
- local sites: { Vector3 } = {}
200
- local okSites, generated = pcall(function()
201
- return (GeometryService :: any):GenerateFragmentSites(subject, { Count = count })
202
- end)
203
- if okSites and typeof(generated) == "table" then
204
- sites = generated :: { Vector3 }
205
- else
206
- -- Fall back to points scattered inside the part's own volume, so a
207
- -- missing helper costs randomness rather than the whole feature.
208
- for _ = 1, count do
209
- table.insert(
210
- sites,
211
- subject.Position
212
- + Vector3.new(
213
- (math.random() - 0.5) * subject.Size.X,
214
- (math.random() - 0.5) * subject.Size.Y,
215
- (math.random() - 0.5) * subject.Size.Z
216
- )
217
- )
218
- end
219
- end
220
-
221
- local ok, results = pcall(function()
222
- return (GeometryService :: any):FragmentAsync(subject, sites, {})
223
- end)
224
- if not ok then
225
- Dispatch.fail("GEOMETRY_FAILED", string.format("FragmentAsync refused: %s", tostring(results)))
226
- end
227
-
228
- --[[
229
- `FragmentAsync` does not return parts. It returns wrappers:
230
-
231
- { Index = 1, Instance = MeshPart }
232
-
233
- which is unlike `UnionAsync` and its siblings, which hand back the parts
234
- themselves. Reading them as parts is silently destructive rather than
235
- merely wrong, and this file did exactly that: `piece.Parent = folder` set
236
- a key on a Lua table, changing nothing in the data model, while
237
- `Paths.of` then walked that table's freshly-assigned `Name` and `Parent`
238
- fields and produced a completely plausible path -- so the call reported
239
- eight new parts by name, created none of them, and destroyed the
240
- original. Measured, after the pieces failed to appear in a tree listing.
241
-
242
- Hence the check below rather than a cast: this shape is undocumented, so
243
- the next engine update changing it should stop the tool loudly instead of
244
- eating someone's geometry again.
245
- ]]
246
- local produced: { BasePart } = {}
247
- for _, entry in results :: { any } do
248
- local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
249
- if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
250
- table.insert(produced, instance :: BasePart)
251
- end
252
- end
253
-
254
- if #produced == 0 then
255
- -- Refused before the subject is touched, so a shape this file no longer
256
- -- understands costs the operation and not the part.
257
- Dispatch.fail(
258
- "GEOMETRY_FAILED",
259
- string.format(
260
- "FragmentAsync returned %d result(s) but none were parts this version understands.",
261
- #(results :: { any })
262
- ),
263
- "The original is untouched. This usually means the engine's return shape changed."
264
- )
265
- end
266
- local parent = subject.Parent
267
- local created: { string } = {}
268
-
269
- local _, undoable = Undo.record("MCPFragment", "MCP fragment", function()
270
- for index, part in produced do
271
- carryAppearance(subject, part)
272
- part.Name = string.format("%s_%d", subject.Name, index)
273
- part.Parent = parent
274
- table.insert(created, Paths.of(part))
275
- end
276
- if params.keepOriginals ~= true then
277
- subject:Destroy()
278
- end
279
- end)
280
-
281
- return { created = created, pieces = #produced, undoable = undoable }
282
- end
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
-
442
- function Geometry.register()
443
- Dispatch.registerAll("geometry", {
444
- combine = Geometry.combine,
445
- fragment = Geometry.fragment,
446
- sweep = Geometry.sweep,
447
- })
448
- end
449
-
450
- return Geometry
1
+ --!strict
2
+ --[[
3
+ Solid modelling: union, subtract, intersect, fragment.
4
+
5
+ `GeometryService` is the modern replacement for the old `BasePart:UnionAsync`
6
+ pair, and nothing else in this space exposes it. It is how a shape that is
7
+ not a box gets built without importing a mesh: cut a doorway out of a wall,
8
+ round a corner off a platform, punch windows through a facade.
9
+
10
+ Every operation returns *new* parts rather than editing in place, and the
11
+ originals are left alone unless the caller asks for them to go. That is the
12
+ engine's design and it is worth preserving: a subtraction that silently ate
13
+ its inputs would make an unwanted result unrecoverable.
14
+
15
+ The results carry the source part's appearance. Roblox hands back bare
16
+ MeshParts with default material and colour, which for a wall cut from brick
17
+ produces a grey slab where a brick wall was -- correct geometry that looks
18
+ like a mistake, and the sort of thing a caller then has to notice and fix.
19
+ ]]
20
+
21
+ local GeometryService = game:GetService("GeometryService")
22
+
23
+ local AssetService = game:GetService("AssetService")
24
+
25
+ local Dispatch = require(script.Parent.Parent.Dispatch)
26
+ local Paths = require(script.Parent.Parent.Paths)
27
+ local Serialize = require(script.Parent.Parent.Serialize)
28
+ local Undo = require(script.Parent.Parent.Undo)
29
+
30
+ local Geometry = {}
31
+
32
+ -- Appearance carried from the source part onto every result.
33
+ local CARRIED = {
34
+ "Material", "Color", "Transparency", "Reflectance", "CastShadow",
35
+ "Anchored", "CanCollide", "CanTouch", "CanQuery", "CollisionGroup",
36
+ }
37
+
38
+ local OPERATIONS: { [string]: string } = {
39
+ union = "UnionAsync",
40
+ subtract = "SubtractAsync",
41
+ intersect = "IntersectAsync",
42
+ }
43
+
44
+ --[[
45
+ Copies the look of the original onto a result.
46
+
47
+ Guarded per property: the results are MeshParts and the source may be any
48
+ BasePart, so a property that does not exist on one of them should cost that
49
+ property rather than the whole operation.
50
+ ]]
51
+ local function carryAppearance(source: BasePart, target: BasePart)
52
+ for _, property in CARRIED do
53
+ pcall(function()
54
+ (target :: any)[property] = (source :: any)[property]
55
+ end)
56
+ end
57
+ end
58
+
59
+ local function parseVector(text: any): Vector3?
60
+ if typeof(text) ~= "string" or text == "" then
61
+ return nil
62
+ end
63
+ local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
64
+ if x == nil then
65
+ return nil
66
+ end
67
+ return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
68
+ end
69
+
70
+ local function resolveParts(paths: any, what: string): { BasePart }
71
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
72
+ Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
73
+ end
74
+ local parts: { BasePart } = {}
75
+ for _, path in paths :: { string } do
76
+ local instance = Paths.resolve(path)
77
+ if not instance:IsA("BasePart") then
78
+ Dispatch.fail(
79
+ "NOT_A_PART",
80
+ string.format("%s is a %s, not a part.", path, instance.ClassName)
81
+ )
82
+ end
83
+ table.insert(parts, instance :: BasePart)
84
+ end
85
+ return parts
86
+ end
87
+
88
+ function Geometry.combine(params: { [string]: any }): { [string]: any }
89
+ local op = tostring(params.op or "union")
90
+ local method = OPERATIONS[op]
91
+ if method == nil then
92
+ Dispatch.fail("BAD_PARAMS", string.format("unknown geometry op %q", op))
93
+ end
94
+
95
+ local subject = resolveParts({ params.path }, "a `path`")[1]
96
+ local others = resolveParts(params.with, "a `with` list of parts")
97
+
98
+ -- CollisionFidelity defaults to Default rather than Precise: precise
99
+ -- collision on a heavily-cut mesh is expensive, and a caller who needs it
100
+ -- for a walkable surface can say so.
101
+ local options = {
102
+ CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
103
+ or Enum.CollisionFidelity.Default,
104
+ RenderFidelity = Enum.RenderFidelity[tostring(params.renderFidelity or "Automatic")]
105
+ or Enum.RenderFidelity.Automatic,
106
+ SplitApart = params.splitApart == true,
107
+ }
108
+
109
+ local ok, results = pcall(function()
110
+ return (GeometryService :: any)[method](GeometryService, subject, others, options)
111
+ end)
112
+ if not ok then
113
+ Dispatch.fail(
114
+ "GEOMETRY_FAILED",
115
+ string.format("%s refused: %s", method, tostring(results)),
116
+ "Parts must overlap for subtract and intersect to produce anything."
117
+ )
118
+ end
119
+
120
+ -- Same unwrapping guard as fragment, for the same reason: these three do
121
+ -- return parts directly today, but nothing documents that, and the cost of
122
+ -- being wrong is destroying the inputs and creating nothing.
123
+ local produced: { BasePart } = {}
124
+ for _, entry in results :: { any } do
125
+ local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
126
+ if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
127
+ table.insert(produced, instance :: BasePart)
128
+ end
129
+ end
130
+
131
+ if #produced == 0 then
132
+ -- Not an error at the API level, but almost never what was wanted: it
133
+ -- means the solids did not overlap the way the caller assumed.
134
+ Dispatch.fail(
135
+ "EMPTY_RESULT",
136
+ string.format("%s produced no parts.", op),
137
+ "The parts probably do not overlap. Check their positions with `inspect`."
138
+ )
139
+ end
140
+
141
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
142
+ then Paths.resolve(params.parent)
143
+ else subject.Parent
144
+
145
+ local created: { string } = {}
146
+ local removed: { string } = {}
147
+
148
+ local _, undoable = Undo.record("MCPGeometry", "MCP " .. op, function()
149
+ for index, part in produced do
150
+ carryAppearance(subject, part)
151
+ part.Name = if typeof(params.name) == "string" and params.name ~= ""
152
+ then (if #produced == 1 then params.name else string.format("%s%d", params.name, index))
153
+ else subject.Name
154
+ part.Parent = parent
155
+ end
156
+
157
+ -- Removing the inputs is opt-in, and only once the results exist: an
158
+ -- operation that destroyed them first and then failed would leave
159
+ -- nothing to recover.
160
+ if params.keepOriginals ~= true then
161
+ for _, part in { subject, table.unpack(others) } do
162
+ table.insert(removed, Paths.of(part))
163
+ part:Destroy()
164
+ end
165
+ end
166
+
167
+ --[[
168
+ Paths for the produced parts are read only now, after the originals
169
+ are gone rather than in the loop above that named and parented them.
170
+
171
+ A result keeps the subject's name by default, so for the entire
172
+ window before the destroy above runs, the new part and the subject
173
+ it was built from are two same-named siblings in the same parent --
174
+ and Paths.of correctly, but uselessly, disambiguates that with a
175
+ `[2]` suffix that stops being true the instant the destroy runs a
176
+ few lines later. The caller reads this path after the call returns,
177
+ by which point the disambiguation was already wrong. Reading it here
178
+ instead describes where the part actually is once nothing further
179
+ in this function is going to move or remove anything -- correct
180
+ whether or not `keepOriginals` left a real, permanent collision for
181
+ it to still be disambiguating.
182
+ ]]
183
+ for _, part in produced do
184
+ table.insert(created, Paths.of(part))
185
+ end
186
+ end)
187
+
188
+ return { created = created, removed = removed, undoable = undoable }
189
+ end
190
+
191
+ --[[
192
+ Breaks a part into pieces, for destruction and debris.
193
+
194
+ Sites are where the fractures radiate from. Left unspecified the engine
195
+ picks them, which is what most callers want; naming them is for aiming a
196
+ break at a point of impact.
197
+ ]]
198
+ function Geometry.fragment(params: { [string]: any }): { [string]: any }
199
+ local subject = resolveParts({ params.path }, "a `path`")[1]
200
+ local count = math.clamp(tonumber(params.pieces) or 8, 2, 100)
201
+
202
+ local sites: { Vector3 } = {}
203
+ local okSites, generated = pcall(function()
204
+ return (GeometryService :: any):GenerateFragmentSites(subject, { Count = count })
205
+ end)
206
+ if okSites and typeof(generated) == "table" then
207
+ sites = generated :: { Vector3 }
208
+ else
209
+ -- Fall back to points scattered inside the part's own volume, so a
210
+ -- missing helper costs randomness rather than the whole feature.
211
+ for _ = 1, count do
212
+ table.insert(
213
+ sites,
214
+ subject.Position
215
+ + Vector3.new(
216
+ (math.random() - 0.5) * subject.Size.X,
217
+ (math.random() - 0.5) * subject.Size.Y,
218
+ (math.random() - 0.5) * subject.Size.Z
219
+ )
220
+ )
221
+ end
222
+ end
223
+
224
+ local ok, results = pcall(function()
225
+ return (GeometryService :: any):FragmentAsync(subject, sites, {})
226
+ end)
227
+ if not ok then
228
+ Dispatch.fail("GEOMETRY_FAILED", string.format("FragmentAsync refused: %s", tostring(results)))
229
+ end
230
+
231
+ --[[
232
+ `FragmentAsync` does not return parts. It returns wrappers:
233
+
234
+ { Index = 1, Instance = MeshPart }
235
+
236
+ which is unlike `UnionAsync` and its siblings, which hand back the parts
237
+ themselves. Reading them as parts is silently destructive rather than
238
+ merely wrong, and this file did exactly that: `piece.Parent = folder` set
239
+ a key on a Lua table, changing nothing in the data model, while
240
+ `Paths.of` then walked that table's freshly-assigned `Name` and `Parent`
241
+ fields and produced a completely plausible path -- so the call reported
242
+ eight new parts by name, created none of them, and destroyed the
243
+ original. Measured, after the pieces failed to appear in a tree listing.
244
+
245
+ Hence the check below rather than a cast: this shape is undocumented, so
246
+ the next engine update changing it should stop the tool loudly instead of
247
+ eating someone's geometry again.
248
+ ]]
249
+ local produced: { BasePart } = {}
250
+ for _, entry in results :: { any } do
251
+ local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
252
+ if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
253
+ table.insert(produced, instance :: BasePart)
254
+ end
255
+ end
256
+
257
+ if #produced == 0 then
258
+ -- Refused before the subject is touched, so a shape this file no longer
259
+ -- understands costs the operation and not the part.
260
+ Dispatch.fail(
261
+ "GEOMETRY_FAILED",
262
+ string.format(
263
+ "FragmentAsync returned %d result(s) but none were parts this version understands.",
264
+ #(results :: { any })
265
+ ),
266
+ "The original is untouched. This usually means the engine's return shape changed."
267
+ )
268
+ end
269
+ local parent = subject.Parent
270
+ local created: { string } = {}
271
+
272
+ local _, undoable = Undo.record("MCPFragment", "MCP fragment", function()
273
+ for index, part in produced do
274
+ carryAppearance(subject, part)
275
+ part.Name = string.format("%s_%d", subject.Name, index)
276
+ part.Parent = parent
277
+ table.insert(created, Paths.of(part))
278
+ end
279
+ if params.keepOriginals ~= true then
280
+ subject:Destroy()
281
+ end
282
+ end)
283
+
284
+ return { created = created, pieces = #produced, undoable = undoable }
285
+ end
286
+
287
+ --[[
288
+ Builds the motion to sweep along, as a list of CFrames.
289
+
290
+ Three ways to say it, in the order they are checked. `positions` is the
291
+ escape hatch; `to` is a slide; `spin` is a hinge, which is the case worth
292
+ having -- a door, a hatch, a drawbridge. All three are sampled rather than
293
+ solved, so `steps` trades accuracy for time: the volume is the hull of the
294
+ samples, and too few of them on a wide arc cuts the corners off the swing.
295
+ ]]
296
+ local function motionOf(subject: BasePart, params: { [string]: any }): { CFrame }
297
+ local start = subject.CFrame
298
+ local steps = math.clamp(tonumber(params.steps) or 12, 2, 64)
299
+
300
+ local positions = params.positions
301
+ if typeof(positions) == "table" and #(positions :: { any }) > 0 then
302
+ local frames: { CFrame } = {}
303
+ for _, entry in positions :: { any } do
304
+ local at = parseVector(entry)
305
+ if at == nil then
306
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" position.", tostring(entry)))
307
+ end
308
+ table.insert(frames, start.Rotation + (at :: Vector3))
309
+ end
310
+ return frames
311
+ end
312
+
313
+ local spin = tonumber(params.spin)
314
+ if spin ~= nil and spin ~= 0 then
315
+ local axis = parseVector(params.axis) or Vector3.yAxis
316
+ if axis.Magnitude == 0 then
317
+ Dispatch.fail("BAD_PARAMS", "`axis` cannot be zero.")
318
+ end
319
+ -- The pivot is the hinge. Defaulting it to the part's own centre makes
320
+ -- the part spin in place, which is right for a wheel and wrong for a
321
+ -- door -- so a door has to name its hinge edge.
322
+ local pivot = parseVector(params.pivot) or subject.Position
323
+ local frames: { CFrame } = {}
324
+ for index = 0, steps - 1 do
325
+ local angle = math.rad(spin) * (index / (steps - 1))
326
+ local turn = CFrame.fromAxisAngle(axis.Unit, angle)
327
+ table.insert(frames, CFrame.new(pivot) * turn * CFrame.new(-pivot) * start)
328
+ end
329
+ return frames
330
+ end
331
+
332
+ local destination = parseVector(params.to)
333
+ if destination == nil then
334
+ Dispatch.fail(
335
+ "BAD_PARAMS",
336
+ "sweep needs a motion.",
337
+ "Give `to` for a slide, `spin` (with `pivot`) for a hinge, or `positions` for a path."
338
+ )
339
+ end
340
+
341
+ local frames: { CFrame } = {}
342
+ for index = 0, steps - 1 do
343
+ local alpha = index / (steps - 1)
344
+ table.insert(frames, start:Lerp(start.Rotation + (destination :: Vector3), alpha))
345
+ end
346
+ return frames
347
+ end
348
+
349
+ --[[
350
+ The volume a part passes through as it moves.
351
+
352
+ The question this answers -- "does the door hit the wall when it opens" --
353
+ has no good answer from the data model. Positions and sizes describe where
354
+ things are, not where something will be on its way somewhere else, and the
355
+ usual workaround is to move the part in steps and test at each one, which
356
+ misses anything thin enough to sit between two samples.
357
+
358
+ `checkAgainst` is why this is worth calling rather than just building the
359
+ volume: the swept solid is put in the world for a moment and asked what it
360
+ overlaps. Left on (`keep`) it stays as a part; turned off it is destroyed
361
+ once measured, so a clearance check leaves nothing behind.
362
+ ]]
363
+ function Geometry.sweep(params: { [string]: any }): { [string]: any }
364
+ local subject = resolveParts({ params.path }, "a `path`")[1]
365
+ local frames = motionOf(subject, params)
366
+
367
+ local ok, volume = pcall(function()
368
+ return (GeometryService :: any):SweepPartAsync(subject, frames, {
369
+ CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
370
+ or Enum.CollisionFidelity.Default,
371
+ })
372
+ end)
373
+ if not ok or typeof(volume) ~= "Instance" then
374
+ Dispatch.fail(
375
+ "GEOMETRY_FAILED",
376
+ string.format("SweepPartAsync refused: %s", tostring(volume)),
377
+ "The part must be a solid Studio can sweep. Terrain and some mesh shapes are refused."
378
+ )
379
+ end
380
+
381
+ local swept = volume :: BasePart
382
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
383
+ then Paths.resolve(params.parent)
384
+ else subject.Parent
385
+ local keep = params.keep ~= false
386
+
387
+ local hits: { string } = {}
388
+ local path = ""
389
+
390
+ local _, undoable = Undo.record("MCPSweep", "MCP sweep", function()
391
+ carryAppearance(subject, swept)
392
+ swept.Name = if typeof(params.name) == "string" and params.name ~= ""
393
+ then params.name
394
+ else subject.Name .. "_Sweep"
395
+ swept.Anchored = true
396
+ swept.CanCollide = false
397
+ swept.Transparency = tonumber(params.transparency) or 0.5
398
+ swept.Parent = parent
399
+
400
+ --[[
401
+ Measured while the volume is in the world, because that is the only
402
+ state the spatial query can read. The subject is excluded: a part
403
+ always overlaps its own swept volume, and reporting that would bury
404
+ the answer under the one hit nobody asked about.
405
+ ]]
406
+ local against = params.checkAgainst
407
+ if against ~= nil then
408
+ local overlap = OverlapParams.new()
409
+ overlap.MaxParts = 100
410
+ local named = if typeof(against) == "table" then against :: { any } else {}
411
+ if #named > 0 then
412
+ -- A named list is the question "does it hit THESE", so anything
413
+ -- else in the way is not an answer.
414
+ local only, _missing = Paths.resolveMany(named :: { string })
415
+ overlap.FilterType = Enum.RaycastFilterType.Include
416
+ overlap.FilterDescendantsInstances = only
417
+ else
418
+ overlap.FilterType = Enum.RaycastFilterType.Exclude
419
+ overlap.FilterDescendantsInstances = { subject, swept }
420
+ end
421
+ for _, found in workspace:GetPartsInPart(swept, overlap) do
422
+ if found ~= subject and found ~= swept then
423
+ table.insert(hits, Paths.of(found))
424
+ end
425
+ end
426
+ end
427
+
428
+ if keep then
429
+ path = Paths.of(swept)
430
+ else
431
+ swept:Destroy()
432
+ end
433
+ end)
434
+
435
+ return {
436
+ created = if keep then { path } else {},
437
+ hits = hits,
438
+ checked = params.checkAgainst ~= nil,
439
+ frames = #frames,
440
+ kept = keep,
441
+ undoable = undoable,
442
+ }
443
+ end
444
+
445
+ --[[
446
+ What a mesh is actually made of.
447
+
448
+ `inspect` on a MeshPart returns a content id, a size and a collision
449
+ fidelity, and none of those answer the question anyone has about a mesh,
450
+ which is "why is this place slow". A tree that renders as 40,000 triangles
451
+ and one that renders as 400 look identical in the Explorer and identical in
452
+ the Properties panel, and the difference between them is the difference
453
+ between a place that runs on a phone and one that does not.
454
+
455
+ `CreateEditableMeshAsync` opens the real geometry. It is read here and then
456
+ dropped -- nothing is modified, nothing is saved, and the editable copy is
457
+ destroyed before the reply is built, because an EditableMesh holds its data
458
+ outside Luau's heap and inspecting a hundred parts should not accumulate a
459
+ hundred copies of them.
460
+ ]]
461
+ function Geometry.mesh(params: { [string]: any }): { [string]: any }
462
+ local paths = params.paths
463
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
464
+ Dispatch.fail(
465
+ "BAD_PARAMS",
466
+ "mesh needs a non-empty `paths` array.",
467
+ 'Use `find` with selector "MeshPart" to list them.'
468
+ )
469
+ end
470
+
471
+ local items: { { [string]: any } } = {}
472
+ local failures: { string } = {}
473
+ local totalTriangles = 0
474
+
475
+ for _, path in paths :: { string } do
476
+ local okResolve, resolved = pcall(Paths.resolve, path)
477
+ if not okResolve then
478
+ table.insert(failures, string.format("%s: not found", tostring(path)))
479
+ continue
480
+ end
481
+
482
+ local instance = resolved :: Instance
483
+ if not instance:IsA("MeshPart") then
484
+ table.insert(
485
+ failures,
486
+ string.format("%s: is a %s, not a MeshPart", tostring(path), instance.ClassName)
487
+ )
488
+ continue
489
+ end
490
+
491
+ local part = instance :: MeshPart
492
+ local okMesh, mesh = pcall(function()
493
+ return (AssetService :: any):CreateEditableMeshAsync((part :: any).MeshContent)
494
+ end)
495
+ if not okMesh then
496
+ --[[
497
+ Ownership, not a bug, and worth saying so in those words.
498
+
499
+ `CreateEditableMeshAsync` will only open a mesh the signed-in
500
+ Studio user owns, that the experience owner owns, or that has been
501
+ explicitly shared with one of them. Every other mesh on the
502
+ platform -- including anything inserted from the Creator Store --
503
+ refuses with "no permission to load asset", which reads like a
504
+ broken tool rather than a rule about whose asset it is. Measured:
505
+ a catalog accessory's mesh fails this way while a mesh uploaded by
506
+ the user opens fine.
507
+ ]]
508
+ local reason = tostring(mesh)
509
+ if string.find(reason, "permission", 1, true) then
510
+ table.insert(
511
+ failures,
512
+ string.format(
513
+ "%s: its mesh belongs to someone else, so Roblox will not open it. "
514
+ .. "Only meshes owned by the signed-in Studio user or the experience "
515
+ .. "owner can be read this way.",
516
+ tostring(path)
517
+ )
518
+ )
519
+ else
520
+ table.insert(
521
+ failures,
522
+ string.format("%s: could not read its geometry (%s)", tostring(path), reason)
523
+ )
524
+ end
525
+ continue
526
+ end
527
+
528
+ local editable = mesh :: any
529
+ local okRead, row = pcall(function()
530
+ local vertices = #editable:GetVertices()
531
+ local faces = #editable:GetFaces()
532
+ local size = editable:GetSize()
533
+ return {
534
+ path = Paths.of(part),
535
+ name = part.Name,
536
+ vertices = vertices,
537
+ triangles = faces,
538
+ --[[
539
+ The mesh's own size against the size it is displayed at. A
540
+ large ratio is the usual cause of a mesh that looks fine and
541
+ costs far more than it should: the same triangles stretched
542
+ over a bigger object, or shrunk into one nobody can see.
543
+ ]]
544
+ meshSize = string.format("%.2f, %.2f, %.2f", size.X, size.Y, size.Z),
545
+ partSize = string.format("%.2f, %.2f, %.2f", part.Size.X, part.Size.Y, part.Size.Z),
546
+ collisionFidelity = tostring(part.CollisionFidelity):gsub("Enum%.CollisionFidelity%.", ""),
547
+ renderFidelity = tostring(part.RenderFidelity):gsub("Enum%.RenderFidelity%.", ""),
548
+ }
549
+ end)
550
+
551
+ -- Freed before anything else happens with the result, on both paths.
552
+ pcall(function()
553
+ editable:Destroy()
554
+ end)
555
+
556
+ if not okRead then
557
+ table.insert(failures, string.format("%s: %s", tostring(path), tostring(row)))
558
+ continue
559
+ end
560
+
561
+ local entry = row :: { [string]: any }
562
+ totalTriangles += tonumber(entry.triangles) or 0
563
+ table.insert(items, entry)
564
+ end
565
+
566
+ return { items = items, failures = failures, totalTriangles = totalTriangles }
567
+ end
568
+
569
+ --[[
570
+ Mirrors instances across a plane.
571
+
572
+ No engine API does this -- it is the most-asked-for missing Studio tool
573
+ after script-free inserts -- so it is arithmetic here.
574
+
575
+ Position is the easy half: reflect the one component across the plane. The
576
+ rotation is where naive implementations go wrong. Reflecting all three basis
577
+ vectors produces a LEFT-handed matrix, which is not a rotation at all;
578
+ `CFrame.fromMatrix` either refuses it or silently returns something bent.
579
+ Negating one reflected vector restores right-handedness, and the geometric
580
+ meaning of that negation is exactly "the part is now facing the other way",
581
+ which is what mirroring a thing means.
582
+
583
+ Asymmetric shapes are the honest limitation: a Wedge mirrored this way is a
584
+ wedge pointing the other way, which is right, but a MeshPart is not remade --
585
+ its mesh still has whatever handedness it was authored with. Said in the
586
+ result rather than left to be discovered.
587
+ ]]
588
+ local function reflectVector(v: Vector3, axis: string): Vector3
589
+ if axis == "X" then
590
+ return Vector3.new(-v.X, v.Y, v.Z)
591
+ elseif axis == "Y" then
592
+ return Vector3.new(v.X, -v.Y, v.Z)
593
+ end
594
+ return Vector3.new(v.X, v.Y, -v.Z)
595
+ end
596
+
597
+ local function mirrorCFrame(cf: CFrame, axis: string, centre: Vector3): CFrame
598
+ local offset = cf.Position - centre
599
+ local position = centre + reflectVector(offset, axis)
600
+
601
+ -- One vector negated after reflection: without it the basis is left-handed
602
+ -- and no longer describes a rotation.
603
+ local right = -reflectVector(cf.RightVector, axis)
604
+ local up = reflectVector(cf.UpVector, axis)
605
+ return CFrame.fromMatrix(position, right, up)
606
+ end
607
+
608
+ function Geometry.mirror(params: { [string]: any }): { [string]: any }
609
+ local paths = params.paths
610
+ if typeof(paths) ~= "table" or #paths == 0 then
611
+ Dispatch.fail("BAD_PARAMS", "mirror needs `paths`.")
612
+ end
613
+
614
+ local axis = string.upper(tostring(params.axis or "X"))
615
+ if axis ~= "X" and axis ~= "Y" and axis ~= "Z" then
616
+ Dispatch.fail("BAD_PARAMS", string.format("unknown axis %q", axis), 'Use "X", "Y" or "Z".')
617
+ end
618
+
619
+ local resolved, missing = Paths.resolveMany(paths :: { string })
620
+ if #missing > 0 then
621
+ Dispatch.fail("NOT_FOUND", string.format("Could not resolve: %s", table.concat(missing, ", ")))
622
+ end
623
+
624
+ --[[
625
+ The plane defaults to the middle of what is being mirrored.
626
+
627
+ Mirroring about the world origin is almost never what someone means: a
628
+ building at x=200 mirrored about x=0 lands 400 studs away, off the map.
629
+ Mirroring about its own centre flips it in place, which is what "mirror
630
+ this" means when pointing at a thing.
631
+ ]]
632
+ local centre: Vector3
633
+ if typeof(params.about) == "string" and params.about ~= "" then
634
+ local ok, parsed = Serialize.parse(params.about, "Vector3")
635
+ if not ok or typeof(parsed) ~= "Vector3" then
636
+ Dispatch.fail("BAD_PARAMS", '`about` is a position, e.g. "0, 0, 0".')
637
+ end
638
+ centre = parsed :: Vector3
639
+ else
640
+ local total = Vector3.zero
641
+ local counted = 0
642
+ for _, instance in resolved do
643
+ local pivot: CFrame? = nil
644
+ if instance:IsA("Model") then
645
+ pivot = (instance :: Model):GetPivot()
646
+ elseif instance:IsA("BasePart") then
647
+ pivot = (instance :: BasePart).CFrame
648
+ end
649
+ if pivot then
650
+ total += pivot.Position
651
+ counted += 1
652
+ end
653
+ end
654
+ centre = if counted > 0 then total / counted else Vector3.zero
655
+ end
656
+
657
+ local copy = params.copy ~= false
658
+ local results: { string } = {}
659
+ local skipped: { string } = {}
660
+ local meshWarning = false
661
+
662
+ local _, undoable = Undo.record("MCPMirror", "Mirror", function()
663
+ for _, instance in resolved do
664
+ local subject = instance
665
+ if copy then
666
+ local ok, clone = pcall(function()
667
+ return instance:Clone()
668
+ end)
669
+ if not ok or clone == nil then
670
+ table.insert(skipped, string.format("%s could not be copied", Paths.of(instance)))
671
+ continue
672
+ end
673
+ clone.Parent = instance.Parent
674
+ subject = clone
675
+ end
676
+
677
+ if subject:IsA("Model") then
678
+ local model = subject :: Model
679
+ model:PivotTo(mirrorCFrame(model:GetPivot(), axis, centre))
680
+ elseif subject:IsA("BasePart") then
681
+ local part = subject :: BasePart
682
+ part.CFrame = mirrorCFrame(part.CFrame, axis, centre)
683
+ if part:IsA("MeshPart") then
684
+ meshWarning = true
685
+ end
686
+ else
687
+ table.insert(skipped, string.format("%s is a %s, which has no position", Paths.of(subject), subject.ClassName))
688
+ if copy then
689
+ subject:Destroy()
690
+ end
691
+ continue
692
+ end
693
+ table.insert(results, Paths.of(subject))
694
+ end
695
+ end)
696
+
697
+ return {
698
+ axis = axis,
699
+ about = Serialize.value(centre),
700
+ copied = copy,
701
+ items = results,
702
+ count = #results,
703
+ skipped = if #skipped > 0 then skipped else nil,
704
+ undoable = undoable,
705
+ note = if meshWarning
706
+ then "MeshParts were moved and rotated, but their meshes are not remade -- "
707
+ .. "a mesh authored asymmetrically still reads the same way round."
708
+ else nil,
709
+ }
710
+ end
711
+
712
+ function Geometry.register()
713
+ Dispatch.registerAll("geometry", {
714
+ mesh = Geometry.mesh,
715
+ combine = Geometry.combine,
716
+ fragment = Geometry.fragment,
717
+ sweep = Geometry.sweep,
718
+ mirror = Geometry.mirror,
719
+ })
720
+ end
721
+
722
+ return Geometry