@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.8

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 (46) 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 +4 -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 +290 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +133 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +56 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +124 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +182 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +65 -65
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -1,577 +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 AssetService = game:GetService("AssetService")
24
-
25
- local Dispatch = require(script.Parent.Parent.Dispatch)
26
- local Paths = require(script.Parent.Parent.Paths)
27
- local Undo = require(script.Parent.Parent.Undo)
28
-
29
- local Geometry = {}
30
-
31
- -- Appearance carried from the source part onto every result.
32
- local CARRIED = {
33
- "Material", "Color", "Transparency", "Reflectance", "CastShadow",
34
- "Anchored", "CanCollide", "CanTouch", "CanQuery", "CollisionGroup",
35
- }
36
-
37
- local OPERATIONS: { [string]: string } = {
38
- union = "UnionAsync",
39
- subtract = "SubtractAsync",
40
- intersect = "IntersectAsync",
41
- }
42
-
43
- --[[
44
- Copies the look of the original onto a result.
45
-
46
- Guarded per property: the results are MeshParts and the source may be any
47
- BasePart, so a property that does not exist on one of them should cost that
48
- property rather than the whole operation.
49
- ]]
50
- local function carryAppearance(source: BasePart, target: BasePart)
51
- for _, property in CARRIED do
52
- pcall(function()
53
- (target :: any)[property] = (source :: any)[property]
54
- end)
55
- end
56
- end
57
-
58
- local function parseVector(text: any): Vector3?
59
- if typeof(text) ~= "string" or text == "" then
60
- return nil
61
- end
62
- local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
63
- if x == nil then
64
- return nil
65
- end
66
- return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
67
- end
68
-
69
- local function resolveParts(paths: any, what: string): { BasePart }
70
- if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
71
- Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
72
- end
73
- local parts: { BasePart } = {}
74
- for _, path in paths :: { string } do
75
- local instance = Paths.resolve(path)
76
- if not instance:IsA("BasePart") then
77
- Dispatch.fail(
78
- "NOT_A_PART",
79
- string.format("%s is a %s, not a part.", path, instance.ClassName)
80
- )
81
- end
82
- table.insert(parts, instance :: BasePart)
83
- end
84
- return parts
85
- end
86
-
87
- function Geometry.combine(params: { [string]: any }): { [string]: any }
88
- local op = tostring(params.op or "union")
89
- local method = OPERATIONS[op]
90
- if method == nil then
91
- Dispatch.fail("BAD_PARAMS", string.format("unknown geometry op %q", op))
92
- end
93
-
94
- local subject = resolveParts({ params.path }, "a `path`")[1]
95
- local others = resolveParts(params.with, "a `with` list of parts")
96
-
97
- -- CollisionFidelity defaults to Default rather than Precise: precise
98
- -- collision on a heavily-cut mesh is expensive, and a caller who needs it
99
- -- for a walkable surface can say so.
100
- local options = {
101
- CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
102
- or Enum.CollisionFidelity.Default,
103
- RenderFidelity = Enum.RenderFidelity[tostring(params.renderFidelity or "Automatic")]
104
- or Enum.RenderFidelity.Automatic,
105
- SplitApart = params.splitApart == true,
106
- }
107
-
108
- local ok, results = pcall(function()
109
- return (GeometryService :: any)[method](GeometryService, subject, others, options)
110
- end)
111
- if not ok then
112
- Dispatch.fail(
113
- "GEOMETRY_FAILED",
114
- string.format("%s refused: %s", method, tostring(results)),
115
- "Parts must overlap for subtract and intersect to produce anything."
116
- )
117
- end
118
-
119
- -- Same unwrapping guard as fragment, for the same reason: these three do
120
- -- return parts directly today, but nothing documents that, and the cost of
121
- -- being wrong is destroying the inputs and creating nothing.
122
- local produced: { BasePart } = {}
123
- for _, entry in results :: { any } do
124
- local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
125
- if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
126
- table.insert(produced, instance :: BasePart)
127
- end
128
- end
129
-
130
- if #produced == 0 then
131
- -- Not an error at the API level, but almost never what was wanted: it
132
- -- means the solids did not overlap the way the caller assumed.
133
- Dispatch.fail(
134
- "EMPTY_RESULT",
135
- string.format("%s produced no parts.", op),
136
- "The parts probably do not overlap. Check their positions with `inspect`."
137
- )
138
- end
139
-
140
- local parent = if typeof(params.parent) == "string" and params.parent ~= ""
141
- then Paths.resolve(params.parent)
142
- else subject.Parent
143
-
144
- local created: { string } = {}
145
- local removed: { string } = {}
146
-
147
- local _, undoable = Undo.record("MCPGeometry", "MCP " .. op, function()
148
- for index, part in produced do
149
- carryAppearance(subject, part)
150
- part.Name = if typeof(params.name) == "string" and params.name ~= ""
151
- then (if #produced == 1 then params.name else string.format("%s%d", params.name, index))
152
- else subject.Name
153
- part.Parent = parent
154
- end
155
-
156
- -- Removing the inputs is opt-in, and only once the results exist: an
157
- -- operation that destroyed them first and then failed would leave
158
- -- nothing to recover.
159
- if params.keepOriginals ~= true then
160
- for _, part in { subject, table.unpack(others) } do
161
- table.insert(removed, Paths.of(part))
162
- part:Destroy()
163
- end
164
- end
165
-
166
- --[[
167
- Paths for the produced parts are read only now, after the originals
168
- are gone rather than in the loop above that named and parented them.
169
-
170
- A result keeps the subject's name by default, so for the entire
171
- window before the destroy above runs, the new part and the subject
172
- it was built from are two same-named siblings in the same parent --
173
- and Paths.of correctly, but uselessly, disambiguates that with a
174
- `[2]` suffix that stops being true the instant the destroy runs a
175
- few lines later. The caller reads this path after the call returns,
176
- by which point the disambiguation was already wrong. Reading it here
177
- instead describes where the part actually is once nothing further
178
- in this function is going to move or remove anything -- correct
179
- whether or not `keepOriginals` left a real, permanent collision for
180
- it to still be disambiguating.
181
- ]]
182
- for _, part in produced do
183
- table.insert(created, Paths.of(part))
184
- end
185
- end)
186
-
187
- return { created = created, removed = removed, undoable = undoable }
188
- end
189
-
190
- --[[
191
- Breaks a part into pieces, for destruction and debris.
192
-
193
- Sites are where the fractures radiate from. Left unspecified the engine
194
- picks them, which is what most callers want; naming them is for aiming a
195
- break at a point of impact.
196
- ]]
197
- function Geometry.fragment(params: { [string]: any }): { [string]: any }
198
- local subject = resolveParts({ params.path }, "a `path`")[1]
199
- local count = math.clamp(tonumber(params.pieces) or 8, 2, 100)
200
-
201
- local sites: { Vector3 } = {}
202
- local okSites, generated = pcall(function()
203
- return (GeometryService :: any):GenerateFragmentSites(subject, { Count = count })
204
- end)
205
- if okSites and typeof(generated) == "table" then
206
- sites = generated :: { Vector3 }
207
- else
208
- -- Fall back to points scattered inside the part's own volume, so a
209
- -- missing helper costs randomness rather than the whole feature.
210
- for _ = 1, count do
211
- table.insert(
212
- sites,
213
- subject.Position
214
- + Vector3.new(
215
- (math.random() - 0.5) * subject.Size.X,
216
- (math.random() - 0.5) * subject.Size.Y,
217
- (math.random() - 0.5) * subject.Size.Z
218
- )
219
- )
220
- end
221
- end
222
-
223
- local ok, results = pcall(function()
224
- return (GeometryService :: any):FragmentAsync(subject, sites, {})
225
- end)
226
- if not ok then
227
- Dispatch.fail("GEOMETRY_FAILED", string.format("FragmentAsync refused: %s", tostring(results)))
228
- end
229
-
230
- --[[
231
- `FragmentAsync` does not return parts. It returns wrappers:
232
-
233
- { Index = 1, Instance = MeshPart }
234
-
235
- which is unlike `UnionAsync` and its siblings, which hand back the parts
236
- themselves. Reading them as parts is silently destructive rather than
237
- merely wrong, and this file did exactly that: `piece.Parent = folder` set
238
- a key on a Lua table, changing nothing in the data model, while
239
- `Paths.of` then walked that table's freshly-assigned `Name` and `Parent`
240
- fields and produced a completely plausible path -- so the call reported
241
- eight new parts by name, created none of them, and destroyed the
242
- original. Measured, after the pieces failed to appear in a tree listing.
243
-
244
- Hence the check below rather than a cast: this shape is undocumented, so
245
- the next engine update changing it should stop the tool loudly instead of
246
- eating someone's geometry again.
247
- ]]
248
- local produced: { BasePart } = {}
249
- for _, entry in results :: { any } do
250
- local instance = if typeof(entry) == "table" then (entry :: any).Instance else entry
251
- if typeof(instance) == "Instance" and (instance :: Instance):IsA("BasePart") then
252
- table.insert(produced, instance :: BasePart)
253
- end
254
- end
255
-
256
- if #produced == 0 then
257
- -- Refused before the subject is touched, so a shape this file no longer
258
- -- understands costs the operation and not the part.
259
- Dispatch.fail(
260
- "GEOMETRY_FAILED",
261
- string.format(
262
- "FragmentAsync returned %d result(s) but none were parts this version understands.",
263
- #(results :: { any })
264
- ),
265
- "The original is untouched. This usually means the engine's return shape changed."
266
- )
267
- end
268
- local parent = subject.Parent
269
- local created: { string } = {}
270
-
271
- local _, undoable = Undo.record("MCPFragment", "MCP fragment", function()
272
- for index, part in produced do
273
- carryAppearance(subject, part)
274
- part.Name = string.format("%s_%d", subject.Name, index)
275
- part.Parent = parent
276
- table.insert(created, Paths.of(part))
277
- end
278
- if params.keepOriginals ~= true then
279
- subject:Destroy()
280
- end
281
- end)
282
-
283
- return { created = created, pieces = #produced, undoable = undoable }
284
- end
285
-
286
- --[[
287
- Builds the motion to sweep along, as a list of CFrames.
288
-
289
- Three ways to say it, in the order they are checked. `positions` is the
290
- escape hatch; `to` is a slide; `spin` is a hinge, which is the case worth
291
- having -- a door, a hatch, a drawbridge. All three are sampled rather than
292
- solved, so `steps` trades accuracy for time: the volume is the hull of the
293
- samples, and too few of them on a wide arc cuts the corners off the swing.
294
- ]]
295
- local function motionOf(subject: BasePart, params: { [string]: any }): { CFrame }
296
- local start = subject.CFrame
297
- local steps = math.clamp(tonumber(params.steps) or 12, 2, 64)
298
-
299
- local positions = params.positions
300
- if typeof(positions) == "table" and #(positions :: { any }) > 0 then
301
- local frames: { CFrame } = {}
302
- for _, entry in positions :: { any } do
303
- local at = parseVector(entry)
304
- if at == nil then
305
- Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" position.", tostring(entry)))
306
- end
307
- table.insert(frames, start.Rotation + (at :: Vector3))
308
- end
309
- return frames
310
- end
311
-
312
- local spin = tonumber(params.spin)
313
- if spin ~= nil and spin ~= 0 then
314
- local axis = parseVector(params.axis) or Vector3.yAxis
315
- if axis.Magnitude == 0 then
316
- Dispatch.fail("BAD_PARAMS", "`axis` cannot be zero.")
317
- end
318
- -- The pivot is the hinge. Defaulting it to the part's own centre makes
319
- -- the part spin in place, which is right for a wheel and wrong for a
320
- -- door -- so a door has to name its hinge edge.
321
- local pivot = parseVector(params.pivot) or subject.Position
322
- local frames: { CFrame } = {}
323
- for index = 0, steps - 1 do
324
- local angle = math.rad(spin) * (index / (steps - 1))
325
- local turn = CFrame.fromAxisAngle(axis.Unit, angle)
326
- table.insert(frames, CFrame.new(pivot) * turn * CFrame.new(-pivot) * start)
327
- end
328
- return frames
329
- end
330
-
331
- local destination = parseVector(params.to)
332
- if destination == nil then
333
- Dispatch.fail(
334
- "BAD_PARAMS",
335
- "sweep needs a motion.",
336
- "Give `to` for a slide, `spin` (with `pivot`) for a hinge, or `positions` for a path."
337
- )
338
- end
339
-
340
- local frames: { CFrame } = {}
341
- for index = 0, steps - 1 do
342
- local alpha = index / (steps - 1)
343
- table.insert(frames, start:Lerp(start.Rotation + (destination :: Vector3), alpha))
344
- end
345
- return frames
346
- end
347
-
348
- --[[
349
- The volume a part passes through as it moves.
350
-
351
- The question this answers -- "does the door hit the wall when it opens" --
352
- has no good answer from the data model. Positions and sizes describe where
353
- things are, not where something will be on its way somewhere else, and the
354
- usual workaround is to move the part in steps and test at each one, which
355
- misses anything thin enough to sit between two samples.
356
-
357
- `checkAgainst` is why this is worth calling rather than just building the
358
- volume: the swept solid is put in the world for a moment and asked what it
359
- overlaps. Left on (`keep`) it stays as a part; turned off it is destroyed
360
- once measured, so a clearance check leaves nothing behind.
361
- ]]
362
- function Geometry.sweep(params: { [string]: any }): { [string]: any }
363
- local subject = resolveParts({ params.path }, "a `path`")[1]
364
- local frames = motionOf(subject, params)
365
-
366
- local ok, volume = pcall(function()
367
- return (GeometryService :: any):SweepPartAsync(subject, frames, {
368
- CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
369
- or Enum.CollisionFidelity.Default,
370
- })
371
- end)
372
- if not ok or typeof(volume) ~= "Instance" then
373
- Dispatch.fail(
374
- "GEOMETRY_FAILED",
375
- string.format("SweepPartAsync refused: %s", tostring(volume)),
376
- "The part must be a solid Studio can sweep. Terrain and some mesh shapes are refused."
377
- )
378
- end
379
-
380
- local swept = volume :: BasePart
381
- local parent = if typeof(params.parent) == "string" and params.parent ~= ""
382
- then Paths.resolve(params.parent)
383
- else subject.Parent
384
- local keep = params.keep ~= false
385
-
386
- local hits: { string } = {}
387
- local path = ""
388
-
389
- local _, undoable = Undo.record("MCPSweep", "MCP sweep", function()
390
- carryAppearance(subject, swept)
391
- swept.Name = if typeof(params.name) == "string" and params.name ~= ""
392
- then params.name
393
- else subject.Name .. "_Sweep"
394
- swept.Anchored = true
395
- swept.CanCollide = false
396
- swept.Transparency = tonumber(params.transparency) or 0.5
397
- swept.Parent = parent
398
-
399
- --[[
400
- Measured while the volume is in the world, because that is the only
401
- state the spatial query can read. The subject is excluded: a part
402
- always overlaps its own swept volume, and reporting that would bury
403
- the answer under the one hit nobody asked about.
404
- ]]
405
- local against = params.checkAgainst
406
- if against ~= nil then
407
- local overlap = OverlapParams.new()
408
- overlap.MaxParts = 100
409
- local named = if typeof(against) == "table" then against :: { any } else {}
410
- if #named > 0 then
411
- -- A named list is the question "does it hit THESE", so anything
412
- -- else in the way is not an answer.
413
- local only, _missing = Paths.resolveMany(named :: { string })
414
- overlap.FilterType = Enum.RaycastFilterType.Include
415
- overlap.FilterDescendantsInstances = only
416
- else
417
- overlap.FilterType = Enum.RaycastFilterType.Exclude
418
- overlap.FilterDescendantsInstances = { subject, swept }
419
- end
420
- for _, found in workspace:GetPartsInPart(swept, overlap) do
421
- if found ~= subject and found ~= swept then
422
- table.insert(hits, Paths.of(found))
423
- end
424
- end
425
- end
426
-
427
- if keep then
428
- path = Paths.of(swept)
429
- else
430
- swept:Destroy()
431
- end
432
- end)
433
-
434
- return {
435
- created = if keep then { path } else {},
436
- hits = hits,
437
- checked = params.checkAgainst ~= nil,
438
- frames = #frames,
439
- kept = keep,
440
- undoable = undoable,
441
- }
442
- end
443
-
444
- --[[
445
- What a mesh is actually made of.
446
-
447
- `inspect` on a MeshPart returns a content id, a size and a collision
448
- fidelity, and none of those answer the question anyone has about a mesh,
449
- which is "why is this place slow". A tree that renders as 40,000 triangles
450
- and one that renders as 400 look identical in the Explorer and identical in
451
- the Properties panel, and the difference between them is the difference
452
- between a place that runs on a phone and one that does not.
453
-
454
- `CreateEditableMeshAsync` opens the real geometry. It is read here and then
455
- dropped -- nothing is modified, nothing is saved, and the editable copy is
456
- destroyed before the reply is built, because an EditableMesh holds its data
457
- outside Luau's heap and inspecting a hundred parts should not accumulate a
458
- hundred copies of them.
459
- ]]
460
- function Geometry.mesh(params: { [string]: any }): { [string]: any }
461
- local paths = params.paths
462
- if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
463
- Dispatch.fail(
464
- "BAD_PARAMS",
465
- "mesh needs a non-empty `paths` array.",
466
- 'Use `find` with selector "MeshPart" to list them.'
467
- )
468
- end
469
-
470
- local items: { { [string]: any } } = {}
471
- local failures: { string } = {}
472
- local totalTriangles = 0
473
-
474
- for _, path in paths :: { string } do
475
- local okResolve, resolved = pcall(Paths.resolve, path)
476
- if not okResolve then
477
- table.insert(failures, string.format("%s: not found", tostring(path)))
478
- continue
479
- end
480
-
481
- local instance = resolved :: Instance
482
- if not instance:IsA("MeshPart") then
483
- table.insert(
484
- failures,
485
- string.format("%s: is a %s, not a MeshPart", tostring(path), instance.ClassName)
486
- )
487
- continue
488
- end
489
-
490
- local part = instance :: MeshPart
491
- local okMesh, mesh = pcall(function()
492
- return (AssetService :: any):CreateEditableMeshAsync((part :: any).MeshContent)
493
- end)
494
- if not okMesh then
495
- --[[
496
- Ownership, not a bug, and worth saying so in those words.
497
-
498
- `CreateEditableMeshAsync` will only open a mesh the signed-in
499
- Studio user owns, that the experience owner owns, or that has been
500
- explicitly shared with one of them. Every other mesh on the
501
- platform -- including anything inserted from the Creator Store --
502
- refuses with "no permission to load asset", which reads like a
503
- broken tool rather than a rule about whose asset it is. Measured:
504
- a catalog accessory's mesh fails this way while a mesh uploaded by
505
- the user opens fine.
506
- ]]
507
- local reason = tostring(mesh)
508
- if string.find(reason, "permission", 1, true) then
509
- table.insert(
510
- failures,
511
- string.format(
512
- "%s: its mesh belongs to someone else, so Roblox will not open it. "
513
- .. "Only meshes owned by the signed-in Studio user or the experience "
514
- .. "owner can be read this way.",
515
- tostring(path)
516
- )
517
- )
518
- else
519
- table.insert(
520
- failures,
521
- string.format("%s: could not read its geometry (%s)", tostring(path), reason)
522
- )
523
- end
524
- continue
525
- end
526
-
527
- local editable = mesh :: any
528
- local okRead, row = pcall(function()
529
- local vertices = #editable:GetVertices()
530
- local faces = #editable:GetFaces()
531
- local size = editable:GetSize()
532
- return {
533
- path = Paths.of(part),
534
- name = part.Name,
535
- vertices = vertices,
536
- triangles = faces,
537
- --[[
538
- The mesh's own size against the size it is displayed at. A
539
- large ratio is the usual cause of a mesh that looks fine and
540
- costs far more than it should: the same triangles stretched
541
- over a bigger object, or shrunk into one nobody can see.
542
- ]]
543
- meshSize = string.format("%.2f, %.2f, %.2f", size.X, size.Y, size.Z),
544
- partSize = string.format("%.2f, %.2f, %.2f", part.Size.X, part.Size.Y, part.Size.Z),
545
- collisionFidelity = tostring(part.CollisionFidelity):gsub("Enum%.CollisionFidelity%.", ""),
546
- renderFidelity = tostring(part.RenderFidelity):gsub("Enum%.RenderFidelity%.", ""),
547
- }
548
- end)
549
-
550
- -- Freed before anything else happens with the result, on both paths.
551
- pcall(function()
552
- editable:Destroy()
553
- end)
554
-
555
- if not okRead then
556
- table.insert(failures, string.format("%s: %s", tostring(path), tostring(row)))
557
- continue
558
- end
559
-
560
- local entry = row :: { [string]: any }
561
- totalTriangles += tonumber(entry.triangles) or 0
562
- table.insert(items, entry)
563
- end
564
-
565
- return { items = items, failures = failures, totalTriangles = totalTriangles }
566
- end
567
-
568
- function Geometry.register()
569
- Dispatch.registerAll("geometry", {
570
- mesh = Geometry.mesh,
571
- combine = Geometry.combine,
572
- fragment = Geometry.fragment,
573
- sweep = Geometry.sweep,
574
- })
575
- end
576
-
577
- 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