rbxstudio-mcp 2.2.4 → 2.3.1

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