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