@el4cteo/rbx-studio-mcp 0.6.5 → 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.
- package/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/data.js +122 -3
- package/dist/tools/data.js.map +1 -1
- package/dist/tools/exec.js +48 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/scripts.js +112 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +361 -10
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Commands.luau +646 -622
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Phrase.luau +816 -766
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +759 -499
- package/plugin/src/handlers/Assets.luau +636 -587
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Geometry.luau +722 -577
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Spatial.luau +334 -0
- 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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
property
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
local
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
local
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
--
|
|
99
|
-
--
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
--
|
|
121
|
-
--
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if typeof(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
--
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
local
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
part
|
|
151
|
-
|
|
152
|
-
else
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
--
|
|
158
|
-
--
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
part
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
local
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
local
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
--
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(math.random() - 0.5) * subject.Size.
|
|
217
|
-
(math.random() - 0.5) * subject.Size.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
the
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if typeof(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
--
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
local
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
part.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
local
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
--
|
|
320
|
-
--
|
|
321
|
-
|
|
322
|
-
local
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
local
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"
|
|
336
|
-
"
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
local
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
"
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
local
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
local
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
swept
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
swept.
|
|
396
|
-
swept.
|
|
397
|
-
swept.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
overlap
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
--
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
overlap.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
overlap.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
hundred
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
"
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
local
|
|
472
|
-
local
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
local
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
.. "
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
local
|
|
529
|
-
|
|
530
|
-
local
|
|
531
|
-
local
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|