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 +1 -1
- package/studio-plugin/plugin.luau +446 -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,110 @@ 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()
|
|
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(
|
|
4816
|
-
Vector2.new(
|
|
4817
|
-
0,
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
4857
|
-
|
|
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
|
-
--
|
|
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 =
|
|
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
|
|
4939
|
-
|
|
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
|
-
|
|
4943
|
-
|
|
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
|
-
--
|
|
4955
|
-
local
|
|
4956
|
-
local
|
|
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
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
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
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
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
|
|
4975
|
-
local center = (
|
|
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
|
-
--
|
|
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
|
-
|
|
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)
|
|
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
|
|
5001
|
-
local lighting
|
|
5002
|
-
local background
|
|
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
|
-
--
|
|
5006
|
-
local
|
|
5007
|
-
if not
|
|
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
|
-
--
|
|
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.
|
|
5017
|
-
viewportFrame.
|
|
5018
|
-
viewportFrame.
|
|
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
|
|
5029
|
-
local
|
|
5030
|
-
|
|
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
|
-
--
|
|
5033
|
-
local
|
|
5034
|
-
|
|
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
|
-
--
|
|
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
|
|
5168
|
+
-- Parent the gui NOW so AbsoluteSize is populated for the viewport
|
|
5169
|
+
tempGui.Parent = CoreGui
|
|
5170
|
+
RunService.Heartbeat:Wait()
|
|
5050
5171
|
|
|
5051
|
-
|
|
5052
|
-
|
|
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
|
-
|
|
5055
|
-
|
|
5056
|
-
cameraDistance =
|
|
5057
|
-
|
|
5058
|
-
cameraDistance =
|
|
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
|
-
|
|
5062
|
-
local
|
|
5063
|
-
|
|
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
|
-
--
|
|
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(
|
|
5075
|
-
gridPart.Position =
|
|
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(
|
|
5215
|
+
gridPart.Color = Color3.fromRGB(180, 180, 180)
|
|
5079
5216
|
gridPart.Parent = worldModel
|
|
5080
5217
|
end
|
|
5081
5218
|
|
|
5082
|
-
-- Wait for viewport to
|
|
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
|
-
--
|
|
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
|
-
|
|
5129
|
-
|
|
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
|
-
--
|
|
5151
|
-
local
|
|
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
|
-
--
|
|
5154
|
-
local
|
|
5155
|
-
if
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
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
|
-
|
|
5164
|
-
{
|
|
5312
|
+
croppedImage,
|
|
5313
|
+
{
|
|
5314
|
+
CombineType = Enum.ImageCombineType.Overwrite,
|
|
5315
|
+
PivotPoint = Vector2.new(cropW / 2, cropH / 2),
|
|
5316
|
+
}
|
|
5165
5317
|
)
|
|
5166
|
-
|
|
5167
|
-
editableImage = resizedImage
|
|
5318
|
+
croppedImage:Destroy()
|
|
5168
5319
|
end
|
|
5169
5320
|
|
|
5170
|
-
|
|
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
|
-
|
|
5324
|
+
outputImage:Destroy()
|
|
5175
5325
|
|
|
5176
5326
|
return {
|
|
5177
5327
|
success = true,
|
|
5178
5328
|
base64 = base64Data,
|
|
5179
|
-
width =
|
|
5180
|
-
height =
|
|
5329
|
+
width = targetW,
|
|
5330
|
+
height = targetH,
|
|
5331
|
+
format = "RGBA",
|
|
5332
|
+
encoding = "base64",
|
|
5181
5333
|
viewInfo = {
|
|
5182
|
-
objectName
|
|
5183
|
-
objectClass =
|
|
5334
|
+
objectName = target.Name,
|
|
5335
|
+
objectClass = target.ClassName,
|
|
5184
5336
|
boundingBox = {
|
|
5185
|
-
size
|
|
5186
|
-
center = {x =
|
|
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
|
|
5190
|
-
position
|
|
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
|
|
5194
|
-
lighting
|
|
5355
|
+
angle = angleParam,
|
|
5356
|
+
lighting = lighting,
|
|
5195
5357
|
background = background,
|
|
5196
|
-
resolution = {width =
|
|
5197
|
-
}
|
|
5358
|
+
resolution = { width = targetW, height = targetH },
|
|
5359
|
+
},
|
|
5198
5360
|
},
|
|
5199
|
-
message =
|
|
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 (
|
|
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
|
-
--
|
|
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
|
|
5412
|
+
-- Resolve camera direction (unit vector pointing FROM target TOWARD camera)
|
|
5413
|
+
local direction = resolveCameraDirection(anglePreset)
|
|
5258
5414
|
|
|
5259
|
-
-- Calculate distance
|
|
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
|
-
|
|
5265
|
-
local
|
|
5266
|
-
|
|
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
|
|
5432
|
+
cameraDistance = 20
|
|
5270
5433
|
end
|
|
5271
5434
|
|
|
5272
|
-
--
|
|
5273
|
-
local
|
|
5274
|
-
local
|
|
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
|
-
--
|
|
5277
|
-
|
|
5278
|
-
camera.
|
|
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 =
|
|
5473
|
+
angle = angleResponse
|
|
5297
5474
|
},
|
|
5298
|
-
message = "Focused camera on " .. targetInstance.Name .. " from " ..
|
|
5475
|
+
message = "Focused camera on " .. targetInstance.Name .. " from " ..
|
|
5476
|
+
(type(anglePreset) == "string" and anglePreset or "custom") .. " angle"
|
|
5299
5477
|
}
|
|
5300
5478
|
end)
|
|
5301
5479
|
|