rbxstudio-mcp 2.2.4 → 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.4",
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",
@@ -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,107 @@ 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()
4814
4850
  resizedImage:DrawImageTransformed(
4815
- Vector2.new(0, 0), -- position
4816
- Vector2.new(scale, scale), -- scale
4817
- 0, -- rotation
4851
+ Vector2.new(0, 0),
4852
+ Vector2.new(sx, sy),
4853
+ 0,
4818
4854
  editableImage,
4819
- {
4820
- CombineType = Enum.ImageCombineType.Overwrite
4821
- }
4855
+ { CombineType = Enum.ImageCombineType.Overwrite }
4822
4856
  )
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
4857
  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)
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
+ }
4854
4866
  end
4867
+ else
4868
+ resizedImage = editableImage
4869
+ end
4855
4870
 
4856
- if resizedImage ~= editableImage then
4857
- 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
+ )
4858
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
4859
4902
  end
4860
4903
 
4904
+ if resizedImage ~= editableImage then
4905
+ resizedImage:Destroy()
4906
+ end
4861
4907
  editableImage:Destroy()
4862
4908
 
4863
4909
  return resultData
@@ -4867,26 +4913,44 @@ end
4867
4913
  -- VIEWPORTFRAME RENDERING SYSTEM
4868
4914
  -- ============================================
4869
4915
 
4870
- -- 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.
4871
4919
  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),
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,
4888
4936
  }
4889
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
+
4890
4954
  -- Apply lighting preset to WorldModel
4891
4955
  local function applyLightingPreset(worldModel, preset)
4892
4956
  -- Remove existing lighting
@@ -4935,186 +4999,229 @@ local function applyLightingPreset(worldModel, preset)
4935
4999
  -- default: no lights added (ambient only)
4936
5000
  end
4937
5001
 
4938
- -- Calculate bounding box of a model
4939
- 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)
4940
5007
  local parts = {}
4941
-
4942
- -- Collect all BaseParts
4943
- 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
4944
5012
  if child:IsA("BasePart") then
4945
5013
  table.insert(parts, child)
4946
5014
  end
4947
5015
  end
4948
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
+
4949
5026
  if #parts == 0 then
4950
- -- No parts found, return default
4951
5027
  return Vector3.new(1, 1, 1), Vector3.new(0, 0, 0)
4952
5028
  end
4953
5029
 
4954
- -- Calculate bounding box
4955
- local minPos = parts[1].Position - parts[1].Size/2
4956
- 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
4957
5034
 
4958
- for _, part in ipairs(parts) do
4959
- local partMin = part.Position - part.Size/2
4960
- 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
+ }
4961
5041
 
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
- )
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
4972
5054
  end
4973
5055
 
4974
- local size = maxPos - minPos
4975
- local center = (minPos + maxPos) / 2
4976
-
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)
4977
5058
  return size, center
4978
5059
  end
4979
5060
 
4980
- -- 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
+ ]]
4981
5081
  handlers.renderObjectView = function(requestData)
5082
+ local CaptureService = game:GetService("CaptureService")
5083
+ local AssetService = game:GetService("AssetService")
5084
+ local CoreGui = game:GetService("CoreGui")
5085
+
4982
5086
  local success, result = pcall(function()
5087
+ -- ──────────── parse params ────────────
4983
5088
  local instancePath = requestData.instancePath
4984
5089
  if not instancePath then
4985
- return {
4986
- success = false,
4987
- error = "instancePath is required"
4988
- }
5090
+ return { success = false, error = "instancePath is required" }
4989
5091
  end
4990
5092
 
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)
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)
4999
5096
 
5000
- local anglePreset = requestData.angle or "iso"
5001
- local lighting = requestData.lighting or "bright"
5002
- 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"
5003
5100
  local autoDistance = requestData.autoDistance ~= false
5004
5101
 
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
- }
5102
+ -- ──────────── resolve target ────────────
5103
+ local target = getInstanceByPath(instancePath)
5104
+ if not target then
5105
+ return { success = false, error = "Instance not found: " .. instancePath }
5012
5106
  end
5013
5107
 
5014
- -- 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
+
5015
5116
  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)
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
5019
5140
 
5020
- -- Create WorldModel for viewport
5021
5141
  local worldModel = Instance.new("WorldModel")
5022
5142
  worldModel.Parent = viewportFrame
5023
5143
 
5024
5144
  local camera = Instance.new("Camera")
5145
+ camera.FieldOfView = 70
5025
5146
  camera.Parent = viewportFrame
5026
5147
  viewportFrame.CurrentCamera = camera
5027
5148
 
5028
- -- Clone target into WorldModel
5029
- local clonedInstance = targetInstance:Clone()
5030
- 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
5031
5159
 
5032
- -- Calculate bounding box and center
5033
- local boundingSize, boundingCenter = getModelBoundingBox(clonedInstance)
5034
- 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)
5035
5164
 
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
5165
+ -- Parent the gui NOW so AbsoluteSize is populated for the viewport
5166
+ tempGui.Parent = CoreGui
5167
+ RunService.Heartbeat:Wait()
5050
5168
 
5051
- -- Auto-calculate distance to fit object
5052
- 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
5053
5191
  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
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
5059
5197
  end
5060
5198
 
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
5199
+ local dir = resolveCameraDirection(angleParam)
5200
+ local cameraPos = boundCenter + dir * cameraDistance
5201
+ camera.CFrame = CFrame.lookAt(cameraPos, boundCenter)
5067
5202
 
5068
- -- Apply lighting
5203
+ -- ──────────── lighting + grid ────────────
5069
5204
  applyLightingPreset(worldModel, lighting)
5070
5205
 
5071
- -- Add grid background if requested
5072
5206
  if background == "grid" then
5073
5207
  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)
5208
+ gridPart.Size = Vector3.new(sphereRadius * 8, 0.05, sphereRadius * 8)
5209
+ gridPart.Position = boundCenter - Vector3.new(0, boundSize.Y / 2 + 0.025, 0)
5076
5210
  gridPart.Anchored = true
5077
5211
  gridPart.Material = Enum.Material.SmoothPlastic
5078
- gridPart.Color = Color3.fromRGB(200, 200, 200)
5212
+ gridPart.Color = Color3.fromRGB(180, 180, 180)
5079
5213
  gridPart.Parent = worldModel
5080
5214
  end
5081
5215
 
5082
- -- Wait for viewport to render
5083
- task.wait(0.1)
5216
+ -- Wait for the viewport to draw at its new size
5084
5217
  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
5218
  RunService.Heartbeat:Wait()
5110
- task.wait(0.05) -- Extra wait to ensure viewport renders at new size
5111
5219
 
5112
- -- Capture screenshot of the viewport
5220
+ -- ──────────── capture ────────────
5113
5221
  local captureComplete = Instance.new("BindableEvent")
5114
- local captureResult = nil
5115
- local captureError = nil
5222
+ local captureResult, captureError = nil, nil
5116
5223
 
5117
- pcall(function()
5224
+ local pcOk, pcErr = pcall(function()
5118
5225
  CaptureService:CaptureScreenshot(function(contentId)
5119
5226
  if contentId then
5120
5227
  captureResult = contentId
@@ -5125,78 +5232,133 @@ handlers.renderObjectView = function(requestData)
5125
5232
  end)
5126
5233
  end)
5127
5234
 
5128
- -- Wait with timeout
5129
- 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()
5130
5241
  if not captureResult and not captureError then
5131
- captureError = "Capture timed out"
5242
+ captureError = "Capture timed out (8s)"
5132
5243
  captureComplete:Fire()
5133
5244
  end
5134
5245
  end)
5135
-
5136
5246
  captureComplete.Event:Wait()
5137
5247
  task.cancel(timeoutThread)
5138
5248
  captureComplete:Destroy()
5249
+
5250
+ -- We can destroy the gui now; the contentId is already saved
5139
5251
  tempGui:Destroy()
5140
- viewportFrame:Destroy()
5141
5252
 
5142
5253
  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
- }
5254
+ return { success = false, error = "Failed to capture viewport: " .. captureError }
5148
5255
  end
5149
5256
 
5150
- -- Load captured image
5151
- 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
5152
5262
 
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),
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),
5162
5308
  0,
5163
- editableImage,
5164
- {CombineType = Enum.ImageCombineType.Overwrite}
5309
+ croppedImage,
5310
+ {
5311
+ CombineType = Enum.ImageCombineType.Overwrite,
5312
+ PivotPoint = Vector2.new(cropW / 2, cropH / 2),
5313
+ }
5165
5314
  )
5166
- editableImage:Destroy()
5167
- editableImage = resizedImage
5315
+ croppedImage:Destroy()
5168
5316
  end
5169
5317
 
5170
- -- Convert to base64 RGBA
5171
- local rgbaBuffer = editableImage:ReadPixelsBuffer(Vector2.new(0, 0), editableImage.Size)
5318
+ local rgbaBuffer = outputImage:ReadPixelsBuffer(Vector2.new(0, 0), outputImage.Size)
5172
5319
  local rgbaString = buffer.tostring(rgbaBuffer)
5173
5320
  local base64Data = base64encode(rgbaString)
5174
- editableImage:Destroy()
5321
+ outputImage:Destroy()
5175
5322
 
5176
5323
  return {
5177
5324
  success = true,
5178
5325
  base64 = base64Data,
5179
- width = width,
5180
- height = height,
5326
+ width = targetW,
5327
+ height = targetH,
5328
+ format = "RGBA",
5329
+ encoding = "base64",
5181
5330
  viewInfo = {
5182
- objectName = targetInstance.Name,
5183
- objectClass = targetInstance.ClassName,
5331
+ objectName = target.Name,
5332
+ objectClass = target.ClassName,
5184
5333
  boundingBox = {
5185
- size = {x = boundingSize.X, y = boundingSize.Y, z = boundingSize.Z},
5186
- 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 },
5187
5336
  },
5188
5337
  camera = {
5189
- distance = cameraDistance,
5190
- 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,
5191
5350
  },
5192
5351
  settings = {
5193
- angle = anglePreset,
5194
- lighting = lighting,
5352
+ angle = angleParam,
5353
+ lighting = lighting,
5195
5354
  background = background,
5196
- resolution = {width = width, height = height}
5197
- }
5355
+ resolution = { width = targetW, height = targetH },
5356
+ },
5198
5357
  },
5199
- 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
+ ),
5200
5362
  }
5201
5363
  end)
5202
5364
 
@@ -5205,7 +5367,7 @@ handlers.renderObjectView = function(requestData)
5205
5367
  else
5206
5368
  return {
5207
5369
  success = false,
5208
- error = "Failed to render object: " .. tostring(result)
5370
+ error = "Failed to render object: " .. tostring(result),
5209
5371
  }
5210
5372
  end
5211
5373
  end
@@ -5214,7 +5376,10 @@ end
5214
5376
  -- CAMERA CONTROL SYSTEM
5215
5377
  -- ============================================
5216
5378
 
5217
- -- 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.
5218
5383
  handlers.focusCamera = function(requestData)
5219
5384
  local success, result = pcall(function()
5220
5385
  local instancePath = requestData.instancePath
@@ -5227,7 +5392,7 @@ handlers.focusCamera = function(requestData)
5227
5392
 
5228
5393
  local anglePreset = requestData.angle or "iso"
5229
5394
  local autoDistance = requestData.autoDistance ~= false
5230
- local customDistance = requestData.distance
5395
+ local customDistance = tonumber(requestData.distance)
5231
5396
 
5232
5397
  -- Get target instance
5233
5398
  local targetInstance = getInstanceByPath(instancePath)
@@ -5238,47 +5403,55 @@ handlers.focusCamera = function(requestData)
5238
5403
  }
5239
5404
  end
5240
5405
 
5241
- -- Calculate bounding box and center (reuse from ViewportFrame code)
5406
+ -- Calculate bounding box and center (handles BasePart, Model with rotated parts)
5242
5407
  local boundingSize, boundingCenter = getModelBoundingBox(targetInstance)
5243
- local maxDimension = math.max(boundingSize.X, boundingSize.Y, boundingSize.Z)
5244
5408
 
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
5409
+ -- Resolve camera direction (unit vector pointing FROM target TOWARD camera)
5410
+ local direction = resolveCameraDirection(anglePreset)
5258
5411
 
5259
- -- 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
5260
5415
  local cameraDistance
5261
5416
  if customDistance then
5262
- cameraDistance = customDistance
5417
+ cameraDistance = math.max(customDistance, 0.1)
5263
5418
  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
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
5267
5427
  cameraDistance = math.max(cameraDistance, 5) -- Minimum 5 studs
5268
5428
  else
5269
- cameraDistance = 20 -- Default distance
5429
+ cameraDistance = 20
5270
5430
  end
5271
5431
 
5272
- -- Apply distance to camera offset direction
5273
- local offsetDirection = cameraOffset.Position.Unit
5274
- 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)
5275
5435
 
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
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
5280
5439
  camera.Focus = CFrame.new(boundingCenter)
5281
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
+
5282
5455
  return {
5283
5456
  success = true,
5284
5457
  object = {
@@ -5292,10 +5465,12 @@ handlers.focusCamera = function(requestData)
5292
5465
  camera = {
5293
5466
  position = {x = finalCameraPos.X, y = finalCameraPos.Y, z = finalCameraPos.Z},
5294
5467
  lookAt = {x = boundingCenter.X, y = boundingCenter.Y, z = boundingCenter.Z},
5468
+ direction = {x = direction.X, y = direction.Y, z = direction.Z},
5295
5469
  distance = cameraDistance,
5296
- angle = anglePreset
5470
+ angle = angleResponse
5297
5471
  },
5298
- 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"
5299
5474
  }
5300
5475
  end)
5301
5476