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 +1 -1
- package/studio-plugin/plugin.luau +443 -268
package/package.json
CHANGED
|
@@ -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
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
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),
|
|
4816
|
-
Vector2.new(
|
|
4817
|
-
0,
|
|
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
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
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
|
-
|
|
4857
|
-
|
|
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
|
-
--
|
|
4873
|
-
front
|
|
4874
|
-
back
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
top
|
|
4878
|
-
bottom
|
|
4879
|
-
|
|
4880
|
-
-- Isometric views
|
|
4881
|
-
iso
|
|
4882
|
-
iso_front
|
|
4883
|
-
iso_back
|
|
4884
|
-
|
|
4885
|
-
-- Dramatic
|
|
4886
|
-
low_angle
|
|
4887
|
-
high_angle =
|
|
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
|
|
4939
|
-
|
|
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
|
-
|
|
4943
|
-
|
|
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
|
-
--
|
|
4955
|
-
local
|
|
4956
|
-
local
|
|
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
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
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
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
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
|
|
4975
|
-
local center = (
|
|
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
|
-
--
|
|
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
|
-
|
|
4992
|
-
local
|
|
4993
|
-
local
|
|
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
|
|
5001
|
-
local lighting
|
|
5002
|
-
local background
|
|
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
|
-
--
|
|
5006
|
-
local
|
|
5007
|
-
if not
|
|
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
|
-
--
|
|
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.
|
|
5017
|
-
viewportFrame.
|
|
5018
|
-
viewportFrame.
|
|
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
|
|
5029
|
-
local
|
|
5030
|
-
|
|
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
|
-
--
|
|
5033
|
-
local
|
|
5034
|
-
|
|
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
|
-
--
|
|
5037
|
-
|
|
5038
|
-
|
|
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
|
-
|
|
5052
|
-
|
|
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
|
-
|
|
5055
|
-
|
|
5056
|
-
cameraDistance =
|
|
5057
|
-
|
|
5058
|
-
cameraDistance =
|
|
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
|
-
|
|
5062
|
-
local
|
|
5063
|
-
|
|
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
|
-
--
|
|
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(
|
|
5075
|
-
gridPart.Position =
|
|
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(
|
|
5212
|
+
gridPart.Color = Color3.fromRGB(180, 180, 180)
|
|
5079
5213
|
gridPart.Parent = worldModel
|
|
5080
5214
|
end
|
|
5081
5215
|
|
|
5082
|
-
-- Wait for viewport to
|
|
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
|
-
--
|
|
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
|
-
|
|
5129
|
-
|
|
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
|
-
--
|
|
5151
|
-
local
|
|
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
|
-
--
|
|
5154
|
-
local
|
|
5155
|
-
if
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
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
|
-
|
|
5164
|
-
{
|
|
5309
|
+
croppedImage,
|
|
5310
|
+
{
|
|
5311
|
+
CombineType = Enum.ImageCombineType.Overwrite,
|
|
5312
|
+
PivotPoint = Vector2.new(cropW / 2, cropH / 2),
|
|
5313
|
+
}
|
|
5165
5314
|
)
|
|
5166
|
-
|
|
5167
|
-
editableImage = resizedImage
|
|
5315
|
+
croppedImage:Destroy()
|
|
5168
5316
|
end
|
|
5169
5317
|
|
|
5170
|
-
|
|
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
|
-
|
|
5321
|
+
outputImage:Destroy()
|
|
5175
5322
|
|
|
5176
5323
|
return {
|
|
5177
5324
|
success = true,
|
|
5178
5325
|
base64 = base64Data,
|
|
5179
|
-
width =
|
|
5180
|
-
height =
|
|
5326
|
+
width = targetW,
|
|
5327
|
+
height = targetH,
|
|
5328
|
+
format = "RGBA",
|
|
5329
|
+
encoding = "base64",
|
|
5181
5330
|
viewInfo = {
|
|
5182
|
-
objectName
|
|
5183
|
-
objectClass =
|
|
5331
|
+
objectName = target.Name,
|
|
5332
|
+
objectClass = target.ClassName,
|
|
5184
5333
|
boundingBox = {
|
|
5185
|
-
size
|
|
5186
|
-
center = {x =
|
|
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
|
|
5190
|
-
position
|
|
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
|
|
5194
|
-
lighting
|
|
5352
|
+
angle = angleParam,
|
|
5353
|
+
lighting = lighting,
|
|
5195
5354
|
background = background,
|
|
5196
|
-
resolution = {width =
|
|
5197
|
-
}
|
|
5355
|
+
resolution = { width = targetW, height = targetH },
|
|
5356
|
+
},
|
|
5198
5357
|
},
|
|
5199
|
-
message =
|
|
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 (
|
|
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
|
-
--
|
|
5246
|
-
local
|
|
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
|
|
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
|
-
|
|
5265
|
-
local
|
|
5266
|
-
|
|
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
|
|
5429
|
+
cameraDistance = 20
|
|
5270
5430
|
end
|
|
5271
5431
|
|
|
5272
|
-
--
|
|
5273
|
-
local
|
|
5274
|
-
local
|
|
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
|
-
--
|
|
5277
|
-
|
|
5278
|
-
camera.
|
|
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 =
|
|
5470
|
+
angle = angleResponse
|
|
5297
5471
|
},
|
|
5298
|
-
message = "Focused camera on " .. targetInstance.Name .. " from " ..
|
|
5472
|
+
message = "Focused camera on " .. targetInstance.Name .. " from " ..
|
|
5473
|
+
(type(anglePreset) == "string" and anglePreset or "custom") .. " angle"
|
|
5299
5474
|
}
|
|
5300
5475
|
end)
|
|
5301
5476
|
|