rbxstudio-mcp 2.2.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rbxstudio-mcp",
3
- "version": "2.2.3",
3
+ "version": "2.3.0",
4
4
  "description": "MCP Server for Roblox Studio Integration - Access Studio data, scripts, and objects through AI tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -219,11 +219,19 @@ local function injectTestCompanion(serverUrl, sessionId)
219
219
  local script = Instance.new("Script")
220
220
  script.Name = TEST_COMPANION_NAME
221
221
  script.Source = source
222
- -- Archivable=false so a stray companion never gets saved into the user's
223
- -- place file even if cleanup somehow misses it. The play-test mechanism
224
- -- still includes it in the test DataModel because it duplicates the live
225
- -- in-memory tree (not the saved file).
226
- script.Archivable = false
222
+ -- IMPORTANT: Archivable MUST stay true (the default).
223
+ -- ExecutePlayModeAsync builds the test DataModel by Clone()-ing the Edit
224
+ -- DataModel, and Instance:Clone() silently returns nil for any instance
225
+ -- with Archivable=false. We previously set this to false as a "safety"
226
+ -- against the companion getting saved into the user's place file — but
227
+ -- the side effect was that the companion never made it into the test
228
+ -- session at all. No companion → no log streaming and no way to receive
229
+ -- the "end" command, so stop_play would just time out.
230
+ --
231
+ -- We rely on the cleanup sweeps (on plugin activate, before each test,
232
+ -- after each test) to keep companions out of the saved file. The script
233
+ -- is also tagged with TEST_COMPANION_TAG so any orphan is easy to find.
234
+ script.Archivable = true
227
235
  script.Parent = game:GetService("ServerScriptService")
228
236
 
229
237
  pcall(function()
@@ -4307,6 +4315,7 @@ handlers.executeLua = function(requestData)
4307
4315
  NumberRange = NumberRange,
4308
4316
  TweenInfo = TweenInfo,
4309
4317
  Font = Font,
4318
+ Content = Content, -- Required for AssetService:CreateEditableImageAsync(Content.fromUri(...))
4310
4319
 
4311
4320
  -- Enums
4312
4321
  Enum = Enum,
@@ -4563,6 +4572,34 @@ handlers.playSolo = function(requestData)
4563
4572
  }
4564
4573
  end
4565
4574
 
4575
+ -- HttpService.HttpEnabled preflight.
4576
+ --
4577
+ -- Plugins (this script) bypass HttpEnabled, but in-game scripts — and
4578
+ -- that includes our injected companion running inside the test
4579
+ -- DataModel — do not. Without HttpEnabled the companion can't reach
4580
+ -- the bridge to stream logs or receive the "end" command, so stop_play
4581
+ -- silently times out from the AI's perspective.
4582
+ --
4583
+ -- We can't fix this for the user: HttpEnabled is gated by
4584
+ -- LocalUserSecurity, which means a plugin literally cannot flip it
4585
+ -- from code. Best we can do is fail fast with an actionable error so
4586
+ -- the AI/user knows exactly what to toggle.
4587
+ local httpEnabledOk, httpEnabled = pcall(function()
4588
+ return HttpService.HttpEnabled
4589
+ end)
4590
+ if httpEnabledOk and httpEnabled == false then
4591
+ return {
4592
+ success = false,
4593
+ error = "HttpService.HttpEnabled is false. The injected test-session companion runs as an in-game script and needs HTTP access to stream logs and receive the stop command. Enable it in Studio: Game Settings → Security → Allow HTTP Requests (or Home → Game Settings → Security). A plugin cannot toggle this setting itself (LocalUserSecurity).",
4594
+ fixSteps = {
4595
+ "Open Game Settings (Home tab → Game Settings, or File → Game Settings)",
4596
+ "Go to the Security section",
4597
+ "Toggle 'Allow HTTP Requests' ON",
4598
+ "Click Save, then call play_solo again",
4599
+ },
4600
+ }
4601
+ end
4602
+
4566
4603
  -- If a previous test was somehow left running (e.g. plugin reloaded
4567
4604
  -- mid-test, user clicked Stop without us hearing), best-effort it.
4568
4605
  -- We can't detect Play mode from a plugin reliably, so we just do an
@@ -4686,13 +4723,19 @@ local function base64encode(data)
4686
4723
  return table.concat(result)
4687
4724
  end
4688
4725
 
4726
+ -- Maximum raw RGBA pixel-data size we're willing to send across the HTTP bridge.
4727
+ -- At 16 MB raw -> ~21.3 MB base64 -> well within typical localhost HTTP body limits,
4728
+ -- and the TS layer compresses to PNG before returning to clients.
4729
+ -- 16 MB / 4 bytes/pixel = 4,194,304 pixels (e.g., 2048x2048).
4730
+ local MAX_RAW_PIXEL_BYTES = 16 * 1024 * 1024
4731
+
4689
4732
  handlers.captureScreenshot = function(requestData)
4690
4733
  local CaptureService = game:GetService("CaptureService")
4691
4734
  local AssetService = game:GetService("AssetService")
4692
4735
 
4693
4736
  -- Parameters (default 768 to avoid CaptureService bug with <600px)
4694
- local maxWidth = requestData.maxWidth or 768
4695
- local maxHeight = requestData.maxHeight or 768
4737
+ local maxWidth = tonumber(requestData.maxWidth) or 768
4738
+ local maxHeight = tonumber(requestData.maxHeight) or 768
4696
4739
  local returnBase64 = requestData.returnBase64 ~= false -- Default true
4697
4740
 
4698
4741
  -- Use a BindableEvent to wait for the async callback
@@ -4760,68 +4803,107 @@ handlers.captureScreenshot = function(requestData)
4760
4803
  message = "Screenshot captured successfully"
4761
4804
  }
4762
4805
 
4763
- -- Resize if needed and return base64
4764
- if returnBase64 then
4765
- -- Calculate resize dimensions maintaining aspect ratio
4766
- local scaleX = maxWidth / originalSize.X
4767
- local scaleY = maxHeight / originalSize.Y
4768
- local scale = math.min(scaleX, scaleY, 1) -- Don't upscale
4769
-
4770
- local newWidth = math.floor(originalSize.X * scale)
4771
- local newHeight = math.floor(originalSize.Y * scale)
4772
-
4773
- -- Create resized image
4774
- local resizedImage
4775
- if scale < 1 then
4776
- resizedImage = AssetService:CreateEditableImage({Size = Vector2.new(newWidth, newHeight)})
4777
- -- Draw the original scaled down
4806
+ if not returnBase64 then
4807
+ editableImage:Destroy()
4808
+ return resultData
4809
+ end
4810
+
4811
+ -- Step 1: Calculate target size from caller's max bounds (no upscale).
4812
+ local scaleX = maxWidth / originalSize.X
4813
+ local scaleY = maxHeight / originalSize.Y
4814
+ local scale = math.min(scaleX, scaleY, 1)
4815
+
4816
+ local targetWidth = math.max(1, math.floor(originalSize.X * scale))
4817
+ local targetHeight = math.max(1, math.floor(originalSize.Y * scale))
4818
+
4819
+ -- Step 2: Auto-shrink if pixel-data would exceed our HTTP-bridge cap.
4820
+ -- We keep aspect ratio; pick scale = sqrt(cap / required_bytes).
4821
+ local requiredBytes = targetWidth * targetHeight * 4
4822
+ local autoShrunk = false
4823
+ if requiredBytes > MAX_RAW_PIXEL_BYTES then
4824
+ local shrink = math.sqrt(MAX_RAW_PIXEL_BYTES / requiredBytes)
4825
+ targetWidth = math.max(1, math.floor(targetWidth * shrink))
4826
+ targetHeight = math.max(1, math.floor(targetHeight * shrink))
4827
+ autoShrunk = true
4828
+ end
4829
+
4830
+ -- Step 3: Build the final image (resize via DrawImageTransformed if needed).
4831
+ local resizedImage
4832
+ local needsResize = (targetWidth ~= originalSize.X) or (targetHeight ~= originalSize.Y)
4833
+ if needsResize then
4834
+ local createOk, createdImage = pcall(function()
4835
+ return AssetService:CreateEditableImage({Size = Vector2.new(targetWidth, targetHeight)})
4836
+ end)
4837
+ if not createOk or not createdImage then
4838
+ editableImage:Destroy()
4839
+ return {
4840
+ success = false,
4841
+ error = "Failed to allocate resized EditableImage: " .. tostring(createdImage),
4842
+ contentId = tostring(captureResult)
4843
+ }
4844
+ end
4845
+ resizedImage = createdImage
4846
+
4847
+ local sx = targetWidth / originalSize.X
4848
+ local sy = targetHeight / originalSize.Y
4849
+ local drawOk, drawErr = pcall(function()
4778
4850
  resizedImage:DrawImageTransformed(
4779
- Vector2.new(0, 0), -- position
4780
- Vector2.new(scale, scale), -- scale
4781
- 0, -- rotation
4851
+ Vector2.new(0, 0),
4852
+ Vector2.new(sx, sy),
4853
+ 0,
4782
4854
  editableImage,
4783
- {
4784
- CombineType = Enum.ImageCombineType.Overwrite
4785
- }
4855
+ { CombineType = Enum.ImageCombineType.Overwrite }
4786
4856
  )
4787
- else
4788
- resizedImage = editableImage
4789
- newWidth = originalSize.X
4790
- newHeight = originalSize.Y
4791
- end
4792
-
4793
- -- Read pixels
4794
- local pixelSuccess, pixelBuffer = pcall(function()
4795
- return resizedImage:ReadPixelsBuffer(Vector2.new(0, 0), Vector2.new(newWidth, newHeight))
4796
4857
  end)
4797
-
4798
- if pixelSuccess and pixelBuffer then
4799
- -- Convert buffer to string for base64 encoding
4800
- local pixelString = buffer.tostring(pixelBuffer)
4801
-
4802
- -- Base64 encode (for smaller images only due to size)
4803
- if #pixelString <= 1024 * 1024 then -- Max 1MB raw
4804
- local base64Data = base64encode(pixelString)
4805
- resultData.base64 = base64Data
4806
- resultData.width = newWidth
4807
- resultData.height = newHeight
4808
- resultData.format = "RGBA"
4809
- resultData.encoding = "base64"
4810
- resultData.pixelDataSize = #pixelString
4811
- else
4812
- resultData.warning = "Image too large for base64 encoding (" .. #pixelString .. " bytes). Use smaller maxWidth/maxHeight."
4813
- resultData.width = newWidth
4814
- resultData.height = newHeight
4815
- end
4816
- else
4817
- resultData.warning = "Failed to read pixels: " .. tostring(pixelBuffer)
4858
+ if not drawOk then
4859
+ editableImage:Destroy()
4860
+ resizedImage:Destroy()
4861
+ return {
4862
+ success = false,
4863
+ error = "Failed to resize image: " .. tostring(drawErr),
4864
+ contentId = tostring(captureResult)
4865
+ }
4818
4866
  end
4867
+ else
4868
+ resizedImage = editableImage
4869
+ end
4819
4870
 
4820
- if resizedImage ~= editableImage then
4821
- resizedImage:Destroy()
4871
+ -- Step 4: Read pixels, possibly retrying with even smaller size if Roblox refuses.
4872
+ local pixelString
4873
+ local readOk, pixelBuffer = pcall(function()
4874
+ return resizedImage:ReadPixelsBuffer(Vector2.new(0, 0), Vector2.new(targetWidth, targetHeight))
4875
+ end)
4876
+ if readOk and pixelBuffer then
4877
+ pixelString = buffer.tostring(pixelBuffer)
4878
+ end
4879
+
4880
+ if pixelString and #pixelString == targetWidth * targetHeight * 4 then
4881
+ resultData.base64 = base64encode(pixelString)
4882
+ resultData.width = targetWidth
4883
+ resultData.height = targetHeight
4884
+ resultData.format = "RGBA"
4885
+ resultData.encoding = "base64"
4886
+ resultData.pixelDataSize = #pixelString
4887
+ if autoShrunk then
4888
+ resultData.autoShrunk = true
4889
+ resultData.message = string.format(
4890
+ "Screenshot captured. Auto-shrunk from requested %dx%d to %dx%d to stay under HTTP bridge limit.",
4891
+ math.floor(originalSize.X * scale), math.floor(originalSize.Y * scale),
4892
+ targetWidth, targetHeight
4893
+ )
4822
4894
  end
4895
+ else
4896
+ resultData.success = false
4897
+ resultData.error = "Failed to read pixel buffer (got " ..
4898
+ tostring(pixelString and #pixelString or "nil") ..
4899
+ " bytes, expected " .. tostring(targetWidth * targetHeight * 4) .. ")"
4900
+ resultData.width = targetWidth
4901
+ resultData.height = targetHeight
4823
4902
  end
4824
4903
 
4904
+ if resizedImage ~= editableImage then
4905
+ resizedImage:Destroy()
4906
+ end
4825
4907
  editableImage:Destroy()
4826
4908
 
4827
4909
  return resultData
@@ -4831,26 +4913,44 @@ end
4831
4913
  -- VIEWPORTFRAME RENDERING SYSTEM
4832
4914
  -- ============================================
4833
4915
 
4834
- -- Camera angle presets
4916
+ -- Camera angle presets — UNIT DIRECTION VECTORS pointing FROM target TO camera.
4917
+ -- We construct the final camera CFrame via CFrame.lookAt(center + dir*distance, center)
4918
+ -- so look-at orientation is always clean, and rotation isn't double-applied.
4835
4919
  local CameraPresets = {
4836
- -- Standard orthographic views
4837
- front = CFrame.new(0, 0, 10) * CFrame.Angles(0, 0, 0),
4838
- back = CFrame.new(0, 0, -10) * CFrame.Angles(0, math.pi, 0),
4839
- left = CFrame.new(-10, 0, 0) * CFrame.Angles(0, math.pi/2, 0),
4840
- right = CFrame.new(10, 0, 0) * CFrame.Angles(0, -math.pi/2, 0),
4841
- top = CFrame.new(0, 10, 0) * CFrame.Angles(-math.pi/2, 0, 0),
4842
- bottom = CFrame.new(0, -10, 0) * CFrame.Angles(math.pi/2, 0, 0),
4843
-
4844
- -- Isometric views (popular for thumbnails)
4845
- iso = CFrame.new(7, 7, 7) * CFrame.Angles(-math.pi/6, math.pi/4, 0),
4846
- iso_front = CFrame.new(5, 5, 10) * CFrame.Angles(-math.pi/6, 0, 0),
4847
- iso_back = CFrame.new(-5, 5, -10) * CFrame.Angles(-math.pi/6, math.pi, 0),
4848
-
4849
- -- Dramatic angles
4850
- low_angle = CFrame.new(0, -5, 10) * CFrame.Angles(math.pi/6, 0, 0),
4851
- high_angle = CFrame.new(0, 15, 8) * CFrame.Angles(-math.pi/3, 0, 0),
4920
+ -- Orthographic views (camera looks toward origin from +X/+Y/+Z etc.)
4921
+ front = Vector3.new( 0, 0, 1).Unit,
4922
+ back = Vector3.new( 0, 0, -1).Unit,
4923
+ right = Vector3.new( 1, 0, 0).Unit,
4924
+ left = Vector3.new(-1, 0, 0).Unit,
4925
+ top = Vector3.new( 0, 1, 0).Unit,
4926
+ bottom = Vector3.new( 0, -1, 0).Unit,
4927
+
4928
+ -- Isometric views
4929
+ iso = Vector3.new( 1, 1, 1).Unit,
4930
+ iso_front = Vector3.new( 0, 1, 1).Unit,
4931
+ iso_back = Vector3.new( 0, 1, -1).Unit,
4932
+
4933
+ -- Dramatic
4934
+ low_angle = Vector3.new( 0, -0.4, 1).Unit,
4935
+ high_angle = Vector3.new( 0, 1.2, 0.6).Unit,
4852
4936
  }
4853
4937
 
4938
+ -- Resolve an angle parameter (string preset OR table {pitch,yaw,roll}) to a unit direction
4939
+ local function resolveCameraDirection(angleParam)
4940
+ if type(angleParam) == "string" and CameraPresets[angleParam] then
4941
+ return CameraPresets[angleParam]
4942
+ elseif type(angleParam) == "table" then
4943
+ -- Custom: pitch (X-axis tilt), yaw (Y-axis spin), roll ignored for direction
4944
+ local pitch = math.rad(tonumber(angleParam.pitch) or 0)
4945
+ local yaw = math.rad(tonumber(angleParam.yaw) or 0)
4946
+ -- Start with "front" direction (+Z), apply yaw around Y, then pitch around X
4947
+ local dir = (CFrame.Angles(0, yaw, 0) * CFrame.Angles(pitch, 0, 0)).LookVector
4948
+ -- LookVector points FROM camera TO target. We want FROM target TO camera, so negate.
4949
+ return (-dir).Unit
4950
+ end
4951
+ return CameraPresets.iso
4952
+ end
4953
+
4854
4954
  -- Apply lighting preset to WorldModel
4855
4955
  local function applyLightingPreset(worldModel, preset)
4856
4956
  -- Remove existing lighting
@@ -4899,186 +4999,229 @@ local function applyLightingPreset(worldModel, preset)
4899
4999
  -- default: no lights added (ambient only)
4900
5000
  end
4901
5001
 
4902
- -- Calculate bounding box of a model
4903
- local function getModelBoundingBox(model)
5002
+ -- Calculate world-space axis-aligned bounding box of a Model OR a single BasePart.
5003
+ -- Properly accounts for part rotation by transforming the 8 corners of each part's
5004
+ -- local-space size box into world space.
5005
+ local function getModelBoundingBox(target)
5006
+ -- Collect parts (including the target itself if it's a BasePart)
4904
5007
  local parts = {}
4905
-
4906
- -- Collect all BaseParts
4907
- for _, child in ipairs(model:GetDescendants()) do
5008
+ if target:IsA("BasePart") then
5009
+ table.insert(parts, target)
5010
+ end
5011
+ for _, child in ipairs(target:GetDescendants()) do
4908
5012
  if child:IsA("BasePart") then
4909
5013
  table.insert(parts, child)
4910
5014
  end
4911
5015
  end
4912
5016
 
5017
+ -- Prefer Model:GetBoundingBox() when available — it handles rotation correctly
5018
+ -- and covers attachments/MeshParts uniformly. Fallback only if it fails.
5019
+ if target:IsA("Model") then
5020
+ local ok, cf, sz = pcall(function() return target:GetBoundingBox() end)
5021
+ if ok and cf and sz and sz.Magnitude > 0 then
5022
+ return sz, cf.Position
5023
+ end
5024
+ end
5025
+
4913
5026
  if #parts == 0 then
4914
- -- No parts found, return default
4915
5027
  return Vector3.new(1, 1, 1), Vector3.new(0, 0, 0)
4916
5028
  end
4917
5029
 
4918
- -- Calculate bounding box
4919
- local minPos = parts[1].Position - parts[1].Size/2
4920
- local maxPos = parts[1].Position + parts[1].Size/2
5030
+ -- Manual rotated AABB: project each part's 8 corners through its CFrame
5031
+ local huge = math.huge
5032
+ local minX, minY, minZ = huge, huge, huge
5033
+ local maxX, maxY, maxZ = -huge, -huge, -huge
4921
5034
 
4922
- for _, part in ipairs(parts) do
4923
- local partMin = part.Position - part.Size/2
4924
- local partMax = part.Position + part.Size/2
5035
+ local CORNER_SIGNS = {
5036
+ Vector3.new( 1, 1, 1), Vector3.new( 1, 1, -1),
5037
+ Vector3.new( 1, -1, 1), Vector3.new( 1, -1, -1),
5038
+ Vector3.new(-1, 1, 1), Vector3.new(-1, 1, -1),
5039
+ Vector3.new(-1, -1, 1), Vector3.new(-1, -1, -1),
5040
+ }
4925
5041
 
4926
- minPos = Vector3.new(
4927
- math.min(minPos.X, partMin.X),
4928
- math.min(minPos.Y, partMin.Y),
4929
- math.min(minPos.Z, partMin.Z)
4930
- )
4931
- maxPos = Vector3.new(
4932
- math.max(maxPos.X, partMax.X),
4933
- math.max(maxPos.Y, partMax.Y),
4934
- math.max(maxPos.Z, partMax.Z)
4935
- )
5042
+ for _, part in ipairs(parts) do
5043
+ local cf = part.CFrame
5044
+ local h = part.Size / 2
5045
+ for _, s in ipairs(CORNER_SIGNS) do
5046
+ local world = cf * Vector3.new(h.X * s.X, h.Y * s.Y, h.Z * s.Z)
5047
+ if world.X < minX then minX = world.X end
5048
+ if world.Y < minY then minY = world.Y end
5049
+ if world.Z < minZ then minZ = world.Z end
5050
+ if world.X > maxX then maxX = world.X end
5051
+ if world.Y > maxY then maxY = world.Y end
5052
+ if world.Z > maxZ then maxZ = world.Z end
5053
+ end
4936
5054
  end
4937
5055
 
4938
- local size = maxPos - minPos
4939
- local center = (minPos + maxPos) / 2
4940
-
5056
+ local size = Vector3.new(maxX - minX, maxY - minY, maxZ - minZ)
5057
+ local center = Vector3.new((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2)
4941
5058
  return size, center
4942
5059
  end
4943
5060
 
4944
- -- Main handler: Render Object View
5061
+ --[[
5062
+ render_object_view — REWRITTEN (v2)
5063
+
5064
+ Strategy:
5065
+ 1. Build a ViewportFrame that fills the ENTIRE 3D capture area (CoreGui, IgnoreGuiInset,
5066
+ UDim2.fromScale(1,1)). Empirically verified that this exactly equals
5067
+ Camera.ViewportSize and the area CaptureService:CaptureScreenshot() captures.
5068
+ 2. Compute camera distance using the bounding sphere + min(vFOV, hFOV) so the
5069
+ entire object fits regardless of screen aspect ratio.
5070
+ 3. Use CFrame.lookAt() — no double-rotation hack.
5071
+ 4. CaptureService captures the screen; the viewport content fills it edge-to-edge.
5072
+ 5. Center-CROP the captured image to the requested aspect ratio (preserves shape).
5073
+ 6. Resize to target dimensions with a SINGLE scale factor (no stretch).
5074
+
5075
+ This actually delivers what the docs promised:
5076
+ - No Studio chrome bleed
5077
+ - Correct aspect ratio
5078
+ - Correct camera orientation
5079
+ - Works at any resolution from 64×64 to 2048×2048
5080
+ ]]
4945
5081
  handlers.renderObjectView = function(requestData)
5082
+ local CaptureService = game:GetService("CaptureService")
5083
+ local AssetService = game:GetService("AssetService")
5084
+ local CoreGui = game:GetService("CoreGui")
5085
+
4946
5086
  local success, result = pcall(function()
5087
+ -- ──────────── parse params ────────────
4947
5088
  local instancePath = requestData.instancePath
4948
5089
  if not instancePath then
4949
- return {
4950
- success = false,
4951
- error = "instancePath is required"
4952
- }
5090
+ return { success = false, error = "instancePath is required" }
4953
5091
  end
4954
5092
 
4955
- -- Parse resolution (default 768 to avoid CaptureService bug with <600px)
4956
- local resolution = requestData.resolution or {width = 768, height = 768}
4957
- local width = resolution.width or 768
4958
- local height = resolution.height or 768
4959
-
4960
- -- Clamp resolution for performance
4961
- width = math.clamp(width, 64, 2048)
4962
- height = math.clamp(height, 64, 2048)
5093
+ local resolution = requestData.resolution or { width = 768, height = 768 }
5094
+ local targetW = math.clamp(tonumber(resolution.width) or 768, 64, 2048)
5095
+ local targetH = math.clamp(tonumber(resolution.height) or 768, 64, 2048)
4963
5096
 
4964
- local anglePreset = requestData.angle or "iso"
4965
- local lighting = requestData.lighting or "bright"
4966
- local background = requestData.background or "transparent"
5097
+ local angleParam = requestData.angle or "iso"
5098
+ local lighting = requestData.lighting or "bright"
5099
+ local background = requestData.background or "solid"
4967
5100
  local autoDistance = requestData.autoDistance ~= false
4968
5101
 
4969
- -- Get target instance
4970
- local targetInstance = getInstanceByPath(instancePath)
4971
- if not targetInstance then
4972
- return {
4973
- success = false,
4974
- error = "Instance not found: " .. instancePath
4975
- }
5102
+ -- ──────────── resolve target ────────────
5103
+ local target = getInstanceByPath(instancePath)
5104
+ if not target then
5105
+ return { success = false, error = "Instance not found: " .. instancePath }
4976
5106
  end
4977
5107
 
4978
- -- Create ViewportFrame (no parent = no GUI overhead)
5108
+ -- ──────────── build viewport ────────────
5109
+ local tempGui = Instance.new("ScreenGui")
5110
+ tempGui.Name = "MCPRenderViewport"
5111
+ tempGui.IgnoreGuiInset = true
5112
+ tempGui.ResetOnSpawn = false
5113
+ tempGui.DisplayOrder = 2147483646
5114
+ tempGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
5115
+
4979
5116
  local viewportFrame = Instance.new("ViewportFrame")
4980
- viewportFrame.Size = UDim2.fromOffset(width, height)
4981
- viewportFrame.BackgroundTransparency = background == "transparent" and 1 or 0
4982
- viewportFrame.BackgroundColor3 = background == "grid" and Color3.fromRGB(128, 128, 128) or Color3.fromRGB(255, 255, 255)
5117
+ viewportFrame.Size = UDim2.fromScale(1, 1)
5118
+ viewportFrame.Position = UDim2.fromScale(0, 0)
5119
+ viewportFrame.BorderSizePixel = 0
5120
+ viewportFrame.ZIndex = 1
5121
+ if background == "transparent" then
5122
+ viewportFrame.BackgroundTransparency = 1
5123
+ else
5124
+ viewportFrame.BackgroundTransparency = 0
5125
+ -- Default solid bg = white. Allow caller to pass a custom color.
5126
+ local bgColor = requestData.backgroundColor
5127
+ if typeof(bgColor) == "table" and bgColor.r and bgColor.g and bgColor.b then
5128
+ viewportFrame.BackgroundColor3 = Color3.fromRGB(
5129
+ tonumber(bgColor.r) or 255,
5130
+ tonumber(bgColor.g) or 255,
5131
+ tonumber(bgColor.b) or 255
5132
+ )
5133
+ elseif background == "grid" then
5134
+ viewportFrame.BackgroundColor3 = Color3.fromRGB(220, 220, 220)
5135
+ else
5136
+ viewportFrame.BackgroundColor3 = Color3.fromRGB(245, 245, 245)
5137
+ end
5138
+ end
5139
+ viewportFrame.Parent = tempGui
4983
5140
 
4984
- -- Create WorldModel for viewport
4985
5141
  local worldModel = Instance.new("WorldModel")
4986
5142
  worldModel.Parent = viewportFrame
4987
5143
 
4988
5144
  local camera = Instance.new("Camera")
5145
+ camera.FieldOfView = 70
4989
5146
  camera.Parent = viewportFrame
4990
5147
  viewportFrame.CurrentCamera = camera
4991
5148
 
4992
- -- Clone target into WorldModel
4993
- local clonedInstance = targetInstance:Clone()
4994
- clonedInstance.Parent = worldModel
5149
+ -- Clone target into world (preserves all properties incl. mesh/textures)
5150
+ local cloned = target:Clone()
5151
+ -- Anchor everything so physics doesn't move it
5152
+ if cloned:IsA("BasePart") then
5153
+ cloned.Anchored = true
5154
+ end
5155
+ for _, d in ipairs(cloned:GetDescendants()) do
5156
+ if d:IsA("BasePart") then d.Anchored = true end
5157
+ end
5158
+ cloned.Parent = worldModel
4995
5159
 
4996
- -- Calculate bounding box and center
4997
- local boundingSize, boundingCenter = getModelBoundingBox(clonedInstance)
4998
- local maxDimension = math.max(boundingSize.X, boundingSize.Y, boundingSize.Z)
5160
+ -- ──────────── camera positioning ────────────
5161
+ local boundSize, boundCenter = getModelBoundingBox(cloned)
5162
+ -- Bounding sphere radius (fits any orientation)
5163
+ local sphereRadius = math.max(boundSize.Magnitude / 2, 0.5)
4999
5164
 
5000
- -- Position camera
5001
- local cameraOffset
5002
- if type(anglePreset) == "string" and CameraPresets[anglePreset] then
5003
- cameraOffset = CameraPresets[anglePreset]
5004
- elseif type(anglePreset) == "table" then
5005
- -- Custom angle {pitch, yaw, roll, distance}
5006
- local pitch = math.rad(anglePreset.pitch or 0)
5007
- local yaw = math.rad(anglePreset.yaw or 0)
5008
- local roll = math.rad(anglePreset.roll or 0)
5009
- local distance = anglePreset.distance or 10
5010
- cameraOffset = CFrame.new(0, 0, distance) * CFrame.Angles(pitch, yaw, roll)
5011
- else
5012
- cameraOffset = CameraPresets.iso
5013
- end
5165
+ -- Parent the gui NOW so AbsoluteSize is populated for the viewport
5166
+ tempGui.Parent = CoreGui
5167
+ RunService.Heartbeat:Wait()
5014
5168
 
5015
- -- Auto-calculate distance to fit object
5016
- local cameraDistance = 10
5169
+ local screenSize = viewportFrame.AbsoluteSize
5170
+ if screenSize.X < 1 or screenSize.Y < 1 then
5171
+ -- Fallback: use camera.ViewportSize
5172
+ screenSize = workspace.CurrentCamera.ViewportSize
5173
+ end
5174
+ local sourceAspect = screenSize.X / screenSize.Y
5175
+
5176
+ -- The captured image will be center-cropped to the TARGET aspect ratio
5177
+ -- before being resized to (targetW × targetH). So we must size the camera
5178
+ -- distance based on the smaller FOV of the *post-crop* viewport, not the source.
5179
+ -- Vertical FOV is fixed at camera.FieldOfView regardless of crop.
5180
+ -- Horizontal FOV after crop = 2*atan(tan(vFOV/2) * effectiveAspect), where
5181
+ -- effectiveAspect is the smaller of source vs target aspect (we never gain pixels).
5182
+ local vFovRad = math.rad(camera.FieldOfView)
5183
+ local targetAspect = targetW / targetH
5184
+ local effectiveAspect = math.min(sourceAspect, targetAspect)
5185
+ local effectiveHFovRad = 2 * math.atan(math.tan(vFovRad / 2) * effectiveAspect)
5186
+ local minFov = math.min(vFovRad, effectiveHFovRad)
5187
+
5188
+ -- Distance so bounding sphere fits in the smaller FOV (with padding).
5189
+ local PADDING = 1.15
5190
+ local cameraDistance
5017
5191
  if autoDistance then
5018
- local fov = 70
5019
- cameraDistance = maxDimension / (2 * math.tan(math.rad(fov / 2))) * 1.3
5020
- cameraDistance = math.max(cameraDistance, 1) -- Minimum distance
5021
- elseif type(anglePreset) == "table" and anglePreset.distance then
5022
- cameraDistance = anglePreset.distance
5192
+ cameraDistance = (sphereRadius / math.sin(minFov / 2)) * PADDING
5193
+ elseif type(angleParam) == "table" and angleParam.distance then
5194
+ cameraDistance = tonumber(angleParam.distance) or 10
5195
+ else
5196
+ cameraDistance = 10
5023
5197
  end
5024
5198
 
5025
- -- Apply distance to camera offset
5026
- local offsetDirection = cameraOffset.Position.Unit
5027
- local finalCameraPos = boundingCenter + (offsetDirection * cameraDistance)
5199
+ local dir = resolveCameraDirection(angleParam)
5200
+ local cameraPos = boundCenter + dir * cameraDistance
5201
+ camera.CFrame = CFrame.lookAt(cameraPos, boundCenter)
5028
5202
 
5029
- camera.CFrame = CFrame.new(finalCameraPos, boundingCenter) * cameraOffset.Rotation
5030
- camera.FieldOfView = 70
5031
-
5032
- -- Apply lighting
5203
+ -- ──────────── lighting + grid ────────────
5033
5204
  applyLightingPreset(worldModel, lighting)
5034
5205
 
5035
- -- Add grid background if requested
5036
5206
  if background == "grid" then
5037
5207
  local gridPart = Instance.new("Part")
5038
- gridPart.Size = Vector3.new(maxDimension * 5, 0.1, maxDimension * 5)
5039
- gridPart.Position = boundingCenter - Vector3.new(0, boundingSize.Y/2 + 0.1, 0)
5208
+ gridPart.Size = Vector3.new(sphereRadius * 8, 0.05, sphereRadius * 8)
5209
+ gridPart.Position = boundCenter - Vector3.new(0, boundSize.Y / 2 + 0.025, 0)
5040
5210
  gridPart.Anchored = true
5041
5211
  gridPart.Material = Enum.Material.SmoothPlastic
5042
- gridPart.Color = Color3.fromRGB(200, 200, 200)
5212
+ gridPart.Color = Color3.fromRGB(180, 180, 180)
5043
5213
  gridPart.Parent = worldModel
5044
5214
  end
5045
5215
 
5046
- -- Wait for viewport to render
5047
- task.wait(0.1)
5216
+ -- Wait for the viewport to draw at its new size
5048
5217
  RunService.Heartbeat:Wait()
5049
-
5050
- -- WORKAROUND: ViewportFrame doesn't support direct pixel reading
5051
- -- We need to temporarily parent it to screen and use CaptureService
5052
- -- This is a limitation of Roblox's API
5053
- local StarterGui = game:GetService("StarterGui")
5054
- local CaptureService = game:GetService("CaptureService")
5055
- local AssetService = game:GetService("AssetService")
5056
-
5057
- -- Create temporary ScreenGui
5058
- local tempGui = Instance.new("ScreenGui")
5059
- tempGui.Name = "MCPTempCapture"
5060
- tempGui.ResetOnSpawn = false
5061
- tempGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
5062
-
5063
- -- CRITICAL FIX: Scale ViewportFrame to fill most of the screen before capture
5064
- -- CaptureScreenshot captures the ENTIRE screen, so a small ViewportFrame = tiny object
5065
- -- By scaling to ~90% of screen, the object fills most of the captured image
5066
- local screenSize = workspace.CurrentCamera.ViewportSize
5067
- viewportFrame.Size = UDim2.fromOffset(screenSize.X * 0.9, screenSize.Y * 0.9)
5068
- viewportFrame.Position = UDim2.fromOffset(screenSize.X * 0.05, screenSize.Y * 0.05)
5069
- viewportFrame.Parent = tempGui
5070
- tempGui.Parent = game.CoreGui
5071
-
5072
- -- Wait a frame for rendering
5073
5218
  RunService.Heartbeat:Wait()
5074
- task.wait(0.05) -- Extra wait to ensure viewport renders at new size
5075
5219
 
5076
- -- Capture screenshot of the viewport
5220
+ -- ──────────── capture ────────────
5077
5221
  local captureComplete = Instance.new("BindableEvent")
5078
- local captureResult = nil
5079
- local captureError = nil
5222
+ local captureResult, captureError = nil, nil
5080
5223
 
5081
- pcall(function()
5224
+ local pcOk, pcErr = pcall(function()
5082
5225
  CaptureService:CaptureScreenshot(function(contentId)
5083
5226
  if contentId then
5084
5227
  captureResult = contentId
@@ -5089,78 +5232,133 @@ handlers.renderObjectView = function(requestData)
5089
5232
  end)
5090
5233
  end)
5091
5234
 
5092
- -- Wait with timeout
5093
- local timeoutThread = task.delay(5, function()
5235
+ if not pcOk then
5236
+ tempGui:Destroy()
5237
+ return { success = false, error = "CaptureScreenshot threw: " .. tostring(pcErr) }
5238
+ end
5239
+
5240
+ local timeoutThread = task.delay(8, function()
5094
5241
  if not captureResult and not captureError then
5095
- captureError = "Capture timed out"
5242
+ captureError = "Capture timed out (8s)"
5096
5243
  captureComplete:Fire()
5097
5244
  end
5098
5245
  end)
5099
-
5100
5246
  captureComplete.Event:Wait()
5101
5247
  task.cancel(timeoutThread)
5102
5248
  captureComplete:Destroy()
5249
+
5250
+ -- We can destroy the gui now; the contentId is already saved
5103
5251
  tempGui:Destroy()
5104
- viewportFrame:Destroy()
5105
5252
 
5106
5253
  if captureError then
5107
- return {
5108
- success = false,
5109
- error = "Failed to capture viewport: " .. captureError,
5110
- note = "ViewportFrame capture requires CaptureService (limitation of Roblox API)"
5111
- }
5254
+ return { success = false, error = "Failed to capture viewport: " .. captureError }
5112
5255
  end
5113
5256
 
5114
- -- Load captured image
5115
- local editableImage = AssetService:CreateEditableImageAsync(Content.fromUri(tostring(captureResult)))
5257
+ -- ──────────── crop + resize ────────────
5258
+ local sourceImage = AssetService:CreateEditableImageAsync(Content.fromUri(tostring(captureResult)))
5259
+ local sourceSize = sourceImage.Size
5260
+ local srcAspect = sourceSize.X / sourceSize.Y
5261
+ local targetAspect = targetW / targetH
5116
5262
 
5117
- -- Resize if needed
5118
- local originalSize = editableImage.Size
5119
- if originalSize.X ~= width or originalSize.Y ~= height then
5120
- local resizedImage = AssetService:CreateEditableImage({Size = Vector2.new(width, height)})
5121
- local scaleX = width / originalSize.X
5122
- local scaleY = height / originalSize.Y
5123
- resizedImage:DrawImageTransformed(
5124
- Vector2.new(0, 0),
5125
- Vector2.new(scaleX, scaleY),
5263
+ -- Center-crop the source to match target aspect ratio.
5264
+ local cropW, cropH
5265
+ if srcAspect > targetAspect then
5266
+ -- Source is wider than target → crop sides
5267
+ cropH = sourceSize.Y
5268
+ cropW = math.floor(sourceSize.Y * targetAspect)
5269
+ else
5270
+ -- Source is taller than target → crop top/bottom
5271
+ cropW = sourceSize.X
5272
+ cropH = math.floor(sourceSize.X / targetAspect)
5273
+ end
5274
+ cropW = math.max(cropW, 1)
5275
+ cropH = math.max(cropH, 1)
5276
+ local cropX = math.floor((sourceSize.X - cropW) / 2)
5277
+ local cropY = math.floor((sourceSize.Y - cropH) / 2)
5278
+
5279
+ -- Build cropped image
5280
+ local croppedImage = AssetService:CreateEditableImage({
5281
+ Size = Vector2.new(cropW, cropH),
5282
+ })
5283
+ -- DrawImageTransformed with offset = -cropOrigin draws the cropped region at (0,0)
5284
+ croppedImage:DrawImageTransformed(
5285
+ Vector2.new(cropW / 2, cropH / 2), -- destination center
5286
+ Vector2.new(1, 1), -- no scale
5287
+ 0, -- no rotation
5288
+ sourceImage,
5289
+ {
5290
+ CombineType = Enum.ImageCombineType.Overwrite,
5291
+ PivotPoint = Vector2.new(cropX + cropW / 2, cropY + cropH / 2),
5292
+ }
5293
+ )
5294
+ sourceImage:Destroy()
5295
+
5296
+ -- Resize cropped image to exact target dimensions (uniform scale = no stretch)
5297
+ local outputImage
5298
+ if cropW == targetW and cropH == targetH then
5299
+ outputImage = croppedImage
5300
+ else
5301
+ outputImage = AssetService:CreateEditableImage({
5302
+ Size = Vector2.new(targetW, targetH),
5303
+ })
5304
+ local scale = targetW / cropW -- equals targetH / cropH (matched aspects)
5305
+ outputImage:DrawImageTransformed(
5306
+ Vector2.new(targetW / 2, targetH / 2),
5307
+ Vector2.new(scale, scale),
5126
5308
  0,
5127
- editableImage,
5128
- {CombineType = Enum.ImageCombineType.Overwrite}
5309
+ croppedImage,
5310
+ {
5311
+ CombineType = Enum.ImageCombineType.Overwrite,
5312
+ PivotPoint = Vector2.new(cropW / 2, cropH / 2),
5313
+ }
5129
5314
  )
5130
- editableImage:Destroy()
5131
- editableImage = resizedImage
5315
+ croppedImage:Destroy()
5132
5316
  end
5133
5317
 
5134
- -- Convert to base64 RGBA
5135
- local rgbaBuffer = editableImage:ReadPixelsBuffer(Vector2.new(0, 0), editableImage.Size)
5318
+ local rgbaBuffer = outputImage:ReadPixelsBuffer(Vector2.new(0, 0), outputImage.Size)
5136
5319
  local rgbaString = buffer.tostring(rgbaBuffer)
5137
5320
  local base64Data = base64encode(rgbaString)
5138
- editableImage:Destroy()
5321
+ outputImage:Destroy()
5139
5322
 
5140
5323
  return {
5141
5324
  success = true,
5142
5325
  base64 = base64Data,
5143
- width = width,
5144
- height = height,
5326
+ width = targetW,
5327
+ height = targetH,
5328
+ format = "RGBA",
5329
+ encoding = "base64",
5145
5330
  viewInfo = {
5146
- objectName = targetInstance.Name,
5147
- objectClass = targetInstance.ClassName,
5331
+ objectName = target.Name,
5332
+ objectClass = target.ClassName,
5148
5333
  boundingBox = {
5149
- size = {x = boundingSize.X, y = boundingSize.Y, z = boundingSize.Z},
5150
- center = {x = boundingCenter.X, y = boundingCenter.Y, z = boundingCenter.Z}
5334
+ size = { x = boundSize.X, y = boundSize.Y, z = boundSize.Z },
5335
+ center = { x = boundCenter.X, y = boundCenter.Y, z = boundCenter.Z },
5151
5336
  },
5152
5337
  camera = {
5153
- distance = cameraDistance,
5154
- position = {x = finalCameraPos.X, y = finalCameraPos.Y, z = finalCameraPos.Z}
5338
+ distance = cameraDistance,
5339
+ position = { x = cameraPos.X, y = cameraPos.Y, z = cameraPos.Z },
5340
+ fieldOfView = camera.FieldOfView,
5341
+ sphereRadius = sphereRadius,
5342
+ },
5343
+ capture = {
5344
+ sourceWidth = sourceSize.X,
5345
+ sourceHeight = sourceSize.Y,
5346
+ cropOriginX = cropX,
5347
+ cropOriginY = cropY,
5348
+ cropWidth = cropW,
5349
+ cropHeight = cropH,
5155
5350
  },
5156
5351
  settings = {
5157
- angle = anglePreset,
5158
- lighting = lighting,
5352
+ angle = angleParam,
5353
+ lighting = lighting,
5159
5354
  background = background,
5160
- resolution = {width = width, height = height}
5161
- }
5355
+ resolution = { width = targetW, height = targetH },
5356
+ },
5162
5357
  },
5163
- message = "Rendered " .. targetInstance.Name .. " at " .. width .. "x" .. height
5358
+ message = string.format(
5359
+ "Rendered %s at %dx%d (cropped from %dx%d source)",
5360
+ target.Name, targetW, targetH, sourceSize.X, sourceSize.Y
5361
+ ),
5164
5362
  }
5165
5363
  end)
5166
5364
 
@@ -5169,7 +5367,7 @@ handlers.renderObjectView = function(requestData)
5169
5367
  else
5170
5368
  return {
5171
5369
  success = false,
5172
- error = "Failed to render object: " .. tostring(result)
5370
+ error = "Failed to render object: " .. tostring(result),
5173
5371
  }
5174
5372
  end
5175
5373
  end
@@ -5178,7 +5376,10 @@ end
5178
5376
  -- CAMERA CONTROL SYSTEM
5179
5377
  -- ============================================
5180
5378
 
5181
- -- Focus Studio camera on an object (like pressing F in Studio)
5379
+ -- Focus Studio camera on an object (like pressing F in Studio).
5380
+ -- IMPORTANT: We do NOT lock CameraType. In Studio edit-mode, the Studio camera
5381
+ -- responds to CFrame writes immediately, while keeping mouse/WASD navigation
5382
+ -- intact for the user.
5182
5383
  handlers.focusCamera = function(requestData)
5183
5384
  local success, result = pcall(function()
5184
5385
  local instancePath = requestData.instancePath
@@ -5191,7 +5392,7 @@ handlers.focusCamera = function(requestData)
5191
5392
 
5192
5393
  local anglePreset = requestData.angle or "iso"
5193
5394
  local autoDistance = requestData.autoDistance ~= false
5194
- local customDistance = requestData.distance
5395
+ local customDistance = tonumber(requestData.distance)
5195
5396
 
5196
5397
  -- Get target instance
5197
5398
  local targetInstance = getInstanceByPath(instancePath)
@@ -5202,47 +5403,55 @@ handlers.focusCamera = function(requestData)
5202
5403
  }
5203
5404
  end
5204
5405
 
5205
- -- Calculate bounding box and center (reuse from ViewportFrame code)
5406
+ -- Calculate bounding box and center (handles BasePart, Model with rotated parts)
5206
5407
  local boundingSize, boundingCenter = getModelBoundingBox(targetInstance)
5207
- local maxDimension = math.max(boundingSize.X, boundingSize.Y, boundingSize.Z)
5208
5408
 
5209
- -- Get camera offset from presets
5210
- local cameraOffset
5211
- if type(anglePreset) == "string" and CameraPresets[anglePreset] then
5212
- cameraOffset = CameraPresets[anglePreset]
5213
- elseif type(anglePreset) == "table" then
5214
- -- Custom angle {pitch, yaw, roll, distance}
5215
- local pitch = math.rad(anglePreset.pitch or 0)
5216
- local yaw = math.rad(anglePreset.yaw or 0)
5217
- local roll = math.rad(anglePreset.roll or 0)
5218
- cameraOffset = CFrame.new(0, 0, 10) * CFrame.Angles(pitch, yaw, roll)
5219
- else
5220
- cameraOffset = CameraPresets.iso
5221
- end
5409
+ -- Resolve camera direction (unit vector pointing FROM target TOWARD camera)
5410
+ local direction = resolveCameraDirection(anglePreset)
5222
5411
 
5223
- -- Calculate distance to fit object in view (like F shortcut)
5412
+ -- Calculate distance using bounding sphere + the smaller of vFOV/hFOV.
5413
+ -- This guarantees the object fits regardless of viewport aspect ratio.
5414
+ local camera = workspace.CurrentCamera
5224
5415
  local cameraDistance
5225
5416
  if customDistance then
5226
- cameraDistance = customDistance
5417
+ cameraDistance = math.max(customDistance, 0.1)
5227
5418
  elseif autoDistance then
5228
- -- FOV of 70 degrees, fit object with some padding
5229
- local fov = 70
5230
- cameraDistance = maxDimension / (2 * math.tan(math.rad(fov / 2))) * 1.5
5419
+ local sphereRadius = math.max(boundingSize.Magnitude / 2, 0.5)
5420
+ local viewportSize = camera.ViewportSize
5421
+ local aspect = (viewportSize.X > 0 and viewportSize.Y > 0)
5422
+ and (viewportSize.X / viewportSize.Y) or 1
5423
+ local vFovRad = math.rad(camera.FieldOfView)
5424
+ local hFovRad = 2 * math.atan(math.tan(vFovRad / 2) * aspect)
5425
+ local minFov = math.min(vFovRad, hFovRad)
5426
+ cameraDistance = (sphereRadius / math.sin(minFov / 2)) * 1.3 -- 30% padding
5231
5427
  cameraDistance = math.max(cameraDistance, 5) -- Minimum 5 studs
5232
5428
  else
5233
- cameraDistance = 20 -- Default distance
5429
+ cameraDistance = 20
5234
5430
  end
5235
5431
 
5236
- -- Apply distance to camera offset direction
5237
- local offsetDirection = cameraOffset.Position.Unit
5238
- local finalCameraPos = boundingCenter + (offsetDirection * cameraDistance)
5432
+ -- Build clean camera CFrame using lookAt — no double rotation.
5433
+ local finalCameraPos = boundingCenter + (direction * cameraDistance)
5434
+ local newCFrame = CFrame.lookAt(finalCameraPos, boundingCenter)
5239
5435
 
5240
- -- Set Studio camera (workspace.CurrentCamera)
5241
- local camera = workspace.CurrentCamera
5242
- camera.CameraType = Enum.CameraType.Fixed
5243
- camera.CFrame = CFrame.new(finalCameraPos, boundingCenter) * cameraOffset.Rotation
5436
+ -- Drive Studio camera. We don't change CameraType so Studio's mouse/WASD
5437
+ -- navigation continues to work after this call.
5438
+ camera.CFrame = newCFrame
5244
5439
  camera.Focus = CFrame.new(boundingCenter)
5245
5440
 
5441
+ -- Describe the angle in the response (string preset or pitch/yaw)
5442
+ local angleResponse
5443
+ if type(anglePreset) == "string" then
5444
+ angleResponse = anglePreset
5445
+ elseif type(anglePreset) == "table" then
5446
+ angleResponse = {
5447
+ pitch = tonumber(anglePreset.pitch) or 0,
5448
+ yaw = tonumber(anglePreset.yaw) or 0,
5449
+ roll = tonumber(anglePreset.roll) or 0
5450
+ }
5451
+ else
5452
+ angleResponse = "iso"
5453
+ end
5454
+
5246
5455
  return {
5247
5456
  success = true,
5248
5457
  object = {
@@ -5256,10 +5465,12 @@ handlers.focusCamera = function(requestData)
5256
5465
  camera = {
5257
5466
  position = {x = finalCameraPos.X, y = finalCameraPos.Y, z = finalCameraPos.Z},
5258
5467
  lookAt = {x = boundingCenter.X, y = boundingCenter.Y, z = boundingCenter.Z},
5468
+ direction = {x = direction.X, y = direction.Y, z = direction.Z},
5259
5469
  distance = cameraDistance,
5260
- angle = anglePreset
5470
+ angle = angleResponse
5261
5471
  },
5262
- message = "Focused camera on " .. targetInstance.Name .. " from " .. tostring(anglePreset) .. " angle"
5472
+ message = "Focused camera on " .. targetInstance.Name .. " from " ..
5473
+ (type(anglePreset) == "string" and anglePreset or "custom") .. " angle"
5263
5474
  }
5264
5475
  end)
5265
5476