dominus-cli 2.2.1 → 2.3.0

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.
@@ -0,0 +1,427 @@
1
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
2
+ local Selection = game:GetService("Selection")
3
+
4
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
5
+ local ValueCodec = require(script.Parent.ValueCodec)
6
+
7
+ local Building = {}
8
+
9
+ local MAX_ITEMS = 200
10
+ local MAX_PROPERTIES = 100
11
+
12
+ local ALLOWED_PART_CLASSES = {
13
+ Part = true,
14
+ WedgePart = true,
15
+ CornerWedgePart = true,
16
+ TrussPart = true,
17
+ MeshPart = true,
18
+ SpawnLocation = true,
19
+ Seat = true,
20
+ VehicleSeat = true,
21
+ }
22
+
23
+ local function resolve(target, label)
24
+ local instance, err = InstanceRegistry.resolve(target)
25
+ if not instance then
26
+ error(label .. ": " .. tostring(err))
27
+ end
28
+ return instance
29
+ end
30
+
31
+ local function cleanup(instances)
32
+ for _, instance in instances do
33
+ pcall(function()
34
+ if instance.Parent == nil then
35
+ instance:Destroy()
36
+ end
37
+ end)
38
+ end
39
+ end
40
+
41
+ local function record(name, callback, created)
42
+ local recording = ChangeHistoryService:TryBeginRecording(name)
43
+ if not recording then
44
+ return { success = false, error = "Another plugin recording is already active" }
45
+ end
46
+
47
+ local ok, result = pcall(callback)
48
+ if not ok then
49
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
50
+ cleanup(created or {})
51
+ return { success = false, error = tostring(result), rolledBack = true }
52
+ end
53
+
54
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
55
+ result.success = true
56
+ return result
57
+ end
58
+
59
+ local function finiteNumber(value, label)
60
+ assert(type(value) == "number" and value == value and math.abs(value) < math.huge, label .. " must be finite")
61
+ return value
62
+ end
63
+
64
+ local function vector3(value, label, default)
65
+ if value == nil then
66
+ return default
67
+ end
68
+ assert(type(value) == "table", label .. " must be a Vector3 object")
69
+ return Vector3.new(
70
+ finiteNumber(value.X or value.x or value[1], label .. ".x"),
71
+ finiteNumber(value.Y or value.y or value[2], label .. ".y"),
72
+ finiteNumber(value.Z or value.z or value[3], label .. ".z")
73
+ )
74
+ end
75
+
76
+ local function rotation(value, label)
77
+ local degrees = vector3(value, label, Vector3.zero)
78
+ return CFrame.fromOrientation(math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z))
79
+ end
80
+
81
+ local function setProperties(instance, properties)
82
+ if properties == nil then
83
+ return {}
84
+ end
85
+ assert(type(properties) == "table", "properties must be an object")
86
+ local count = 0
87
+ local applied = {}
88
+ for propertyName, value in properties do
89
+ count += 1
90
+ if count > MAX_PROPERTIES then
91
+ error("At most " .. MAX_PROPERTIES .. " properties are allowed per item")
92
+ end
93
+ applied[propertyName] = ValueCodec.setProperty(instance, propertyName, value)
94
+ end
95
+ return applied
96
+ end
97
+
98
+ local function validateItemCount(items, label, maximum)
99
+ assert(type(items) == "table" and #items > 0, label .. " must contain at least one item")
100
+ assert(#items <= maximum, label .. " is limited to " .. maximum .. " items")
101
+ end
102
+
103
+ local function assertMovable(instance, label)
104
+ assert(instance ~= game and instance.Parent ~= game, label .. " cannot be a DataModel service")
105
+ end
106
+
107
+ function Building.createParts(spec)
108
+ validateItemCount(spec.parts, "parts", MAX_ITEMS)
109
+ local parent = spec.parent and resolve(spec.parent, "parent") or workspace
110
+ local group = nil
111
+ local created = {}
112
+
113
+ return record("Dominus 2: Build parts", function()
114
+ if spec.groupName ~= nil then
115
+ assert(type(spec.groupName) == "string" and #spec.groupName > 0, "groupName must be a non-empty string")
116
+ local groupClass = spec.groupClass or "Model"
117
+ assert(groupClass == "Model" or groupClass == "Folder", "groupClass must be Model or Folder")
118
+ group = Instance.new(groupClass)
119
+ group.Name = spec.groupName
120
+ table.insert(created, group)
121
+ end
122
+
123
+ local parts = {}
124
+ for index, item in spec.parts do
125
+ assert(type(item) == "table", "Part " .. index .. " must be an object")
126
+ assert(type(item.name) == "string" and #item.name > 0, "Part " .. index .. ".name must be a non-empty string")
127
+ local className = item.className or "Part"
128
+ if item.shape == "Wedge" then
129
+ className = "WedgePart"
130
+ elseif item.shape == "CornerWedge" then
131
+ className = "CornerWedgePart"
132
+ end
133
+ assert(ALLOWED_PART_CLASSES[className] == true, "Part " .. index .. " uses an unsupported class: " .. tostring(className))
134
+
135
+ local part = Instance.new(className)
136
+ table.insert(created, part)
137
+ part.Name = item.name
138
+ if part:IsA("BasePart") then
139
+ part.Anchored = item.anchored ~= false
140
+ end
141
+ if item.position then
142
+ part.Position = vector3(item.position, "Part " .. index .. ".position")
143
+ end
144
+ if item.orientation then
145
+ part.Orientation = vector3(item.orientation, "Part " .. index .. ".orientation")
146
+ end
147
+ if item.size then
148
+ part.Size = vector3(item.size, "Part " .. index .. ".size")
149
+ end
150
+ if item.color then
151
+ ValueCodec.setProperty(part, "Color", item.color)
152
+ end
153
+ if item.material then
154
+ ValueCodec.setProperty(part, "Material", item.material)
155
+ end
156
+ if item.transparency ~= nil then
157
+ part.Transparency = finiteNumber(item.transparency, "Part " .. index .. ".transparency")
158
+ end
159
+ if item.canCollide ~= nil then
160
+ assert(type(item.canCollide) == "boolean", "Part " .. index .. ".canCollide must be a boolean")
161
+ part.CanCollide = item.canCollide
162
+ end
163
+ if item.shape and className == "Part" then
164
+ ValueCodec.setProperty(part, "Shape", item.shape)
165
+ end
166
+ setProperties(part, item.properties)
167
+ part.Parent = group or parent
168
+ table.insert(parts, part)
169
+ end
170
+
171
+ if group then
172
+ group.Parent = parent
173
+ end
174
+
175
+ local refs = {}
176
+ for _, part in parts do
177
+ table.insert(refs, InstanceRegistry.toRef(part))
178
+ end
179
+ return {
180
+ created = refs,
181
+ group = group and InstanceRegistry.toRef(group) or nil,
182
+ count = #refs,
183
+ }
184
+ end, created)
185
+ end
186
+
187
+ function Building.cloneInstances(spec)
188
+ validateItemCount(spec.requests, "requests", 100)
189
+ local created = {}
190
+
191
+ return record("Dominus 2: Clone instances", function()
192
+ local clones = {}
193
+ local totalCopies = 0
194
+ for requestIndex, request in spec.requests do
195
+ assert(type(request) == "table", "Clone request " .. requestIndex .. " must be an object")
196
+ local source = resolve(request.target, "Clone request " .. requestIndex .. " target")
197
+ assertMovable(source, "Clone source")
198
+ local parent = request.parent and resolve(request.parent, "Clone request " .. requestIndex .. " parent") or source.Parent
199
+ assert(parent ~= nil, "Clone request " .. requestIndex .. " source has no parent")
200
+ local count = request.count or 1
201
+ assert(type(count) == "number" and count % 1 == 0 and count >= 1 and count <= 50, "count must be an integer from 1 to 50")
202
+ totalCopies += count
203
+ assert(totalCopies <= MAX_ITEMS, "A clone batch is limited to " .. MAX_ITEMS .. " copies")
204
+ local offset = vector3(request.offset, "Clone request " .. requestIndex .. ".offset", Vector3.zero)
205
+ local stepOffset = vector3(request.stepOffset, "Clone request " .. requestIndex .. ".stepOffset", Vector3.zero)
206
+
207
+ for copyIndex = 1, count do
208
+ local clone = source:Clone()
209
+ assert(clone ~= nil, "Clone request " .. requestIndex .. " target is not Archivable")
210
+ table.insert(created, clone)
211
+ if request.name then
212
+ clone.Name = count == 1 and request.name or (request.name .. " " .. copyIndex)
213
+ end
214
+ setProperties(clone, request.properties)
215
+ if clone:IsA("PVInstance") then
216
+ local totalOffset = offset + stepOffset * (copyIndex - 1)
217
+ clone:PivotTo(clone:GetPivot() + totalOffset)
218
+ elseif offset.Magnitude > 0 or stepOffset.Magnitude > 0 then
219
+ error("Clone request " .. requestIndex .. " cannot offset a non-PVInstance")
220
+ end
221
+ clone.Parent = parent
222
+ table.insert(clones, InstanceRegistry.toRef(clone))
223
+ end
224
+ end
225
+ return { clones = clones, count = #clones }
226
+ end, created)
227
+ end
228
+
229
+ local function validateIndependentTargets(targets)
230
+ for leftIndex, left in targets do
231
+ for rightIndex = leftIndex + 1, #targets do
232
+ local right = targets[rightIndex]
233
+ assert(not left:IsAncestorOf(right) and not right:IsAncestorOf(left), "Targets " .. leftIndex .. " and " .. rightIndex .. " overlap")
234
+ end
235
+ end
236
+ end
237
+
238
+ function Building.groupInstances(spec)
239
+ validateItemCount(spec.targets, "targets", 100)
240
+ assert(type(spec.name) == "string" and #spec.name > 0, "name must be a non-empty string")
241
+ local className = spec.className or "Model"
242
+ assert(className == "Model" or className == "Folder", "className must be Model or Folder")
243
+ local targets = {}
244
+ for index, targetRef in spec.targets do
245
+ local target = resolve(targetRef, "Target " .. index)
246
+ assertMovable(target, "Target " .. index)
247
+ table.insert(targets, target)
248
+ end
249
+ validateIndependentTargets(targets)
250
+ local parent = spec.parent and resolve(spec.parent, "parent") or targets[1].Parent
251
+ assert(parent ~= nil, "The first target has no parent")
252
+ local created = {}
253
+
254
+ return record("Dominus 2: Group instances", function()
255
+ local group = Instance.new(className)
256
+ table.insert(created, group)
257
+ group.Name = spec.name
258
+ group.Parent = parent
259
+ for _, target in targets do
260
+ assert(not target:IsAncestorOf(group), "Cannot group an ancestor into its descendant")
261
+ target.Parent = group
262
+ end
263
+ return { group = InstanceRegistry.toRef(group), count = #targets }
264
+ end, created)
265
+ end
266
+
267
+ function Building.ungroupInstances(spec)
268
+ assert(spec.confirm == true, "confirm must be true")
269
+ validateItemCount(spec.targets, "targets", 20)
270
+
271
+ return record("Dominus 2: Ungroup instances", function()
272
+ local moved = {}
273
+ local removed = {}
274
+ for index, targetRef in spec.targets do
275
+ local group = resolve(targetRef, "Group " .. index)
276
+ assert(group:IsA("Model") or group:IsA("Folder"), "Group " .. index .. " must be a Model or Folder")
277
+ assertMovable(group, "Group " .. index)
278
+ local parent = spec.parent and resolve(spec.parent, "parent") or group.Parent
279
+ assert(parent ~= nil, "Group " .. index .. " has no parent")
280
+ for _, child in group:GetChildren() do
281
+ child.Parent = parent
282
+ table.insert(moved, InstanceRegistry.toRef(child))
283
+ end
284
+ table.insert(removed, { name = group.Name, className = group.ClassName })
285
+ group:Destroy()
286
+ end
287
+ return { moved = moved, removedGroups = removed, count = #moved }
288
+ end)
289
+ end
290
+
291
+ local function absolutePivot(current, operation)
292
+ if operation.cframe then
293
+ return ValueCodec.decodeForCurrent(current, operation.cframe)
294
+ end
295
+ local position = vector3(operation.position, "position", current.Position)
296
+ local x, y, z = current:ToOrientation()
297
+ if operation.orientation then
298
+ local degrees = vector3(operation.orientation, "orientation")
299
+ x, y, z = math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z)
300
+ end
301
+ return CFrame.new(position) * CFrame.fromOrientation(x, y, z)
302
+ end
303
+
304
+ function Building.transformInstances(spec)
305
+ validateItemCount(spec.operations, "operations", 100)
306
+
307
+ return record("Dominus 2: Transform instances", function()
308
+ local results = {}
309
+ for index, operation in spec.operations do
310
+ local target = resolve(operation.target, "Operation " .. index .. " target")
311
+ assert(target:IsA("PVInstance"), "Operation " .. index .. " target must be a Model or BasePart")
312
+ local current = target:GetPivot()
313
+ local mode = operation.mode or "absolute"
314
+ local nextPivot
315
+ if mode == "absolute" then
316
+ nextPivot = absolutePivot(current, operation)
317
+ elseif mode == "relative" then
318
+ local offset = vector3(operation.offset, "Operation " .. index .. ".offset", Vector3.zero)
319
+ local rotate = rotation(operation.rotationOffset, "Operation " .. index .. ".rotationOffset")
320
+ if operation.space == "local" then
321
+ nextPivot = current * CFrame.new(offset) * rotate
322
+ else
323
+ nextPivot = CFrame.new(offset) * current * rotate
324
+ end
325
+ else
326
+ error("Operation " .. index .. ".mode must be absolute or relative")
327
+ end
328
+ target:PivotTo(nextPivot)
329
+ table.insert(results, { target = InstanceRegistry.toRef(target), pivot = ValueCodec.encode(target:GetPivot()) })
330
+ end
331
+ return { transformed = results, count = #results }
332
+ end)
333
+ end
334
+
335
+ function Building.createWelds(spec)
336
+ validateItemCount(spec.welds, "welds", MAX_ITEMS)
337
+ local created = {}
338
+
339
+ return record("Dominus 2: Create welds", function()
340
+ local welds = {}
341
+ for index, item in spec.welds do
342
+ local part0 = resolve(item.part0, "Weld " .. index .. " part0")
343
+ local part1 = resolve(item.part1, "Weld " .. index .. " part1")
344
+ assert(part0:IsA("BasePart") and part1:IsA("BasePart"), "Weld " .. index .. " endpoints must be BaseParts")
345
+ assert(part0 ~= part1, "Weld " .. index .. " endpoints must be different")
346
+ local parent = item.parent and resolve(item.parent, "Weld " .. index .. " parent") or part0
347
+ local weld = Instance.new("WeldConstraint")
348
+ table.insert(created, weld)
349
+ weld.Name = item.name or (part0.Name .. "_to_" .. part1.Name)
350
+ weld.Part0 = part0
351
+ weld.Part1 = part1
352
+ weld.Parent = parent
353
+ table.insert(welds, InstanceRegistry.toRef(weld))
354
+ end
355
+ return { welds = welds, count = #welds }
356
+ end, created)
357
+ end
358
+
359
+ local function overlapParams(spec)
360
+ local params = OverlapParams.new()
361
+ local maxParts = spec.maxParts or 200
362
+ assert(type(maxParts) == "number" and maxParts % 1 == 0 and maxParts >= 1 and maxParts <= 500, "maxParts must be an integer from 1 to 500")
363
+ params.MaxParts = maxParts
364
+ params.RespectCanCollide = spec.respectCanCollide == true
365
+ if spec.filter and #spec.filter > 0 then
366
+ local instances = {}
367
+ for index, targetRef in spec.filter do
368
+ table.insert(instances, resolve(targetRef, "Filter " .. index))
369
+ end
370
+ params.FilterDescendantsInstances = instances
371
+ params.FilterType = spec.filterType == "Include" and Enum.RaycastFilterType.Include or Enum.RaycastFilterType.Exclude
372
+ end
373
+ return params
374
+ end
375
+
376
+ function Building.queryParts(spec)
377
+ local root = spec.root and resolve(spec.root, "root") or workspace
378
+ assert(root:IsA("WorldRoot"), "root must be Workspace or a WorldModel")
379
+ local mode = spec.mode or "box"
380
+ local found
381
+ if mode == "box" then
382
+ local position = vector3(spec.position, "position")
383
+ local size = vector3(spec.size, "size")
384
+ assert(size.X > 0 and size.Y > 0 and size.Z > 0, "size components must be greater than zero")
385
+ local box = CFrame.new(position) * rotation(spec.orientation, "orientation")
386
+ found = root:GetPartBoundsInBox(box, size, overlapParams(spec))
387
+ elseif mode == "radius" then
388
+ local position = vector3(spec.position, "position")
389
+ local radius = finiteNumber(spec.radius, "radius")
390
+ assert(radius > 0, "radius must be greater than zero")
391
+ found = root:GetPartBoundsInRadius(position, radius, overlapParams(spec))
392
+ else
393
+ error("mode must be box or radius")
394
+ end
395
+
396
+ table.sort(found, function(left, right)
397
+ return InstanceRegistry.getDisplayPath(left) < InstanceRegistry.getDisplayPath(right)
398
+ end)
399
+ local parts = {}
400
+ for _, part in found do
401
+ table.insert(parts, {
402
+ name = part.Name,
403
+ className = part.ClassName,
404
+ ref = InstanceRegistry.toRef(part),
405
+ position = ValueCodec.encode(part.Position),
406
+ size = ValueCodec.encode(part.Size),
407
+ })
408
+ end
409
+ return { success = true, parts = parts, count = #parts, mode = mode }
410
+ end
411
+
412
+ function Building.setSelection(spec)
413
+ assert(type(spec.targets) == "table", "targets must be an array")
414
+ assert(#spec.targets <= 200, "At most 200 instances can be selected")
415
+ local targets = {}
416
+ for index, targetRef in spec.targets do
417
+ table.insert(targets, resolve(targetRef, "Target " .. index))
418
+ end
419
+ Selection:Set(targets)
420
+ local selected = {}
421
+ for _, target in targets do
422
+ table.insert(selected, InstanceRegistry.toRef(target))
423
+ end
424
+ return { success = true, selection = selected, count = #selected }
425
+ end
426
+
427
+ return Building
@@ -1,3 +1,4 @@
1
+ local Building = require(script.Parent.Building)
1
2
  local Explorer = require(script.Parent.Explorer)
2
3
  local InstanceRegistry = require(script.Parent.InstanceRegistry)
3
4
  local Mutation = require(script.Parent.Mutation)
@@ -37,6 +38,30 @@ local handlers = {
37
38
  ["studio:v2:apply"] = function(payload)
38
39
  return Mutation.apply(payload)
39
40
  end,
41
+ ["studio:v2:create_parts"] = function(payload)
42
+ return Building.createParts(payload)
43
+ end,
44
+ ["studio:v2:clone_instances"] = function(payload)
45
+ return Building.cloneInstances(payload)
46
+ end,
47
+ ["studio:v2:group_instances"] = function(payload)
48
+ return Building.groupInstances(payload)
49
+ end,
50
+ ["studio:v2:ungroup_instances"] = function(payload)
51
+ return Building.ungroupInstances(payload)
52
+ end,
53
+ ["studio:v2:transform_instances"] = function(payload)
54
+ return Building.transformInstances(payload)
55
+ end,
56
+ ["studio:v2:create_welds"] = function(payload)
57
+ return Building.createWelds(payload)
58
+ end,
59
+ ["studio:v2:query_parts"] = function(payload)
60
+ return Building.queryParts(payload)
61
+ end,
62
+ ["studio:v2:set_selection"] = function(payload)
63
+ return Building.setSelection(payload)
64
+ end,
40
65
  ["studio:v2:delete"] = function(payload)
41
66
  return Mutation.delete(payload)
42
67
  end,
@@ -44,6 +44,7 @@ function Mutation.apply(spec)
44
44
  end
45
45
 
46
46
  local results = {}
47
+ local created = {}
47
48
  local ok, err = pcall(function()
48
49
  for index, operation in operations do
49
50
  assert(type(operation) == "table", "Operation " .. index .. " must be an object")
@@ -59,12 +60,34 @@ function Mutation.apply(spec)
59
60
  assert(type(operation.className) == "string", "create.className must be a string")
60
61
  assert(type(operation.name) == "string", "create.name must be a string")
61
62
  local instance = Instance.new(operation.className)
63
+ table.insert(created, instance)
62
64
  instance.Name = operation.name
63
65
  if operation.properties then
64
66
  setProperties(instance, operation.properties)
65
67
  end
66
68
  instance.Parent = parent
67
69
  table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(instance) })
70
+ elseif operation.op == "clone" then
71
+ local target = resolve(operation.target, "Operation " .. index .. " target")
72
+ assert(target ~= game and target.Parent ~= game, "Services cannot be cloned")
73
+ local parent = operation.parent and resolve(operation.parent, "Operation " .. index .. " parent") or target.Parent
74
+ assert(parent ~= nil, "Clone target has no parent")
75
+ local clone = target:Clone()
76
+ assert(clone ~= nil, "Clone target is not Archivable")
77
+ table.insert(created, clone)
78
+ if operation.name then
79
+ clone.Name = operation.name
80
+ end
81
+ if operation.properties then
82
+ setProperties(clone, operation.properties)
83
+ end
84
+ if operation.offset then
85
+ assert(clone:IsA("PVInstance"), "Only Models and BaseParts can be spatially offset")
86
+ local offset = ValueCodec.decodeForCurrent(Vector3.zero, operation.offset)
87
+ clone:PivotTo(clone:GetPivot() + offset)
88
+ end
89
+ clone.Parent = parent
90
+ table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(clone) })
68
91
  elseif operation.op == "move" then
69
92
  local target = resolve(operation.target, "Operation " .. index .. " target")
70
93
  local parent = resolve(operation.parent, "Operation " .. index .. " parent")
@@ -79,6 +102,13 @@ function Mutation.apply(spec)
79
102
 
80
103
  if not ok then
81
104
  ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
105
+ for _, instance in created do
106
+ pcall(function()
107
+ if instance.Parent == nil then
108
+ instance:Destroy()
109
+ end
110
+ end)
111
+ end
82
112
  return { success = false, error = tostring(err), rolledBack = true }
83
113
  end
84
114