@princeofscale/bloxforge-inspector 2.20.2
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/assets/Baseplate.rbxl +0 -0
- package/dist/index.js +19281 -0
- package/package.json +62 -0
- package/studio-plugin/INSTALLATION.md +161 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +171699 -0
- package/studio-plugin/MCPPlugin.rbxmx +171699 -0
- package/studio-plugin/default.project.json +19 -0
- package/studio-plugin/dev.project.json +23 -0
- package/studio-plugin/include/LibMP.lua +156378 -0
- package/studio-plugin/inspector-icon.png +0 -0
- package/studio-plugin/package-lock.json +692 -0
- package/studio-plugin/package.json +19 -0
- package/studio-plugin/plugin.json +10 -0
- package/studio-plugin/src/modules/ClientBroker.ts +447 -0
- package/studio-plugin/src/modules/Communication.ts +697 -0
- package/studio-plugin/src/modules/EvalBridges.ts +255 -0
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/JobRegistry.ts +77 -0
- package/studio-plugin/src/modules/LuauExec.ts +465 -0
- package/studio-plugin/src/modules/Recording.ts +28 -0
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +152 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +114 -0
- package/studio-plugin/src/modules/State.ts +101 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +276 -0
- package/studio-plugin/src/modules/UI.ts +790 -0
- package/studio-plugin/src/modules/Utils.ts +318 -0
- package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
- package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
- package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +203 -0
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +160 -0
- package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
- package/studio-plugin/src/modules/handlers/JobHandlers.ts +111 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +14 -0
- package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +354 -0
- package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +1263 -0
- package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +809 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
- package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
- package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +535 -0
- package/studio-plugin/src/server/index.server.ts +134 -0
- package/studio-plugin/src/types/index.d.ts +83 -0
- package/studio-plugin/tsconfig.json +21 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import Utils from "../Utils";
|
|
2
|
+
import Recording from "../Recording";
|
|
3
|
+
|
|
4
|
+
const MaterialService = game.GetService("MaterialService");
|
|
5
|
+
|
|
6
|
+
const { getInstancePath, getInstanceByPath } = Utils;
|
|
7
|
+
const { beginRecording, finishRecording } = Recording;
|
|
8
|
+
|
|
9
|
+
const MATERIAL_BY_NAME = new Map<string, Enum.Material>();
|
|
10
|
+
for (const enumItem of Enum.Material.GetEnumItems()) {
|
|
11
|
+
MATERIAL_BY_NAME.set(enumItem.Name, enumItem as unknown as Enum.Material);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Shape class mapping
|
|
15
|
+
const SHAPE_CLASSES: Record<string, string> = {
|
|
16
|
+
Block: "Part",
|
|
17
|
+
Wedge: "WedgePart",
|
|
18
|
+
Cylinder: "Part",
|
|
19
|
+
Ball: "Part",
|
|
20
|
+
CornerWedge: "CornerWedgePart",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const PALETTE_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
24
|
+
|
|
25
|
+
function roundTo(n: number, decimals: number): number {
|
|
26
|
+
const mult = 10 ** decimals;
|
|
27
|
+
return math.round(n * mult) / mult;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodePaletteKey(index: number): string {
|
|
31
|
+
const base = PALETTE_KEYS.size();
|
|
32
|
+
let value = math.floor(index) + 1;
|
|
33
|
+
let encoded = "";
|
|
34
|
+
while (value > 0) {
|
|
35
|
+
value -= 1;
|
|
36
|
+
const digit = value % base;
|
|
37
|
+
encoded = PALETTE_KEYS.sub(digit + 1, digit + 1) + encoded;
|
|
38
|
+
value = math.floor(value / base);
|
|
39
|
+
}
|
|
40
|
+
return encoded;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getVariantName(part: BasePart): string {
|
|
44
|
+
let variantName = part.MaterialVariant;
|
|
45
|
+
if (variantName === "") {
|
|
46
|
+
const [ok, variantAttr] = pcall(() => part.GetAttribute("MaterialVariant"));
|
|
47
|
+
if (ok && type(variantAttr) === "string") {
|
|
48
|
+
variantName = variantAttr as string;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return variantName;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function exportBuild(requestData: Record<string, unknown>) {
|
|
55
|
+
const instancePath = requestData.instancePath as string;
|
|
56
|
+
const outputId = requestData.outputId as string;
|
|
57
|
+
const style = (requestData.style as string) ?? "misc";
|
|
58
|
+
|
|
59
|
+
if (!instancePath) {
|
|
60
|
+
return { error: "Instance path is required" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const instance = getInstanceByPath(instancePath);
|
|
64
|
+
if (!instance) return { error: `Instance not found: ${instancePath}` };
|
|
65
|
+
|
|
66
|
+
if (!instance.IsA("Model") && !instance.IsA("Folder")) {
|
|
67
|
+
return { error: "Instance must be a Model or Folder" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [success, result] = pcall(() => {
|
|
71
|
+
const descendants = instance.GetDescendants();
|
|
72
|
+
const baseParts: BasePart[] = [];
|
|
73
|
+
for (const desc of descendants) {
|
|
74
|
+
if (desc.IsA("BasePart")) {
|
|
75
|
+
baseParts.push(desc);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (baseParts.size() === 0) {
|
|
80
|
+
return { error: "No BaseParts found in instance" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Compute bounding box center
|
|
84
|
+
let minX = math.huge;
|
|
85
|
+
let minY = math.huge;
|
|
86
|
+
let minZ = math.huge;
|
|
87
|
+
let maxX = -math.huge;
|
|
88
|
+
let maxY = -math.huge;
|
|
89
|
+
let maxZ = -math.huge;
|
|
90
|
+
|
|
91
|
+
for (const part of baseParts) {
|
|
92
|
+
const pos = part.Position;
|
|
93
|
+
const sz = part.Size;
|
|
94
|
+
const halfX = sz.X / 2;
|
|
95
|
+
const halfY = sz.Y / 2;
|
|
96
|
+
const halfZ = sz.Z / 2;
|
|
97
|
+
minX = math.min(minX, pos.X - halfX);
|
|
98
|
+
minY = math.min(minY, pos.Y - halfY);
|
|
99
|
+
minZ = math.min(minZ, pos.Z - halfZ);
|
|
100
|
+
maxX = math.max(maxX, pos.X + halfX);
|
|
101
|
+
maxY = math.max(maxY, pos.Y + halfY);
|
|
102
|
+
maxZ = math.max(maxZ, pos.Z + halfZ);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const centerX = (minX + maxX) / 2;
|
|
106
|
+
const centerY = minY; // Use bottom as Y origin
|
|
107
|
+
const centerZ = (minZ + maxZ) / 2;
|
|
108
|
+
const boundsX = roundTo(maxX - minX, 1);
|
|
109
|
+
const boundsY = roundTo(maxY - minY, 1);
|
|
110
|
+
const boundsZ = roundTo(maxZ - minZ, 1);
|
|
111
|
+
|
|
112
|
+
// Build palette from unique (BrickColor, Material, MaterialVariant?) combos
|
|
113
|
+
const paletteMap = new Map<string, string>();
|
|
114
|
+
const palette: Record<string, [string, string] | [string, string, string]> = {};
|
|
115
|
+
let keyIndex = 0;
|
|
116
|
+
|
|
117
|
+
for (const part of baseParts) {
|
|
118
|
+
const colorName = part.BrickColor.Name;
|
|
119
|
+
const materialName = part.Material.Name;
|
|
120
|
+
const variantName = getVariantName(part);
|
|
121
|
+
const combo = variantName !== "" ? `${colorName}|${materialName}|${variantName}` : `${colorName}|${materialName}`;
|
|
122
|
+
|
|
123
|
+
if (!paletteMap.has(combo)) {
|
|
124
|
+
const key = encodePaletteKey(keyIndex);
|
|
125
|
+
keyIndex++;
|
|
126
|
+
paletteMap.set(combo, key);
|
|
127
|
+
if (variantName !== "") {
|
|
128
|
+
palette[key] = [colorName, materialName, variantName];
|
|
129
|
+
} else {
|
|
130
|
+
palette[key] = [colorName, materialName];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Build compact part arrays
|
|
136
|
+
const parts: unknown[][] = [];
|
|
137
|
+
for (const part of baseParts) {
|
|
138
|
+
const pos = part.Position;
|
|
139
|
+
const orient = part.Orientation;
|
|
140
|
+
const sz = part.Size;
|
|
141
|
+
const colorName = part.BrickColor.Name;
|
|
142
|
+
const materialName = part.Material.Name;
|
|
143
|
+
const variantName = getVariantName(part);
|
|
144
|
+
const combo = variantName !== "" ? `${colorName}|${materialName}|${variantName}` : `${colorName}|${materialName}`;
|
|
145
|
+
const paletteKey = paletteMap.get(combo) ?? "a";
|
|
146
|
+
|
|
147
|
+
// Relative position to center
|
|
148
|
+
const relX = roundTo(pos.X - centerX, 1);
|
|
149
|
+
const relY = roundTo(pos.Y - centerY, 1);
|
|
150
|
+
const relZ = roundTo(pos.Z - centerZ, 1);
|
|
151
|
+
const sizeX = roundTo(sz.X, 1);
|
|
152
|
+
const sizeY = roundTo(sz.Y, 1);
|
|
153
|
+
const sizeZ = roundTo(sz.Z, 1);
|
|
154
|
+
const rotX = roundTo(orient.X, 1);
|
|
155
|
+
const rotY = roundTo(orient.Y, 1);
|
|
156
|
+
const rotZ = roundTo(orient.Z, 1);
|
|
157
|
+
|
|
158
|
+
// Determine shape
|
|
159
|
+
let shape = "Block";
|
|
160
|
+
if (part.IsA("WedgePart")) {
|
|
161
|
+
shape = "Wedge";
|
|
162
|
+
} else if (part.IsA("CornerWedgePart")) {
|
|
163
|
+
shape = "CornerWedge";
|
|
164
|
+
} else if (part.IsA("Part")) {
|
|
165
|
+
const p = part as Part;
|
|
166
|
+
if (p.Shape === Enum.PartType.Cylinder) {
|
|
167
|
+
shape = "Cylinder";
|
|
168
|
+
} else if (p.Shape === Enum.PartType.Ball) {
|
|
169
|
+
shape = "Ball";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Build part array with optional trailing fields
|
|
174
|
+
const hasTransparency = part.Transparency > 0;
|
|
175
|
+
const hasShape = shape !== "Block";
|
|
176
|
+
|
|
177
|
+
let partArr: defined[];
|
|
178
|
+
if (hasTransparency) {
|
|
179
|
+
partArr = [relX, relY, relZ, sizeX, sizeY, sizeZ, rotX, rotY, rotZ, paletteKey, hasShape ? shape : "Block", roundTo(part.Transparency, 2)];
|
|
180
|
+
} else if (hasShape) {
|
|
181
|
+
partArr = [relX, relY, relZ, sizeX, sizeY, sizeZ, rotX, rotY, rotZ, paletteKey, shape];
|
|
182
|
+
} else {
|
|
183
|
+
partArr = [relX, relY, relZ, sizeX, sizeY, sizeZ, rotX, rotY, rotZ, paletteKey];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
parts.push(partArr);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const buildId = outputId ?? `${style}/${instance.Name.lower().gsub(" ", "_")[0]}`;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
buildData: {
|
|
194
|
+
id: buildId,
|
|
195
|
+
style: style,
|
|
196
|
+
bounds: [boundsX, boundsY, boundsZ],
|
|
197
|
+
palette: palette,
|
|
198
|
+
parts: parts,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (success && result) {
|
|
204
|
+
return result;
|
|
205
|
+
} else {
|
|
206
|
+
return { error: `Failed to export build: ${result}` };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function importBuild(requestData: Record<string, unknown>) {
|
|
211
|
+
const buildData = requestData.buildData as Record<string, unknown>;
|
|
212
|
+
const targetPath = requestData.targetPath as string;
|
|
213
|
+
const positionOffset = (requestData.position as number[]) ?? [0, 0, 0];
|
|
214
|
+
|
|
215
|
+
if (!buildData || !targetPath) {
|
|
216
|
+
return { error: "buildData and targetPath are required" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const parentInstance = getInstanceByPath(targetPath);
|
|
220
|
+
if (!parentInstance) return { error: `Target not found: ${targetPath}` };
|
|
221
|
+
const recordingId = beginRecording("Import build");
|
|
222
|
+
|
|
223
|
+
const [success, result] = pcall(() => {
|
|
224
|
+
const palette = buildData.palette as Record<string, [string, string, string?]>;
|
|
225
|
+
const parts = buildData.parts as unknown[][];
|
|
226
|
+
const buildId = (buildData.id as string) ?? "imported_build";
|
|
227
|
+
|
|
228
|
+
// Create model container
|
|
229
|
+
const model = new Instance("Model");
|
|
230
|
+
model.Name = buildId.match("[^/]+$")[0] as string ?? buildId;
|
|
231
|
+
|
|
232
|
+
let partCount = 0;
|
|
233
|
+
|
|
234
|
+
for (const partArr of parts) {
|
|
235
|
+
const posX = (partArr[0] as number) + (positionOffset[0] ?? 0);
|
|
236
|
+
const posY = (partArr[1] as number) + (positionOffset[1] ?? 0);
|
|
237
|
+
const posZ = (partArr[2] as number) + (positionOffset[2] ?? 0);
|
|
238
|
+
const sizeX = partArr[3] as number;
|
|
239
|
+
const sizeY = partArr[4] as number;
|
|
240
|
+
const sizeZ = partArr[5] as number;
|
|
241
|
+
const rotX = partArr[6] as number;
|
|
242
|
+
const rotY = partArr[7] as number;
|
|
243
|
+
const rotZ = partArr[8] as number;
|
|
244
|
+
const paletteKey = partArr[9] as string;
|
|
245
|
+
const shape = (partArr[10] as string) ?? "Block";
|
|
246
|
+
const transparency = (partArr[11] as number) ?? 0;
|
|
247
|
+
|
|
248
|
+
// Determine class from shape
|
|
249
|
+
const className = SHAPE_CLASSES[shape] ?? "Part";
|
|
250
|
+
const part = new Instance(className as keyof CreatableInstances) as BasePart;
|
|
251
|
+
|
|
252
|
+
// Set shape for Part instances with non-Block shapes
|
|
253
|
+
if (className === "Part" && shape !== "Block") {
|
|
254
|
+
if (shape === "Cylinder") {
|
|
255
|
+
(part as Part).Shape = Enum.PartType.Cylinder;
|
|
256
|
+
} else if (shape === "Ball") {
|
|
257
|
+
(part as Part).Shape = Enum.PartType.Ball;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
part.Size = new Vector3(sizeX, sizeY, sizeZ);
|
|
262
|
+
part.Position = new Vector3(posX, posY, posZ);
|
|
263
|
+
part.Orientation = new Vector3(rotX, rotY, rotZ);
|
|
264
|
+
part.Anchored = true;
|
|
265
|
+
|
|
266
|
+
if (transparency > 0) {
|
|
267
|
+
part.Transparency = transparency;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Apply palette
|
|
271
|
+
const paletteEntry = palette[paletteKey];
|
|
272
|
+
if (paletteEntry) {
|
|
273
|
+
const [colorName, materialName, variantName] = paletteEntry;
|
|
274
|
+
pcall(() => {
|
|
275
|
+
part.BrickColor = new BrickColor(colorName as unknown as number);
|
|
276
|
+
});
|
|
277
|
+
pcall(() => {
|
|
278
|
+
const mat = MATERIAL_BY_NAME.get(materialName);
|
|
279
|
+
if (mat !== undefined) {
|
|
280
|
+
part.Material = mat;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// Apply MaterialVariant if specified
|
|
284
|
+
if (variantName !== undefined && variantName !== "") {
|
|
285
|
+
pcall(() => {
|
|
286
|
+
part.MaterialVariant = variantName;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
part.Parent = model;
|
|
292
|
+
partCount++;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
model.Parent = parentInstance;
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
success: true,
|
|
299
|
+
partCount: partCount,
|
|
300
|
+
modelPath: getInstancePath(model),
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (success && result) {
|
|
305
|
+
finishRecording(recordingId, true);
|
|
306
|
+
return result;
|
|
307
|
+
} else {
|
|
308
|
+
finishRecording(recordingId, false);
|
|
309
|
+
return { error: `Failed to import build: ${result}` };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function importScene(requestData: Record<string, unknown>) {
|
|
314
|
+
const expandedBuilds = requestData.expandedBuilds as Record<string, unknown>[];
|
|
315
|
+
const targetPath = (requestData.targetPath as string) ?? "game.Workspace";
|
|
316
|
+
|
|
317
|
+
if (!expandedBuilds || !typeIs(expandedBuilds, "table") || (expandedBuilds as defined[]).size() === 0) {
|
|
318
|
+
return { error: "expandedBuilds array is required" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const parentInstance = getInstanceByPath(targetPath);
|
|
322
|
+
if (!parentInstance) return { error: `Target not found: ${targetPath}` };
|
|
323
|
+
const recordingId = beginRecording("Import scene");
|
|
324
|
+
|
|
325
|
+
const [success, result] = pcall(() => {
|
|
326
|
+
let modelCount = 0;
|
|
327
|
+
let totalParts = 0;
|
|
328
|
+
const models: Record<string, unknown>[] = [];
|
|
329
|
+
|
|
330
|
+
for (const entry of expandedBuilds) {
|
|
331
|
+
const buildData = entry.buildData as Record<string, unknown>;
|
|
332
|
+
const position = (entry.position as number[]) ?? [0, 0, 0];
|
|
333
|
+
const rotation = (entry.rotation as number[]) ?? [0, 0, 0];
|
|
334
|
+
const name = (entry.name as string) ?? "SceneModel";
|
|
335
|
+
|
|
336
|
+
const palette = buildData.palette as Record<string, [string, string, string?]>;
|
|
337
|
+
const parts = buildData.parts as unknown[][];
|
|
338
|
+
|
|
339
|
+
const model = new Instance("Model");
|
|
340
|
+
model.Name = name;
|
|
341
|
+
|
|
342
|
+
const rotCF = CFrame.Angles(
|
|
343
|
+
math.rad(rotation[0] ?? 0),
|
|
344
|
+
math.rad(rotation[1] ?? 0),
|
|
345
|
+
math.rad(rotation[2] ?? 0),
|
|
346
|
+
);
|
|
347
|
+
const originCF = new CFrame(position[0] ?? 0, position[1] ?? 0, position[2] ?? 0).mul(rotCF);
|
|
348
|
+
|
|
349
|
+
let partCount = 0;
|
|
350
|
+
|
|
351
|
+
for (const partArr of parts) {
|
|
352
|
+
const localX = partArr[0] as number;
|
|
353
|
+
const localY = partArr[1] as number;
|
|
354
|
+
const localZ = partArr[2] as number;
|
|
355
|
+
const sizeX = partArr[3] as number;
|
|
356
|
+
const sizeY = partArr[4] as number;
|
|
357
|
+
const sizeZ = partArr[5] as number;
|
|
358
|
+
const rotX = partArr[6] as number;
|
|
359
|
+
const rotY = partArr[7] as number;
|
|
360
|
+
const rotZ = partArr[8] as number;
|
|
361
|
+
const paletteKey = partArr[9] as string;
|
|
362
|
+
const shape = (partArr[10] as string) ?? "Block";
|
|
363
|
+
const transparency = (partArr[11] as number) ?? 0;
|
|
364
|
+
|
|
365
|
+
const className = SHAPE_CLASSES[shape] ?? "Part";
|
|
366
|
+
const part = new Instance(className as keyof CreatableInstances) as BasePart;
|
|
367
|
+
|
|
368
|
+
if (className === "Part" && shape !== "Block") {
|
|
369
|
+
if (shape === "Cylinder") {
|
|
370
|
+
(part as Part).Shape = Enum.PartType.Cylinder;
|
|
371
|
+
} else if (shape === "Ball") {
|
|
372
|
+
(part as Part).Shape = Enum.PartType.Ball;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
part.Size = new Vector3(sizeX, sizeY, sizeZ);
|
|
377
|
+
|
|
378
|
+
// Apply local rotation then world transform
|
|
379
|
+
const localRotCF = CFrame.Angles(math.rad(rotX), math.rad(rotY), math.rad(rotZ));
|
|
380
|
+
const localPosCF = new CFrame(localX, localY, localZ).mul(localRotCF);
|
|
381
|
+
const worldCF = originCF.mul(localPosCF);
|
|
382
|
+
|
|
383
|
+
part.CFrame = worldCF;
|
|
384
|
+
part.Anchored = true;
|
|
385
|
+
|
|
386
|
+
if (transparency > 0) {
|
|
387
|
+
part.Transparency = transparency;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const paletteEntry = palette[paletteKey];
|
|
391
|
+
if (paletteEntry) {
|
|
392
|
+
const [colorName, materialName, variantName] = paletteEntry;
|
|
393
|
+
pcall(() => {
|
|
394
|
+
part.BrickColor = new BrickColor(colorName as unknown as number);
|
|
395
|
+
});
|
|
396
|
+
pcall(() => {
|
|
397
|
+
const mat = MATERIAL_BY_NAME.get(materialName);
|
|
398
|
+
if (mat !== undefined) {
|
|
399
|
+
part.Material = mat;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
if (variantName !== undefined && variantName !== "") {
|
|
403
|
+
pcall(() => {
|
|
404
|
+
part.MaterialVariant = variantName;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
part.Parent = model;
|
|
410
|
+
partCount++;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
model.Parent = parentInstance;
|
|
414
|
+
modelCount++;
|
|
415
|
+
totalParts += partCount;
|
|
416
|
+
models.push({
|
|
417
|
+
name: name,
|
|
418
|
+
partCount: partCount,
|
|
419
|
+
modelPath: getInstancePath(model),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
success: true,
|
|
425
|
+
modelCount: modelCount,
|
|
426
|
+
totalParts: totalParts,
|
|
427
|
+
models: models,
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (success && result) {
|
|
432
|
+
finishRecording(recordingId, true);
|
|
433
|
+
return result;
|
|
434
|
+
} else {
|
|
435
|
+
finishRecording(recordingId, false);
|
|
436
|
+
return { error: `Failed to import scene: ${result}` };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function searchMaterials(requestData: Record<string, unknown>) {
|
|
441
|
+
const query = ((requestData.query as string) ?? "").lower();
|
|
442
|
+
const maxResults = (requestData.maxResults as number) ?? 50;
|
|
443
|
+
|
|
444
|
+
const [success, result] = pcall(() => {
|
|
445
|
+
const children = MaterialService.GetChildren();
|
|
446
|
+
const materials: Record<string, unknown>[] = [];
|
|
447
|
+
|
|
448
|
+
for (const child of children) {
|
|
449
|
+
if (!child.IsA("MaterialVariant")) continue;
|
|
450
|
+
|
|
451
|
+
const nameMatch = query === "" || child.Name.lower().find(query)[0] !== undefined;
|
|
452
|
+
if (!nameMatch) continue;
|
|
453
|
+
|
|
454
|
+
materials.push({
|
|
455
|
+
name: child.Name,
|
|
456
|
+
baseMaterial: child.BaseMaterial.Name,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (materials.size() >= maxResults) break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
success: true,
|
|
464
|
+
materials: materials,
|
|
465
|
+
total: materials.size(),
|
|
466
|
+
};
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (success && result) {
|
|
470
|
+
return result;
|
|
471
|
+
} else {
|
|
472
|
+
return { error: `Failed to search materials: ${result}` };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export = {
|
|
477
|
+
exportBuild,
|
|
478
|
+
importBuild,
|
|
479
|
+
importScene,
|
|
480
|
+
searchMaterials,
|
|
481
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import * as RenderMonitor from "../RenderMonitor";
|
|
2
|
+
|
|
3
|
+
const CaptureService = game.GetService("CaptureService");
|
|
4
|
+
const AssetService = game.GetService("AssetService");
|
|
5
|
+
const Workspace = game.GetService("Workspace");
|
|
6
|
+
|
|
7
|
+
const MAX_TILE_SIZE = 1024;
|
|
8
|
+
const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
9
|
+
const PAD_BYTE = string.byte("=")[0];
|
|
10
|
+
|
|
11
|
+
const B64: number[] = [];
|
|
12
|
+
for (let i = 0; i < 64; i++) {
|
|
13
|
+
B64[i] = string.byte(BASE64_CHARS, i + 1)[0];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function encodeBase64(buf: buffer): string {
|
|
17
|
+
const len = buffer.len(buf);
|
|
18
|
+
const fullTriples = math.floor(len / 3);
|
|
19
|
+
const remaining = len - fullTriples * 3;
|
|
20
|
+
const outLen = (fullTriples + (remaining > 0 ? 1 : 0)) * 4;
|
|
21
|
+
const out = buffer.create(outLen);
|
|
22
|
+
|
|
23
|
+
let si = 0;
|
|
24
|
+
let di = 0;
|
|
25
|
+
|
|
26
|
+
for (let t = 0; t < fullTriples; t++) {
|
|
27
|
+
const b0 = buffer.readu8(buf, si);
|
|
28
|
+
const b1 = buffer.readu8(buf, si + 1);
|
|
29
|
+
const b2 = buffer.readu8(buf, si + 2);
|
|
30
|
+
|
|
31
|
+
buffer.writeu8(out, di, B64[bit32.rshift(b0, 2)]);
|
|
32
|
+
buffer.writeu8(out, di + 1, B64[bit32.bor(bit32.lshift(bit32.band(b0, 3), 4), bit32.rshift(b1, 4))]);
|
|
33
|
+
buffer.writeu8(out, di + 2, B64[bit32.bor(bit32.lshift(bit32.band(b1, 15), 2), bit32.rshift(b2, 6))]);
|
|
34
|
+
buffer.writeu8(out, di + 3, B64[bit32.band(b2, 63)]);
|
|
35
|
+
|
|
36
|
+
si += 3;
|
|
37
|
+
di += 4;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (remaining === 2) {
|
|
41
|
+
const b0 = buffer.readu8(buf, si);
|
|
42
|
+
const b1 = buffer.readu8(buf, si + 1);
|
|
43
|
+
buffer.writeu8(out, di, B64[bit32.rshift(b0, 2)]);
|
|
44
|
+
buffer.writeu8(out, di + 1, B64[bit32.bor(bit32.lshift(bit32.band(b0, 3), 4), bit32.rshift(b1, 4))]);
|
|
45
|
+
buffer.writeu8(out, di + 2, B64[bit32.lshift(bit32.band(b1, 15), 2)]);
|
|
46
|
+
buffer.writeu8(out, di + 3, PAD_BYTE);
|
|
47
|
+
} else if (remaining === 1) {
|
|
48
|
+
const b0 = buffer.readu8(buf, si);
|
|
49
|
+
buffer.writeu8(out, di, B64[bit32.rshift(b0, 2)]);
|
|
50
|
+
buffer.writeu8(out, di + 1, B64[bit32.lshift(bit32.band(b0, 3), 4)]);
|
|
51
|
+
buffer.writeu8(out, di + 2, PAD_BYTE);
|
|
52
|
+
buffer.writeu8(out, di + 3, PAD_BYTE);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return buffer.tostring(out);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readPixelsTiled(img: EditableImage, w: number, h: number): buffer {
|
|
59
|
+
const BYTES_PER_PIXEL = 4;
|
|
60
|
+
const fullBuf = buffer.create(w * h * BYTES_PER_PIXEL);
|
|
61
|
+
const fullRowBytes = w * BYTES_PER_PIXEL;
|
|
62
|
+
|
|
63
|
+
for (let ty = 0; ty < h; ty += MAX_TILE_SIZE) {
|
|
64
|
+
const tileH = math.min(MAX_TILE_SIZE, h - ty);
|
|
65
|
+
for (let tx = 0; tx < w; tx += MAX_TILE_SIZE) {
|
|
66
|
+
const tileW = math.min(MAX_TILE_SIZE, w - tx);
|
|
67
|
+
const tileBuf = img.ReadPixelsBuffer(new Vector2(tx, ty), new Vector2(tileW, tileH));
|
|
68
|
+
const tileRowBytes = tileW * BYTES_PER_PIXEL;
|
|
69
|
+
for (let row = 0; row < tileH; row++) {
|
|
70
|
+
buffer.copy(fullBuf, (ty + row) * fullRowBytes + tx * BYTES_PER_PIXEL, tileBuf, row * tileRowBytes, tileRowBytes);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return fullBuf;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Triggers CaptureService:CaptureScreenshot and waits for the temporary
|
|
78
|
+
// content id. Works in any DM, including the play CLIENT (where reading the
|
|
79
|
+
// pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
80
|
+
// a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
81
|
+
// privileged DM (the edit DM) — see captureRead.
|
|
82
|
+
function readLogicalViewport(): { viewportW?: number; viewportH?: number } {
|
|
83
|
+
const camera = Workspace.CurrentCamera;
|
|
84
|
+
if (camera === undefined) return {};
|
|
85
|
+
const size = camera.ViewportSize;
|
|
86
|
+
return {
|
|
87
|
+
viewportW: math.floor(size.X),
|
|
88
|
+
viewportH: math.floor(size.Y),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function doCaptureScreenshot(): { contentId: string; viewportW?: number; viewportH?: number } | { error: string } {
|
|
93
|
+
// Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
94
|
+
// CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
95
|
+
const notRendering = RenderMonitor.notRenderingReason();
|
|
96
|
+
if (notRendering !== undefined) return { error: notRendering };
|
|
97
|
+
|
|
98
|
+
let contentId: string | undefined;
|
|
99
|
+
|
|
100
|
+
CaptureService.CaptureScreenshot((id: string) => {
|
|
101
|
+
contentId = id;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const startTime = tick();
|
|
105
|
+
while (contentId === undefined) {
|
|
106
|
+
if (tick() - startTime > 10) {
|
|
107
|
+
return {
|
|
108
|
+
error: "Screenshot capture timed out (CaptureScreenshot callback never fired). The Studio window is likely minimized or occluded — restore it so the viewport renders. (Known Roblox bug: capture can also fail if the viewport renders a solid color.)",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
task.wait(0.1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { contentId, ...readLogicalViewport() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Promotes a CaptureScreenshot content id into an EditableImage and reads its
|
|
118
|
+
// RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
|
|
119
|
+
// the privilege to create an EditableImage from a temporary texture id (errors
|
|
120
|
+
// "cannot currently create editable image from temporary texture id"), while
|
|
121
|
+
// the edit DM can — even for an id captured in the play client DM.
|
|
122
|
+
function readContentToBase64(contentId: string, viewport?: { viewportW?: number; viewportH?: number }): unknown {
|
|
123
|
+
const [editableOk, editableResult] = pcall(() => {
|
|
124
|
+
return AssetService.CreateEditableImageAsync(Content.fromUri(contentId));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!editableOk) {
|
|
128
|
+
return {
|
|
129
|
+
error: `Failed to create EditableImage from screenshot. Enable EditableImage API: Game Settings > Security > 'Allow Mesh / Image APIs'. (${tostring(editableResult)})`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const editableImage = editableResult as EditableImage;
|
|
134
|
+
const imgSize = editableImage.Size;
|
|
135
|
+
const w = math.floor(imgSize.X);
|
|
136
|
+
const h = math.floor(imgSize.Y);
|
|
137
|
+
|
|
138
|
+
const [readOk, pixelBuffer] = pcall(() => {
|
|
139
|
+
return readPixelsTiled(editableImage, w, h);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
editableImage.Destroy();
|
|
143
|
+
|
|
144
|
+
if (!readOk) {
|
|
145
|
+
return { error: `Failed to read pixel data: ${tostring(pixelBuffer)}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const base64Data = encodeBase64(pixelBuffer as buffer);
|
|
149
|
+
|
|
150
|
+
return { success: true, width: w, height: h, viewportW: viewport?.viewportW, viewportH: viewport?.viewportH, data: base64Data };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Edit-mode single shot: capture and read back in the same (edit) context.
|
|
154
|
+
function captureScreenshotData(requestData: Record<string, unknown> = {}): unknown {
|
|
155
|
+
const position = requestData.cameraPosition as { x: number; y: number; z: number } | undefined;
|
|
156
|
+
const lookAt = requestData.lookAt as { x: number; y: number; z: number } | undefined;
|
|
157
|
+
const camera = Workspace.CurrentCamera;
|
|
158
|
+
if ((position === undefined) !== (lookAt === undefined)) return { error: "cameraPosition and lookAt must be provided together" };
|
|
159
|
+
const priorType = camera?.CameraType;
|
|
160
|
+
const priorCFrame = camera?.CFrame;
|
|
161
|
+
if (camera && position && lookAt) {
|
|
162
|
+
camera.CameraType = Enum.CameraType.Scriptable;
|
|
163
|
+
camera.CFrame = CFrame.lookAt(new Vector3(position.x, position.y, position.z), new Vector3(lookAt.x, lookAt.y, lookAt.z));
|
|
164
|
+
task.wait();
|
|
165
|
+
}
|
|
166
|
+
let cap: ReturnType<typeof doCaptureScreenshot>;
|
|
167
|
+
try {
|
|
168
|
+
cap = doCaptureScreenshot();
|
|
169
|
+
} finally {
|
|
170
|
+
if (camera && priorType !== undefined && priorCFrame !== undefined) {
|
|
171
|
+
camera.CFrame = priorCFrame;
|
|
172
|
+
camera.CameraType = priorType;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if ("error" in cap) return cap;
|
|
176
|
+
return readContentToBase64(cap.contentId, cap);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function captureScreenshot(requestData: Record<string, unknown>): unknown {
|
|
180
|
+
return captureScreenshotData(requestData);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
|
|
184
|
+
function captureBegin(): unknown {
|
|
185
|
+
return doCaptureScreenshot();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
|
|
189
|
+
function captureRead(requestData: Record<string, unknown>): unknown {
|
|
190
|
+
const contentId = requestData.contentId as string | undefined;
|
|
191
|
+
if (!contentId) return { error: "contentId is required" };
|
|
192
|
+
return readContentToBase64(contentId, {
|
|
193
|
+
viewportW: requestData.viewportW as number | undefined,
|
|
194
|
+
viewportH: requestData.viewportH as number | undefined,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export = {
|
|
199
|
+
captureScreenshotData,
|
|
200
|
+
captureScreenshot,
|
|
201
|
+
captureBegin,
|
|
202
|
+
captureRead,
|
|
203
|
+
};
|