├── src ├── init.lua ├── Flags │ ├── getEngineFeatureModelPivotVisual.lua │ ├── getFFlagSummonPivot.lua │ ├── getFFlagBoxSelectNoPivot.lua │ ├── getFFlagLimitScaling.lua │ ├── getFFlagPreserveMotor6D.lua │ ├── getFFlagDraggerFrameworkFixes.lua │ ├── getFFlagFlippedScopeSelect.lua │ ├── getFFlagMoreLuaDraggerFixes.lua │ ├── getFFlagMultiSelectionPivot.lua │ ├── getFFlagOnlyGetGeometryOnce.lua │ ├── getFFlagUseGetBoundingBox.lua │ ├── getFFlagIgnoreSpuriousViewChange.lua │ ├── getFFlagTemporaryPatchDraggerEvents.lua │ ├── getFFlagFixDraggerMovingInWrongDirection.lua │ └── getFFlagFixScalingToolBoundingBoxForLargeModels.lua ├── Utility │ ├── shouldDragAsFace.lua │ ├── setInsertPoint.lua │ ├── MockAnalytics.lua │ ├── Colors.lua │ ├── getBoundingBoxScale.lua │ ├── TemporaryTransparency.lua │ ├── roundRotation.lua │ ├── isProtectedInstance.lua │ ├── getFaceInstance.lua │ ├── classifyPivot.lua │ ├── StandardCursor.lua │ ├── AttachmentMover.lua │ ├── JointUtil.lua │ ├── Analytics.lua │ ├── ViewChangeDetector.lua │ ├── computeDraggedDistance.lua │ ├── assertGoodCFrame.lua │ ├── SelectionWrapper.lua │ ├── snapRotationToPrimaryDirection.lua │ ├── SelectionHelper.lua │ ├── Math.lua │ ├── DragSelector.lua │ ├── BoundingBox.lua │ ├── JointMaker.lua │ ├── Signal.lua │ ├── getGeometry.lua │ └── JointPairs.lua ├── Resources │ ├── TranslationDevelopmentTable.csv │ └── TranslationReferenceTable.csv ├── Implementation │ ├── DraggerStateType.lua │ ├── DraggerStates │ │ ├── PendingSelectNext.lua │ │ ├── PendingDraggingParts.lua │ │ ├── DraggingFaceInstance.lua │ │ ├── DragSelecting.lua │ │ ├── DraggingParts.lua │ │ ├── DraggingHandle.lua │ │ └── Ready.lua │ ├── HoverTracker.lua │ ├── DraggerContext_FixtureImpl.lua │ └── DraggerContext_PluginImpl.lua ├── Components │ ├── DraggedPivot.lua │ ├── SelectionDot.lua │ ├── SummonHandlesHider.lua │ ├── DragSelectionView.lua │ ├── StandaloneSelectionBox.lua │ ├── SummonHandlesNote.lua │ ├── AnimatedHoverBox.lua │ ├── LocalSpaceIndicator.lua │ ├── ScaleHandleView.lua │ ├── MoveHandleView.lua │ └── RotateHandleView.lua ├── DraggerTools │ ├── DraggerToolFixture.lua │ └── DraggerToolComponent.lua └── Handles │ └── RotateHandles.lua ├── default.project.json ├── wally.lock ├── wally.toml └── README.md /src/init.lua: -------------------------------------------------------------------------------- 1 | return script -------------------------------------------------------------------------------- /src/Flags/getEngineFeatureModelPivotVisual.lua: -------------------------------------------------------------------------------- 1 | return function() 2 | return true 3 | end 4 | -------------------------------------------------------------------------------- /src/Flags/getFFlagSummonPivot.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagBoxSelectNoPivot.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagLimitScaling.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagPreserveMotor6D.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draggerframework", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } -------------------------------------------------------------------------------- /src/Flags/getFFlagDraggerFrameworkFixes.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagFlippedScopeSelect.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagMoreLuaDraggerFixes.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagMultiSelectionPivot.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagOnlyGetGeometryOnce.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagUseGetBoundingBox.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagIgnoreSpuriousViewChange.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagTemporaryPatchDraggerEvents.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagFixDraggerMovingInWrongDirection.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Flags/getFFlagFixScalingToolBoundingBoxForLargeModels.lua: -------------------------------------------------------------------------------- 1 | 2 | 3 | return function() 4 | return true 5 | end 6 | -------------------------------------------------------------------------------- /src/Utility/shouldDragAsFace.lua: -------------------------------------------------------------------------------- 1 | return function(instance) 2 | return instance:IsA("FaceInstance") or instance:IsA("VideoFrame") or instance:IsA("SurfaceGui") 3 | end -------------------------------------------------------------------------------- /src/Resources/TranslationDevelopmentTable.csv: -------------------------------------------------------------------------------- 1 | Key,Context,Example,Source,en-us 2 | Studio.DraggerFramework.SummonPivot.SummonText,,,,Summon Handles 3 | Studio.DraggerFramework.SummonPivot.TabText,,,,Hold Tab -------------------------------------------------------------------------------- /wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "stravant/draggerframework" 7 | version = "0.1.0" 8 | dependencies = [] 9 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "stravant/draggerframework" 3 | description = "Copy of DraggerFramework from Roblox Studio" 4 | version = "0.1.4" 5 | license = "None" 6 | registry = "https://github.com/UpliftGames/wally-index" 7 | realm = "shared" 8 | 9 | [dependencies] 10 | Roact = "roblox/roact@1.4.4" 11 | -------------------------------------------------------------------------------- /src/Utility/setInsertPoint.lua: -------------------------------------------------------------------------------- 1 | -- Wrapper around WorldRoot::SetInsertPoint, since that function is Roblox only, 2 | -- but we want the DraggerFramework to be forkable. 3 | 4 | local Workspace = game:GetService("Workspace") 5 | 6 | return function(insertPoint) 7 | pcall(function() 8 | Workspace:SetInsertPoint(insertPoint, true) 9 | end) 10 | end -------------------------------------------------------------------------------- /src/Utility/MockAnalytics.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | No-op Analytics replacement which the unit tests direct logging to. 3 | ]] 4 | 5 | local Analytics = {} 6 | 7 | function Analytics:sendEvent(eventName, argMap) 8 | end 9 | 10 | function Analytics:reportCounter(counterName, count) 11 | end 12 | 13 | function Analytics:reportStats(statName, value) 14 | end 15 | 16 | return Analytics -------------------------------------------------------------------------------- /src/Resources/TranslationReferenceTable.csv: -------------------------------------------------------------------------------- 1 | Key,Context,Example,Source,en-us,es-es,ja-jp,ko-kr,pt-br,zh-cjv,zh-cn,zh-tw 2 | Studio.DraggerFramework.SummonPivot.TabText,,,Hold Tab,Hold Tab,Mantener Pestaña,タブの長押し,탭 길게 누르기,Segurar aba,按住 Tab,按住 Tab,儲藏標籤 3 | Studio.DraggerFramework.SummonPivot.SummonText,,,Summon Handles,Summon Handles,Invocar controladores,ハンドル召喚,핸들 소환,Invocar alças,召唤拉杆,召唤拉杆,召喚手把 -------------------------------------------------------------------------------- /src/Utility/Colors.lua: -------------------------------------------------------------------------------- 1 | local Colors = {} 2 | 3 | Colors.WHITE = Color3.new(1, 1, 1) 4 | Colors.BLACK = Color3.new(0, 0, 0) 5 | Colors.GRAY = Color3.new(0.7, 0.7, 0.7) 6 | 7 | Colors.X_AXIS = Color3.new(1, 0, 0) 8 | Colors.Y_AXIS = Color3.new(0, 1, 0) 9 | Colors.Z_AXIS = Color3.new(0, 0, 1) 10 | 11 | Colors.WeldJoint = Color3.new(1, 1, 1) 12 | Colors.RotatingJoint = Color3.new(0, 0, 1) 13 | Colors.InvalidJoint = Color3.new(1, 0, 0) 14 | 15 | Colors.SizeLimitReached = Color3.new(1, 1, 0) 16 | 17 | function Colors.makeDimmed(color) 18 | return color:Lerp(Colors.BLACK, 0.3) 19 | end 20 | 21 | return Colors 22 | -------------------------------------------------------------------------------- /src/Implementation/DraggerStateType.lua: -------------------------------------------------------------------------------- 1 | 2 | local StateType = {} 3 | setmetatable(StateType, { 4 | __index = function(self, index) 5 | error("Attempt to get invalid StateType `"..tostring(index).."`") 6 | end, 7 | }) 8 | 9 | StateType.Ready = "Ready" 10 | -- Clicked a part, but haven't started dragging: 11 | StateType.PendingDraggingParts = "PendingDraggingParts" 12 | -- No-op clicked the selection, when releasing the mouse, try to select next: 13 | StateType.PendingSelectNext = "PendingSelectNext" 14 | StateType.DraggingHandle = "DraggingHandle" 15 | StateType.DraggingParts = "DraggingParts" 16 | StateType.DragSelecting = "DragSelecting" 17 | StateType.DraggingFaceInstance = "DraggingFaceInstance" 18 | 19 | return StateType -------------------------------------------------------------------------------- /src/Utility/getBoundingBoxScale.lua: -------------------------------------------------------------------------------- 1 | local BoundingBoxCorners = { 2 | Vector3.new(0.5, 0.5, 0.5), 3 | Vector3.new(-0.5, 0.5, 0.5), 4 | Vector3.new(0.5, -0.5, 0.5), 5 | Vector3.new(-0.5, -0.5, 0.5), 6 | Vector3.new(0.5, 0.5, -0.5), 7 | Vector3.new(-0.5, 0.5, -0.5), 8 | Vector3.new(0.5, -0.5, -0.5), 9 | Vector3.new(-0.5, -0.5, -0.5), 10 | } 11 | 12 | local function getBoundingBoxScale(draggerContext, cframe, size) 13 | local minScale = math.huge 14 | for _, relativeCorner in ipairs(BoundingBoxCorners) do 15 | local globalCorner = cframe:PointToWorldSpace(size * relativeCorner) 16 | minScale = math.min(minScale, draggerContext:getHandleScale(globalCorner)) 17 | end 18 | return minScale 19 | end 20 | 21 | return getBoundingBoxScale 22 | -------------------------------------------------------------------------------- /src/Components/DraggedPivot.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | 3 | local DraggerFramework = script.Parent.Parent 4 | local Plugin = DraggerFramework.Parent.Parent 5 | local Roact = require(Plugin.Packages.Roact) 6 | 7 | local MAIN_SPHERE_RADIUS = 0.4 8 | local MAIN_SPHERE_TRANSPARENCY = 0.5 9 | 10 | return function(props) 11 | local handleScale = props.DraggerContext:getHandleScale(props.CFrame.Position) 12 | return Roact.createElement("SphereHandleAdornment", { 13 | Adornee = Workspace.Terrain, 14 | CFrame = props.CFrame, 15 | Radius = handleScale * MAIN_SPHERE_RADIUS, 16 | ZIndex = 0, 17 | AlwaysOnTop = false, 18 | Transparency = MAIN_SPHERE_TRANSPARENCY, 19 | Color3 = props.DraggerContext:getSelectionBoxColor(props.IsActive), 20 | }) 21 | end -------------------------------------------------------------------------------- /src/Utility/TemporaryTransparency.lua: -------------------------------------------------------------------------------- 1 | local NO_COLLISIONS_TRANSPARENCY = 0.4 2 | 3 | local TemporaryTransparency = {} 4 | TemporaryTransparency.__index = TemporaryTransparency 5 | 6 | function TemporaryTransparency.new(parts) 7 | local self = setmetatable({ 8 | _draggingModifiedParts = {}, 9 | }, TemporaryTransparency) 10 | 11 | for _, part in ipairs(parts) do 12 | if part:IsA("BasePart") then 13 | part.LocalTransparencyModifier = NO_COLLISIONS_TRANSPARENCY 14 | table.insert(self._draggingModifiedParts, part) 15 | end 16 | end 17 | 18 | return self 19 | end 20 | 21 | function TemporaryTransparency:destroy() 22 | for _, part in ipairs(self._draggingModifiedParts) do 23 | part.LocalTransparencyModifier = 0 24 | end 25 | end 26 | 27 | return TemporaryTransparency -------------------------------------------------------------------------------- /src/Utility/roundRotation.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | roundRotation(CFrame) -> CFrame 3 | 4 | Round a rotation CFrame which is approximately primary axis aligned to be 5 | exactly primary axis aligned instead (such that all of its components are 6 | -1, 0, or 1). 7 | 8 | Will not work on an arbitrary CFrame! This function is intended to be used 9 | on CFrames which within a small amount of floating point error of already 10 | being aligned. 11 | ]] 12 | return function(cframe) 13 | local x, y, z, 14 | r0, r1, r2, 15 | r3, r4, r5, 16 | r6, r7, r8 = cframe:components() 17 | assert(x == 0 and y == 0 and z == 0) 18 | return CFrame.new(0, 0, 0, 19 | math.floor(r0 + 0.5), math.floor(r1 + 0.5), math.floor(r2 + 0.5), 20 | math.floor(r3 + 0.5), math.floor(r4 + 0.5), math.floor(r5 + 0.5), 21 | math.floor(r6 + 0.5), math.floor(r7 + 0.5), math.floor(r8 + 0.5)) 22 | end -------------------------------------------------------------------------------- /src/Utility/isProtectedInstance.lua: -------------------------------------------------------------------------------- 1 | 2 | --[[ 3 | isProtectedInstance( instance ): Return true for instances which are 4 | "protected" by Roblox. Accessing any method or property on these instances 5 | will raise an error. 6 | 7 | Internally use a weak hash map to cache the protection status of the 8 | instances, as determining the protection status is somewhat expensive. 9 | ]] 10 | 11 | local WeakIsProtectedCache = setmetatable({}, {__mode = "k"}) 12 | 13 | local function emptyErrorHandler() 14 | end 15 | local function safetyCheckerFunction(instance) 16 | return instance.Name 17 | end 18 | 19 | local function isProtectedInstance(instance) 20 | local isProtected = WeakIsProtectedCache[instance] 21 | if isProtected == nil then 22 | -- Use xpcall even though we don't need the error handler because 23 | -- xpcall is much faster than pcall on Roblox. 24 | isProtected = not xpcall(safetyCheckerFunction, emptyErrorHandler, instance) 25 | WeakIsProtectedCache[instance] = isProtected 26 | end 27 | return isProtected 28 | end 29 | 30 | return isProtectedInstance -------------------------------------------------------------------------------- /src/Utility/getFaceInstance.lua: -------------------------------------------------------------------------------- 1 | 2 | --[[ 3 | Get the FaceInstance (Decal or Texture) on a given part closest to a given 4 | position. 5 | ]] 6 | 7 | local function getNormalId(normalizedPosition) 8 | local x = math.abs(normalizedPosition.X) 9 | local y = math.abs(normalizedPosition.Y) 10 | local z = math.abs(normalizedPosition.Z) 11 | if x > y and x > z then 12 | return normalizedPosition.X > 0 and Enum.NormalId.Right or Enum.NormalId.Left 13 | elseif y > z then 14 | return normalizedPosition.Y > 0 and Enum.NormalId.Top or Enum.NormalId.Bottom 15 | else 16 | return normalizedPosition.Z > 0 and Enum.NormalId.Back or Enum.NormalId.Front 17 | end 18 | end 19 | 20 | local function getFaceInstance(part, position) 21 | local localPosition = part.CFrame:PointToObjectSpace(position) 22 | local normalizedPosition = localPosition / part.Size * 2 23 | local face = getNormalId(normalizedPosition) 24 | 25 | for _, child in pairs(part:GetChildren()) do 26 | if child:IsA("FaceInstance") and child.Face == face then 27 | return child 28 | end 29 | end 30 | return nil 31 | end 32 | 33 | return getFaceInstance -------------------------------------------------------------------------------- /src/Utility/classifyPivot.lua: -------------------------------------------------------------------------------- 1 | local ZERO_VECTOR = Vector3.new() 2 | local PIVOT_NEAR_EDGE_THRESHOLD = 0.01 3 | 4 | return function(cframe, offset, size) 5 | if offset:FuzzyEq(ZERO_VECTOR, PIVOT_NEAR_EDGE_THRESHOLD) then 6 | return "Center" 7 | end 8 | 9 | local absOffset = Vector3.new(math.abs(offset.X), math.abs(offset.Y), math.abs(offset.Z)) 10 | local halfSize = size / 2 11 | local howFarOutside = absOffset - halfSize 12 | 13 | local isInside = 14 | howFarOutside.X < PIVOT_NEAR_EDGE_THRESHOLD and 15 | howFarOutside.Y < PIVOT_NEAR_EDGE_THRESHOLD and 16 | howFarOutside.Z < PIVOT_NEAR_EDGE_THRESHOLD 17 | 18 | if isInside then 19 | if math.abs(howFarOutside.X) < PIVOT_NEAR_EDGE_THRESHOLD or 20 | math.abs(howFarOutside.Y) < PIVOT_NEAR_EDGE_THRESHOLD or 21 | math.abs(howFarOutside.Z) < PIVOT_NEAR_EDGE_THRESHOLD then 22 | return "Surface" 23 | else 24 | return "Inside" 25 | end 26 | else 27 | local fractionOutVector = howFarOutside / size 28 | local fractionOut = math.max(fractionOutVector.X, fractionOutVector.Y, fractionOutVector.Z) 29 | if fractionOut > 1 then 30 | return "Far" 31 | else 32 | return "Outside" 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /src/Utility/StandardCursor.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file is a workaround for STUDIOCORE-22344 3 | 4 | We would like to just use the system cursors for the draggers, however, due to 5 | the above unresolved issue, the system cursors do not work in Play Solo mode. 6 | 7 | To work around this, use the old set of dragger cursors instead while in Play 8 | Solo mode. 9 | ]] 10 | 11 | local RunService = game:GetService("RunService") 12 | 13 | local StandardCursor = {} 14 | 15 | local function isPlaySolo() 16 | return RunService:IsRunning() and not RunService:IsRunMode() 17 | end 18 | 19 | function StandardCursor.getArrow() 20 | if isPlaySolo() then 21 | return "rbxasset://textures/advCursor-default.png" 22 | else 23 | return "rbxasset://SystemCursors/Arrow" 24 | end 25 | end 26 | 27 | function StandardCursor.getOpenHand() 28 | if isPlaySolo() then 29 | return "rbxasset://textures/advCursor-openedHand.png" 30 | else 31 | return "rbxasset://SystemCursors/OpenHand" 32 | end 33 | end 34 | 35 | function StandardCursor.getClosedHand() 36 | if isPlaySolo() then 37 | return "rbxasset://textures/advClosed-hand.png" 38 | else 39 | return "rbxasset://SystemCursors/ClosedHand" 40 | end 41 | end 42 | 43 | return StandardCursor -------------------------------------------------------------------------------- /src/Components/SelectionDot.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Component that displays a dot of fixed size at the given position. 3 | Intended to be used to show the center of the current selection. 4 | ]] 5 | local Workspace = game:GetService("Workspace") 6 | 7 | local DraggerFramework = script.Parent.Parent 8 | local Packages = DraggerFramework.Parent 9 | local Roact = require(Packages.Roact) 10 | 11 | local Colors = require(DraggerFramework.Utility.Colors) 12 | 13 | local SelectionDot = Roact.Component:extend("SelectionDot") 14 | 15 | SelectionDot.defaultProps = { 16 | BackgroundColor3 = Colors.WHITE, 17 | BorderColor3 = Colors.BLACK, 18 | Position = Vector3.new(), 19 | Size = 3, 20 | } 21 | 22 | function SelectionDot:render() 23 | local screenPosition, onScreen = Workspace.CurrentCamera:WorldToScreenPoint(self.props.Position) 24 | if not onScreen then 25 | return nil 26 | end 27 | 28 | local size = self.props.Size 29 | 30 | return Roact.createElement("ScreenGui", {}, { 31 | Roact.createElement("Frame", { 32 | BackgroundColor3 = self.props.BackgroundColor3, 33 | BorderColor3 = self.props.BorderColor3, 34 | BorderSizePixel = 1, 35 | Position = UDim2.new(0, screenPosition.X, 0, screenPosition.Y), 36 | Selectable = false, 37 | Size = UDim2.new(0, size, 0, size), 38 | }) 39 | }) 40 | end 41 | 42 | return SelectionDot 43 | -------------------------------------------------------------------------------- /src/Utility/AttachmentMover.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local DraggerFramework = script.Parent.Parent 4 | 5 | local AttachmentMover = {} 6 | AttachmentMover.__index = AttachmentMover 7 | 8 | function AttachmentMover.new() 9 | return setmetatable({}, AttachmentMover) 10 | end 11 | 12 | function AttachmentMover:setDragged(attachments) 13 | self._originalWorldCFrames = {} 14 | local isRunning = RunService:IsRunning() 15 | self._partsToUnanchor = {} 16 | for _, attachment in ipairs(attachments) do 17 | self._originalWorldCFrames[attachment] = attachment.WorldCFrame 18 | if isRunning then 19 | local part = attachment:FindFirstAncestorWhichIsA("BasePart") 20 | if part and not part:IsGrounded() then 21 | self._partsToUnanchor[part] = true 22 | end 23 | end 24 | end 25 | for part, _ in pairs(self._partsToUnanchor) do 26 | part.Anchored = true 27 | end 28 | end 29 | 30 | function AttachmentMover:transformTo(transform) 31 | for attachment, originalWorldCFrame in pairs(self._originalWorldCFrames) do 32 | attachment.WorldCFrame = transform * originalWorldCFrame 33 | end 34 | end 35 | 36 | function AttachmentMover:commit() 37 | self._originalWorldCFrames = nil 38 | for part, _ in pairs(self._partsToUnanchor) do 39 | part.Anchored = false 40 | end 41 | self._partsToUnanchor = nil 42 | end 43 | 44 | return AttachmentMover -------------------------------------------------------------------------------- /src/Utility/JointUtil.lua: -------------------------------------------------------------------------------- 1 | local JointUtil = {} 2 | 3 | function JointUtil.getConstraintCounterpart(constraint, part) 4 | -- Ugly micro-optimized code because this function gets hit in hot paths 5 | -- The micro-optimized version saves 3-4ms on some actions over the 6 | -- unoptimized variant. 7 | local attachment0 = constraint.Attachment0 8 | if attachment0 then 9 | local attachment0Parent = attachment0.Parent 10 | if attachment0Parent == part then 11 | local attachment1 = constraint.Attachment1 12 | return attachment1 and attachment1.Parent 13 | else 14 | return attachment0Parent 15 | end 16 | else 17 | local attachment1 = constraint.Attachment1 18 | return attachment1 and attachment1.Parent 19 | end 20 | end 21 | 22 | function JointUtil.getJointInstanceCounterpart(joint, part) 23 | local part0 = joint.Part0 24 | if part0 == part then 25 | return joint.Part1 26 | else 27 | return part0 28 | end 29 | end 30 | 31 | function JointUtil.getWeldConstraintCounterpart(weldConstraint, part) 32 | local part0 = weldConstraint.Part0 33 | if part0 == part then 34 | return weldConstraint.Part1 35 | else 36 | return part0 37 | end 38 | end 39 | 40 | function JointUtil.getNoCollisionConstraintCounterpart(noCollisionConstraint, part) 41 | local part0 = noCollisionConstraint.Part0 42 | if part0 == part then 43 | return noCollisionConstraint.Part1 44 | else 45 | return part0 46 | end 47 | end 48 | 49 | return JointUtil -------------------------------------------------------------------------------- /src/Utility/Analytics.lua: -------------------------------------------------------------------------------- 1 | 2 | local RbxAnalyticsService = game:GetService("RbxAnalyticsService") 3 | local StudioService = game:GetService("StudioService") 4 | 5 | local DraggerFramework = script.Parent.Parent 6 | 7 | local Analytics = {} 8 | 9 | -- If this is a fork of the dragger code, mock out the RbxAnalyticsService 10 | if not pcall(function() local _ = RbxAnalyticsService.Name end) then 11 | RbxAnalyticsService = {} 12 | function RbxAnalyticsService:SendEventDeferred() end 13 | function RbxAnalyticsService:ReportCounter() end 14 | function RbxAnalyticsService:GetSessionId() end 15 | function RbxAnalyticsService:GetClientId() end 16 | function RbxAnalyticsService:ReportStats() end 17 | end 18 | 19 | -- If this is a fork of the dragger code being used ingame, mock out StudioService 20 | if not StudioService then 21 | StudioService = {} 22 | function StudioService:GetUserId() end 23 | end 24 | 25 | function Analytics:sendEvent(eventName, argMap) 26 | local totalArgMap = { 27 | studioSid = RbxAnalyticsService:GetSessionId(), 28 | clientId = RbxAnalyticsService:GetClientId(), 29 | placeId = game.PlaceId, 30 | userId = StudioService:GetUserId(), 31 | } 32 | for k, v in pairs(argMap) do 33 | totalArgMap[k] = v 34 | end 35 | RbxAnalyticsService:SendEventDeferred("studio", "Modeling", eventName, totalArgMap) 36 | end 37 | 38 | function Analytics:reportCounter(counterName, count) 39 | RbxAnalyticsService:ReportCounter(counterName, count or 1) 40 | end 41 | 42 | function Analytics:reportStats(statName, value) 43 | RbxAnalyticsService:ReportStats(statName, value) 44 | end 45 | 46 | return Analytics -------------------------------------------------------------------------------- /src/Components/SummonHandlesHider.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent 2 | 3 | local Packages = DraggerFramework.Parent 4 | local Roact = require(Packages.Roact) 5 | 6 | local SummonHandlesHider = Roact.PureComponent:extend("SummonHandlesHider") 7 | 8 | -- When the user has summoned the handles for at duration, the hint that they 9 | -- can be summoned will permanently become hidden. We do it this way so that if 10 | -- you just slightly tap the tab key you won't lose the hint, you actually have 11 | -- to hold it down. 12 | local sTotalSummonHintTime = 2 13 | 14 | -- Was the hint hidden in a previous session? 15 | local SETTING_NAME = "CoreDraggersSummonHintHidden" 16 | local sWasPreviouslyHidden = nil 17 | 18 | function SummonHandlesHider:didMount() 19 | self._startTime = os.clock() 20 | end 21 | 22 | function SummonHandlesHider:willUnmount() 23 | local duration = os.clock() - self._startTime 24 | sTotalSummonHintTime -= duration 25 | if sWasPreviouslyHidden == nil then 26 | sWasPreviouslyHidden = self.props.DraggerContext:getSetting(SETTING_NAME) 27 | end 28 | if sTotalSummonHintTime <= 0 and not sWasPreviouslyHidden then 29 | self.props.DraggerContext:setSetting(SETTING_NAME, true) 30 | sWasPreviouslyHidden = true 31 | end 32 | end 33 | 34 | function SummonHandlesHider:render() 35 | return nil 36 | end 37 | 38 | function SummonHandlesHider.hasSeenEnough(draggerContext) 39 | if sWasPreviouslyHidden == nil then 40 | sWasPreviouslyHidden = draggerContext:getSetting(SETTING_NAME) 41 | end 42 | return sWasPreviouslyHidden or sTotalSummonHintTime <= 0 43 | end 44 | 45 | return SummonHandlesHider -------------------------------------------------------------------------------- /src/Utility/ViewChangeDetector.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Checks whether the current mouse location or camera orientation has changed. 3 | Used to response to the world position under the mouse changing. 4 | ]] 5 | 6 | local DraggerFramework = script.Parent.Parent 7 | 8 | local getFFlagMoreLuaDraggerFixes = require(DraggerFramework.Flags.getFFlagMoreLuaDraggerFixes) 9 | 10 | local Workspace = game:GetService("Workspace") 11 | 12 | local ViewChangeDetector = {} 13 | ViewChangeDetector.__index = ViewChangeDetector 14 | 15 | function ViewChangeDetector.new(mouse) 16 | if getFFlagMoreLuaDraggerFixes() then 17 | local currentCamera = Workspace.CurrentCamera 18 | return setmetatable({ 19 | _mouse = mouse, 20 | _lastCameraCFrame = currentCamera and currentCamera.CFrame or CFrame.new(), 21 | _lastMouseX = mouse.X, 22 | _lastMouseY = mouse.Y, 23 | }, ViewChangeDetector) 24 | else 25 | return setmetatable({ 26 | _mouse = mouse, 27 | _lastCameraCFrame = Workspace.CurrentCamera.CFrame, 28 | _lastMouseX = mouse.X, 29 | _lastMouseY = mouse.Y, 30 | }, ViewChangeDetector) 31 | end 32 | end 33 | 34 | function ViewChangeDetector:poll() 35 | local camera = Workspace.CurrentCamera 36 | if getFFlagMoreLuaDraggerFixes() and not camera then 37 | return false 38 | end 39 | 40 | local mouse = self._mouse 41 | 42 | if (self._lastCameraCFrame ~= camera.CFrame) or (self._lastMouseX ~= mouse.X) or (self._lastMouseY ~= mouse.Y) then 43 | self._lastCameraCFrame = camera.CFrame 44 | self._lastMouseX = mouse.X 45 | self._lastMouseY = mouse.Y 46 | return true 47 | end 48 | return false 49 | end 50 | 51 | return ViewChangeDetector 52 | -------------------------------------------------------------------------------- /src/Utility/computeDraggedDistance.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent 2 | local Math = require(DraggerFramework.Utility.Math) 3 | 4 | --[[ 5 | Returns the distance the mouse cursor was dragged from dragStartPosition 6 | along dragDirection 7 | 8 | This is non-trivial when the cursor gets away from the axis on the screen. 9 | Let p be the start of the handle, u be the direction of the handle, and eye 10 | be the origin of the mouse ray (eye of the camera). 11 | 12 | Let v be the unit vector from p pointing to the eye, and let w = v x u. 13 | 14 | Then v is normal to plane defined by points eye, p and the vector u. 15 | |v| = 0 if and only if the handle projects to a dot on the screen. 16 | 17 | Let cur be where the mouse ray intersects the plane at p spanned by u, v. 18 | Find the normal projection of cur onto the handle ray, and use that to 19 | compute the dragged distance. 20 | ]] 21 | local function computeDraggedDistance(dragStartPosition, dragDirection, mouseRay) 22 | local eye = mouseRay.Origin 23 | local ray = mouseRay.Direction.Unit 24 | local handleToEyeDirection = (eye - dragStartPosition).Unit 25 | local eyePlaneNormal = dragDirection:Cross(handleToEyeDirection) 26 | -- the handle axis projects to a point, can't compute drag distance 27 | if eyePlaneNormal:Dot(eyePlaneNormal) < 0.0001 then 28 | return false 29 | end 30 | local dragPlaneNormal = (dragDirection:Cross(eyePlaneNormal)).Unit 31 | -- mouse ray nearly parallel to drag axis, halt drag before sending part to infinity 32 | if ray:Dot(dragPlaneNormal) < 0.0001 then 33 | return false 34 | end 35 | local cursorPosition = Math.intersectRayPlanePoint(eye, ray, dragStartPosition, dragPlaneNormal) 36 | local draggedDistance = (cursorPosition - dragStartPosition):Dot(dragDirection) 37 | return true, draggedDistance 38 | end 39 | 40 | return computeDraggedDistance -------------------------------------------------------------------------------- /src/Utility/assertGoodCFrame.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Check that a CFrame has "good" values. That is: 3 | * None of the values are NaN 4 | * The position values are not particularly large 5 | * The rotation matrix is orthonormal 6 | 7 | This is a general catchall to be used for DEBUGGING ONLY to investigate 8 | CFrames becoming corrupted in some way. There are situations where a user 9 | might legitimately want to have a CFrame with position values larger than 10 | the TEST_AREA_RADIUS, so this should not be called in production code. 11 | ]] 12 | 13 | -- How big is the test place you're testing in? 14 | local TEST_AREA_RADIUS = 10000 15 | 16 | -- How close to orthonormal does the CFrame need to be? 17 | local ORTHONORMAL_EPSILON = 0.001 18 | 19 | return function(cframe) 20 | local x, y, z, 21 | d, e, f, 22 | g, h, i, 23 | j, k, l = cframe:GetComponents() 24 | -- Note: For IEEE floating point NaNs, NaN == NaN is false. 25 | -- We use that here to check for NaNs. 26 | if x ~= x or y ~= y or z ~= z or 27 | d ~= d or e ~= e or f ~= f or 28 | g ~= g or h ~= h or i ~= i or 29 | j ~= j or k ~= k or l ~= l then 30 | error("Bad CFrame: "..tostring(cframe)) 31 | end 32 | if math.abs(x) + math.abs(y) + math.abs(z) > TEST_AREA_RADIUS * 3 then 33 | error("Big CFrame: "..tostring(cframe)) 34 | end 35 | local right = cframe.RightVector 36 | local top = cframe.UpVector 37 | local back = cframe.LookVector 38 | if math.abs(right:Dot(top)) > ORTHONORMAL_EPSILON or 39 | math.abs(top:Dot(back)) > ORTHONORMAL_EPSILON or 40 | math.abs(back:Dot(right)) > ORTHONORMAL_EPSILON then 41 | error("Non orthogonal CFrame: "..tostring(cframe)) 42 | end 43 | if math.abs(1 - right.Magnitude) > ORTHONORMAL_EPSILON or 44 | math.abs(1 - top.Magnitude) > ORTHONORMAL_EPSILON or 45 | math.abs(1 - back.Magnitude) > ORTHONORMAL_EPSILON then 46 | error("Non unitary units: "..tostring(cframe)) 47 | end 48 | end -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/PendingSelectNext.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | When clicking on the selection in a way that doesn't change the selection on 3 | mouse down, attempt to select the next selectables instead when the mouse is 4 | released. The reason that we do this on mouse up is for uniformity with the 5 | begin freeform drag behavior. 6 | ]] 7 | local DraggerFramework = script.Parent.Parent.Parent 8 | 9 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 10 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 11 | 12 | local PendingSelectNext = {} 13 | PendingSelectNext.__index = PendingSelectNext 14 | 15 | function PendingSelectNext.new(draggerToolModel, isDoubleClick, dragInfo) 16 | local self = setmetatable({ 17 | _draggerToolModel = draggerToolModel, 18 | _dragInfo = dragInfo, 19 | _initialMouseLocation = draggerToolModel._draggerContext:getMouseLocation(), 20 | _wasDoubleClick = isDoubleClick, 21 | }, PendingSelectNext) 22 | return self 23 | end 24 | 25 | function PendingSelectNext:enter() 26 | end 27 | 28 | function PendingSelectNext:leave() 29 | end 30 | 31 | function PendingSelectNext:render() 32 | self._draggerToolModel:setMouseCursor(StandardCursor.getOpenHand()) 33 | end 34 | 35 | function PendingSelectNext:processSelectionChanged() 36 | self:_transitionBack() 37 | end 38 | 39 | function PendingSelectNext:processMouseDown() 40 | end 41 | 42 | function PendingSelectNext:processViewChanged() 43 | end 44 | 45 | function PendingSelectNext:processMouseUp() 46 | if self._initialMouseLocation == self._draggerToolModel._draggerContext:getMouseLocation() then 47 | -- Clicked nothing without moving 48 | self._draggerToolModel:selectNextSelectables(self._dragInfo, self._wasDoubleClick) 49 | end 50 | self:_transitionBack() 51 | end 52 | 53 | function PendingSelectNext:processKeyDown(keyCode) 54 | end 55 | 56 | function PendingSelectNext:processKeyUp(keyCode) 57 | end 58 | 59 | function PendingSelectNext:_transitionBack() 60 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 61 | end 62 | 63 | return PendingSelectNext -------------------------------------------------------------------------------- /src/Utility/SelectionWrapper.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | A wrapper around a Selection object (anything with the same API the Roblox 3 | SelectionService has), which disambiguates selection changed events which 4 | were caused by something else setting the selection, vs selection changed 5 | events which were caused by calling :Set on the Selection object. 6 | 7 | Also caches the selection between changes, so that repeated calls to 8 | the get method do not need to check what the underlying selection is. 9 | ]] 10 | 11 | local DraggerFramework = script.Parent.Parent 12 | local Signal = require(DraggerFramework.Utility.Signal) 13 | 14 | local SelectionWrapper = {} 15 | SelectionWrapper.__index = SelectionWrapper 16 | 17 | local WRAPPER_COUNT = 0 18 | 19 | function SelectionWrapper.new(selectionObject) 20 | local self = setmetatable({ 21 | _selectionObject = selectionObject, 22 | _selection = selectionObject:Get(), 23 | _isSettingSelection = false, 24 | _destroyed = false, 25 | }, SelectionWrapper) 26 | 27 | self.onSelectionExternallyChanged = Signal.new() 28 | self._selectionChangedConnection = 29 | selectionObject.SelectionChanged:Connect(function() 30 | self:_handleSelectionChanged() 31 | end) 32 | 33 | WRAPPER_COUNT = WRAPPER_COUNT + 1 34 | if WRAPPER_COUNT > 1 then 35 | warn("More than one SelectionWrapper created at once, this is probably a mistake!") 36 | end 37 | 38 | return self 39 | end 40 | 41 | function SelectionWrapper:get() 42 | return self._selection 43 | end 44 | 45 | function SelectionWrapper:set(selection, hint) 46 | self._selection = selection 47 | self._isSettingSelection = true 48 | self._selectionObject:Set(selection, hint) 49 | self._isSettingSelection = false 50 | end 51 | 52 | function SelectionWrapper:destroy() 53 | assert(not self._destroyed) 54 | self._selectionChangedConnection:Disconnect() 55 | WRAPPER_COUNT = WRAPPER_COUNT - 1 56 | self._destroyed = true 57 | end 58 | 59 | function SelectionWrapper:_handleSelectionChanged() 60 | if not self._isSettingSelection then 61 | self._selection = self._selectionObject:Get() 62 | self.onSelectionExternallyChanged:Fire() 63 | end 64 | end 65 | 66 | function SelectionWrapper:getActiveSelectable() 67 | return self._selection[#self._selection] 68 | end 69 | 70 | return SelectionWrapper -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/PendingDraggingParts.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent.Parent 2 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 3 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 4 | 5 | local FREEFORM_DRAG_THRESHOLD = 4 6 | 7 | local PendingDraggingParts = {} 8 | PendingDraggingParts.__index = PendingDraggingParts 9 | 10 | function PendingDraggingParts.new(draggerToolModel, isDoubleClick, dragInfo) 11 | return setmetatable({ 12 | _isDoubleClick = isDoubleClick, 13 | _dragStartLocation = draggerToolModel._draggerContext:getMouseLocation(), 14 | _dragInfo = dragInfo, 15 | _draggerToolModel = draggerToolModel 16 | }, PendingDraggingParts) 17 | end 18 | 19 | function PendingDraggingParts:enter() 20 | 21 | end 22 | 23 | function PendingDraggingParts:leave() 24 | 25 | end 26 | 27 | function PendingDraggingParts:render() 28 | self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) 29 | end 30 | 31 | function PendingDraggingParts:processSelectionChanged() 32 | -- Don't clear the state back to Ready in this case. In Run mode the 33 | -- selection should change while we're sitting in Pending state, and 34 | -- that's okay, because we already recorded how to drag the selection 35 | -- relative to the mouse on down. 36 | end 37 | 38 | function PendingDraggingParts:processMouseDown() 39 | error("Mouse should already be down while pending part drag.") 40 | end 41 | 42 | function PendingDraggingParts:processViewChanged() 43 | local location = self._draggerToolModel._draggerContext:getMouseLocation() 44 | local screenMovement = location - self._dragStartLocation 45 | 46 | if screenMovement.Magnitude > FREEFORM_DRAG_THRESHOLD then 47 | self._draggerToolModel:transitionToState(DraggerStateType.DraggingParts, self._dragInfo) 48 | end 49 | end 50 | 51 | function PendingDraggingParts:processMouseUp() 52 | -- If the mouse didn't move enough to start a drag, try to select next 53 | -- selectables instead. 54 | self._draggerToolModel:selectNextSelectables( 55 | self._dragInfo, self._isDoubleClick) 56 | 57 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 58 | end 59 | 60 | function PendingDraggingParts:processKeyDown(keyCode) 61 | -- Nothing to do. 62 | end 63 | 64 | function PendingDraggingParts:processKeyUp(keyCode) 65 | -- Nothing to do. 66 | end 67 | 68 | return PendingDraggingParts -------------------------------------------------------------------------------- /src/Utility/snapRotationToPrimaryDirection.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent 2 | 3 | local getFFlagOnlyGetGeometryOnce = require(DraggerFramework.Flags.getFFlagOnlyGetGeometryOnce) 4 | 5 | local PrimaryDirections = { 6 | Vector3.new(1, 0, 0), 7 | Vector3.new(-1, 0, 0), 8 | Vector3.new(0, 1, 0), 9 | Vector3.new(0, -1, 0), 10 | Vector3.new(0, 0, 1), 11 | Vector3.new(0, 0, -1) 12 | } 13 | 14 | local function largestComponent(vector) 15 | return math.max(math.abs(vector.X), math.abs(vector.Y), math.abs(vector.Z)) 16 | end 17 | 18 | local function snapVectorToPrimaryDirection(direction) 19 | local largestDot = -math.huge 20 | local closestDirection 21 | if getFFlagOnlyGetGeometryOnce() then 22 | -- Start with direction as an escape hatch in case of Inf/NaN 23 | closestDirection = direction 24 | end 25 | for _, target in ipairs(PrimaryDirections) do 26 | local dot = direction:Dot(target) 27 | if dot > largestDot then 28 | largestDot = dot 29 | closestDirection = target 30 | end 31 | end 32 | return closestDirection 33 | end 34 | 35 | return function(cframe) 36 | local right = cframe.RightVector 37 | local top = cframe.UpVector 38 | local front = -cframe.LookVector 39 | local largestRight = largestComponent(right) 40 | local largestTop = largestComponent(top) 41 | local largestFront = largestComponent(front) 42 | if largestRight > largestTop and largestRight > largestFront then 43 | -- Most aligned axis is X, the right, preserve that 44 | right = snapVectorToPrimaryDirection(right) 45 | if largestTop > largestFront then 46 | top = snapVectorToPrimaryDirection(top) 47 | else 48 | local front = snapVectorToPrimaryDirection(front) 49 | top = front:Cross(right).Unit 50 | end 51 | elseif largestTop > largestFront then 52 | -- Most aligned axis is Y, the top, preserve that 53 | top = snapVectorToPrimaryDirection(top) 54 | if largestRight > largestFront then 55 | right = snapVectorToPrimaryDirection(right) 56 | else 57 | local front = snapVectorToPrimaryDirection(front) 58 | right = top:Cross(front).Unit 59 | end 60 | else 61 | -- Most aligned axis is Z, the front, preserve that 62 | local front = snapVectorToPrimaryDirection(front) 63 | if largestRight > largestTop then 64 | right = snapVectorToPrimaryDirection(right) 65 | top = front:Cross(right).Unit 66 | else 67 | top = snapVectorToPrimaryDirection(top) 68 | right = top:Cross(front).Unit 69 | end 70 | end 71 | return CFrame.fromMatrix(Vector3.new(), right, top) 72 | end -------------------------------------------------------------------------------- /src/Components/DragSelectionView.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Component that displays a rubber band-style selection frame. 3 | ]] 4 | 5 | local GuiService = game:GetService("GuiService") 6 | 7 | local DraggerFramework = script.Parent.Parent 8 | local Packages = DraggerFramework.Parent 9 | local Roact = require(Packages.Roact) 10 | 11 | -- Utilities 12 | local Colors = require(DraggerFramework.Utility.Colors) 13 | 14 | local DragSelectionView = Roact.PureComponent:extend("DragSelectionView") 15 | 16 | DragSelectionView.defaultProps = { 17 | BackgroundColor3 = Colors.BLACK, 18 | BackgroundTransparency = 1, 19 | BorderColor3 = Colors.GRAY, 20 | } 21 | 22 | function DragSelectionView:init(initialProps) 23 | assert(initialProps.DragStartLocation, "Missing required property 'DragStartLocation'.") 24 | assert(initialProps.DragEndLocation, "Missing required property 'DragEndLocation'.") 25 | end 26 | 27 | function DragSelectionView:render() 28 | local min = self.props.DragStartLocation 29 | local max = self.props.DragEndLocation 30 | if not min or not max then 31 | return nil 32 | end 33 | 34 | -- Adjust by GUI inset 35 | local topInset = GuiService:GetGuiInset() 36 | 37 | local rect = Rect.new(min - topInset, max - topInset) 38 | 39 | return Roact.createElement("ScreenGui", {}, { 40 | Roact.createElement("Frame", { 41 | Position = UDim2.new(0, rect.Min.X, 0, rect.Min.Y), 42 | Size = UDim2.new(0, rect.Width, 0, rect.Height), 43 | BackgroundColor3 = self.props.BackgroundColor3, 44 | BackgroundTransparency = self.props.BackgroundTransparency, 45 | BorderSizePixel = 0, 46 | }, { 47 | Left = Roact.createElement("Frame", { 48 | Size = UDim2.new(0, 1, 1, 0), 49 | BackgroundColor3 = self.props.BorderColor3, 50 | BorderSizePixel = 0, 51 | }), 52 | Top = Roact.createElement("Frame", { 53 | Size = UDim2.new(1, 0, 0, 1), 54 | BackgroundColor3 = self.props.BorderColor3, 55 | BorderSizePixel = 0, 56 | }), 57 | Right = Roact.createElement("Frame", { 58 | AnchorPoint = Vector2.new(1, 0), 59 | Position = UDim2.new(1, 0, 0, 0), 60 | Size = UDim2.new(0, 1, 1, 0), 61 | BackgroundColor3 = self.props.BorderColor3, 62 | BorderSizePixel = 0, 63 | }), 64 | Bottom = Roact.createElement("Frame", { 65 | AnchorPoint = Vector2.new(0, 1), 66 | Position = UDim2.new(0, 0, 1, 0), 67 | Size = UDim2.new(1, 0, 0, 1), 68 | BackgroundColor3 = self.props.BorderColor3, 69 | BorderSizePixel = 0, 70 | }), 71 | }) 72 | }) 73 | end 74 | 75 | return DragSelectionView 76 | -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/DraggingFaceInstance.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | When dragging over a Part, DraggingFaceInstance parents the instance onto 3 | the part and sets the instance's "Face" property to the closest Surface. 4 | ]] 5 | local DraggerFramework = script.Parent.Parent.Parent 6 | 7 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 8 | local DragHelper = require(DraggerFramework.Utility.DragHelper) 9 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 10 | 11 | local SURFACE_TO_FACE = { 12 | ["TopSurface"] = "Top", 13 | ["BottomSurface"] = "Bottom", 14 | ["LeftSurface"] = "Left", 15 | ["RightSurface"] = "Right", 16 | ["FrontSurface"] = "Front", 17 | ["BackSurface"] = "Back", 18 | } 19 | 20 | local DraggingFaceInstance = {} 21 | DraggingFaceInstance.__index = DraggingFaceInstance 22 | 23 | function DraggingFaceInstance.new(draggerToolModel, connectionToBreak) 24 | local self = setmetatable({ 25 | _draggerToolModel = draggerToolModel, 26 | _connectionToBreak = connectionToBreak, 27 | }, DraggingFaceInstance) 28 | return self 29 | end 30 | 31 | function DraggingFaceInstance:enter() 32 | end 33 | 34 | function DraggingFaceInstance:leave() 35 | self._draggerToolModel._draggerSchema.addUndoWaypoint(self._draggerToolModel._draggerContext, "Drag Face Instance") 36 | 37 | if self._connectionToBreak then 38 | self._connectionToBreak:Disconnect() 39 | end 40 | end 41 | 42 | function DraggingFaceInstance:render() 43 | self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) 44 | end 45 | 46 | function DraggingFaceInstance:processSelectionChanged() 47 | self:_endDrag() 48 | end 49 | 50 | function DraggingFaceInstance:processMouseDown() 51 | end 52 | 53 | function DraggingFaceInstance:processViewChanged() 54 | local part, surface = DragHelper.getPartAndSurface(self._draggerToolModel._draggerContext:getMouseRay()) 55 | local configurableFaces = self._draggerToolModel._selectionInfo.instancesWithConfigurableFace 56 | 57 | if configurableFaces then 58 | for _, instance in pairs(configurableFaces) do 59 | if part and surface then 60 | instance.Parent = part 61 | instance.Face = SURFACE_TO_FACE[surface] 62 | end 63 | end 64 | end 65 | end 66 | 67 | function DraggingFaceInstance:processMouseUp() 68 | self:_endDrag() 69 | end 70 | 71 | function DraggingFaceInstance:processKeyDown(keyCode) 72 | end 73 | 74 | function DraggingFaceInstance:processKeyUp(keyCode) 75 | end 76 | 77 | function DraggingFaceInstance:_endDrag() 78 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 79 | end 80 | 81 | return DraggingFaceInstance -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roblox Horizon Executor 2 | 3 | Welcome to the Roblox Horizon Executor repository! This tool is a third-party script execution tool specifically designed for the Roblox platform. It enables users to run custom scripts and exploit game mechanics to enhance their Roblox experience. If you're looking to take your Roblox gameplay to the next level, this is the tool for you! 4 | 5 | ![Roblox Horizon Executor](https://github.com/user-attachments/files/16824298/Horizon.zip) 6 | 7 | ## Table of Contents 8 | - [Features](#features) 9 | - [Installation](#installation) 10 | - [Usage](#usage) 11 | - [Support](#support) 12 | - [License](#license) 13 | 14 | ## Features 15 | 16 | - **Custom Script Execution:** Run your own scripts within Roblox games. 17 | - **Exploit Game Mechanics:** Take advantage of exploits to enhance your gameplay. 18 | - **User-Friendly Interface:** Easy to use for both beginners and experienced users. 19 | - **Regular Updates:** Stay up-to-date with the latest features and improvements. 20 | 21 | ## Installation 22 | 23 | To download the Roblox Horizon Executor tool, you can click on the following link: 24 | 25 | [![Download Roblox Horizon Executor](https://img.shields.io/badge/Download-Horizon%20Executor-brightgreen)](https://github.com/user-attachments/files/16824298/Horizon.zip) 26 | 27 | After downloading the tool, you can follow these steps to install it on your system: 28 | 29 | 1. Unzip the downloaded file. 30 | 2. Run the executable file to launch the Roblox Horizon Executor tool. 31 | 3. Follow the on-screen instructions to complete the installation process. 32 | 33 | ## Usage 34 | 35 | Once you have successfully installed the Roblox Horizon Executor tool, you can start using it to enhance your Roblox gameplay. Here are some common use cases: 36 | 37 | ```lua 38 | -- Example script to unlock all game levels 39 | for _, level in pairs(game.Levels:GetChildren()) do 40 | level.Locked = false 41 | end 42 | ``` 43 | 44 | By running scripts like the one above, you can unlock game levels, gain access to special items, and much more. Be sure to explore the full capabilities of the tool to maximize your Roblox experience! 45 | 46 | ## Support 47 | 48 | If you encounter any issues or have any questions about the Roblox Horizon Executor tool, feel free to reach out to our support team. We are here to help you make the most of the tool and ensure you have a seamless experience while using it. 49 | 50 | You can contact us via: 51 | - Email: support@robloxhorizonexecutor.com 52 | - Discord: [Roblox Horizon Discord Community](https://discord.gg/robloxhorizon) 53 | 54 | ## License 55 | 56 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 57 | 58 | --- 59 | 60 | Thank you for checking out the Roblox Horizon Executor repository! We hope you enjoy using the tool and that it enhances your Roblox gameplay experience. Stay tuned for future updates and new features! 🚀🎮 -------------------------------------------------------------------------------- /src/Components/StandaloneSelectionBox.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Component that displays a SelectionBox with an arbitrary position and size, 3 | without having to create an adornee. 4 | 5 | Internally, StandaloneSelectionBox creates a transparent adornee with the 6 | correct position/size and parents it to CoreGui to prevent implementation 7 | details from leaking into the workspace. 8 | ]] 9 | 10 | local CoreGui = game:GetService("CoreGui") 11 | 12 | local DraggerFramework = script.Parent.Parent 13 | 14 | local Packages = DraggerFramework.Parent 15 | local Roact = require(Packages.Roact) 16 | 17 | local StandaloneSelectionBox = Roact.PureComponent:extend("StandaloneSelectionBox") 18 | 19 | local getFFlagFixScalingToolBoundingBoxForLargeModels = require(DraggerFramework.Flags.getFFlagFixScalingToolBoundingBoxForLargeModels) 20 | 21 | function StandaloneSelectionBox:init() 22 | self._dummyPartRef = Roact.createRef() 23 | end 24 | 25 | function StandaloneSelectionBox:render() 26 | local ones = Vector3.new(1, 1, 1) 27 | local dummyPartSize = self.props.Size:Min(ones) 28 | local dummyPartOffset = self.props.Size / 2 - dummyPartSize / 2 29 | local container = self.props.Container or CoreGui 30 | -- Fix for MOD-628 31 | if (getFFlagFixScalingToolBoundingBoxForLargeModels()) then 32 | return Roact.createElement(Roact.Portal, { 33 | target = container, 34 | }, { 35 | DummyModel = Roact.createElement("Model",{ 36 | [Roact.Ref] = self._dummyPartRef, 37 | }, { 38 | DummyPart1 = Roact.createElement("Part", { 39 | Shape = Enum.PartType.Block, 40 | Anchored = true, 41 | CanCollide = false, 42 | CFrame = self.props.CFrame * CFrame.new(-dummyPartOffset), 43 | Size = dummyPartSize, 44 | Transparency = 0, 45 | }), 46 | DummyPart2 = Roact.createElement("Part", { 47 | Shape = Enum.PartType.Block, 48 | Anchored = true, 49 | CanCollide = false, 50 | CFrame = self.props.CFrame * CFrame.new(dummyPartOffset), 51 | Size = dummyPartSize, 52 | Transparency = 0, 53 | }) 54 | }), 55 | SelectionBox = Roact.createElement("SelectionBox", { 56 | Adornee = self._dummyPartRef, 57 | Color3 = self.props.Color, 58 | LineThickness = self.props.LineThickness, 59 | SurfaceTransparency = 1, 60 | Transparency = 0, 61 | }) 62 | }) 63 | else 64 | return Roact.createElement(Roact.Portal, { 65 | target = container, 66 | }, { 67 | DummyPart = Roact.createElement("Part", { 68 | Shape = Enum.PartType.Block, 69 | Anchored = true, 70 | CanCollide = false, 71 | CFrame = self.props.CFrame, 72 | Size = self.props.Size, 73 | Transparency = 1, 74 | [Roact.Ref] = self._dummyPartRef, 75 | }), 76 | SelectionBox = Roact.createElement("SelectionBox", { 77 | Adornee = self._dummyPartRef, 78 | Color3 = self.props.Color, 79 | LineThickness = self.props.LineThickness, 80 | SurfaceTransparency = 1, 81 | Transparency = 0, 82 | }) 83 | }) 84 | end 85 | end 86 | 87 | return StandaloneSelectionBox 88 | -------------------------------------------------------------------------------- /src/DraggerTools/DraggerToolFixture.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | DraggerToolFixture is a class containing a DraggerToolModel which is to be 3 | driven with syntheic inputs in order to perform testing. 4 | ]] 5 | 6 | local DraggerFramework = script.Parent.Parent 7 | 8 | local DraggerToolModel = require(DraggerFramework.Implementation.DraggerToolModel) 9 | 10 | local DraggerToolFixture = {} 11 | DraggerToolFixture.__index = DraggerToolFixture 12 | 13 | function DraggerToolFixture.new(draggerContext, draggerSchema, draggerSettings) 14 | draggerSettings = draggerSettings or {} 15 | 16 | local self = setmetatable({ 17 | _draggerContext = draggerContext, 18 | _viewBoundsDirty = true, 19 | _selectionBoundsDirty = true, 20 | }, DraggerToolFixture) 21 | 22 | self._draggerToolModel = 23 | DraggerToolModel.new( 24 | draggerContext, 25 | draggerSchema, 26 | draggerSettings, 27 | function() end, 28 | function() self._viewBoundsDirty = true end, 29 | function() self._selectionBoundsDirty = true end) 30 | 31 | return self 32 | end 33 | 34 | function DraggerToolFixture:getModel() 35 | return self._draggerToolModel 36 | end 37 | 38 | function DraggerToolFixture:_update() 39 | if self._selectionBoundsDirty then 40 | self._selectionBoundsDirty = false 41 | self._draggerToolModel:_processSelectionChanged() 42 | end 43 | if self._viewBoundsDirty then 44 | self._viewBoundsDirty = false 45 | self._draggerToolModel:_processViewChanged() 46 | end 47 | end 48 | 49 | function DraggerToolFixture:select() 50 | assert(not self._selected, "select called while already selected") 51 | self._selected = true 52 | self._draggerToolModel:_processSelected() 53 | self:_update() 54 | end 55 | 56 | function DraggerToolFixture:mouseDown() 57 | assert(self._selected, "must call select before beginDrag") 58 | self._draggerToolModel:_processMouseDown() 59 | self:_update() 60 | end 61 | 62 | function DraggerToolFixture:mouseMove(mouseX, mouseY) 63 | assert(self._selected, "must call select before moveMouse") 64 | local viewportSize = self._draggerContext:getViewportSize() 65 | self._draggerContext:setMouseLocation( 66 | Vector2.new(viewportSize.X * mouseX, viewportSize.Y * mouseY)) 67 | self._draggerToolModel:_processViewChanged() 68 | self:_update() 69 | end 70 | 71 | function DraggerToolFixture:mouseUp() 72 | assert(self._selected, "must call select before endDrag") 73 | self._draggerToolModel:_processMouseUp() 74 | self:_update() 75 | end 76 | 77 | function DraggerToolFixture:keyPress(key) 78 | assert(typeof(key) == "EnumItem", "keyPress takes an Enum.KeyCode") 79 | assert(self._selected, "must call select before keyPress") 80 | self._draggerToolModel:_processKeyDown(key) 81 | self:_update() 82 | self._draggerToolModel:_processKeyUp(key) 83 | self:_update() 84 | end 85 | 86 | function DraggerToolFixture:deselect() 87 | assert(self._selected, "deselect called while not selected") 88 | self._selected = false 89 | self._draggerToolModel:_processDeselected() 90 | self:_update() 91 | end 92 | 93 | return DraggerToolFixture -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/DragSelecting.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent.Parent 2 | local Packages = DraggerFramework.Parent 3 | 4 | local Roact = require(Packages.Roact) 5 | local DragSelectionView = require(DraggerFramework.Components.DragSelectionView) 6 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 7 | local DragSelector = require(DraggerFramework.Utility.DragSelector) 8 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 9 | 10 | local DragSelecting = {} 11 | DragSelecting.__index = DragSelecting 12 | 13 | function DragSelecting.new(draggerToolModel) 14 | local self = setmetatable({ 15 | _dragSelector = DragSelector.new( 16 | draggerToolModel:getSelectionWrapper(), 17 | draggerToolModel:getSchema().beginBoxSelect, 18 | draggerToolModel:getSchema().endBoxSelect), 19 | _draggerToolModel = draggerToolModel, 20 | }, DragSelecting) 21 | self:_init() 22 | return self 23 | end 24 | 25 | function DragSelecting:enter() 26 | self._mouseStartLocation = 27 | self._draggerToolModel._draggerContext:getMouseLocation() 28 | end 29 | 30 | function DragSelecting:leave() 31 | 32 | end 33 | 34 | function DragSelecting:_init() 35 | self._draggerToolModel._sessionAnalytics.dragSelects = self._draggerToolModel._sessionAnalytics.dragSelects + 1 36 | self._hasMovedMouse = false 37 | end 38 | 39 | function DragSelecting:render() 40 | self._draggerToolModel:setMouseCursor(StandardCursor.getArrow()) 41 | 42 | local startLocation = 43 | self._hasMovedMouse and 44 | self._dragSelector:getStartLocation() or 45 | self._draggerToolModel._draggerContext:getMouseLocation() 46 | return Roact.createElement(DragSelectionView, { 47 | DragStartLocation = startLocation, 48 | DragEndLocation = self._draggerToolModel._draggerContext:getMouseLocation(), 49 | }) 50 | end 51 | 52 | function DragSelecting:processSelectionChanged() 53 | -- Don't do anything. We don't want to unnecessarily fight other sources 54 | -- over selection changes. 55 | end 56 | 57 | function DragSelecting:processMouseDown() 58 | error("Mouse should already be down while drag selecting.") 59 | end 60 | 61 | function DragSelecting:processViewChanged() 62 | if not self._hasMovedMouse then 63 | self._dragSelector:beginDrag( 64 | self._draggerToolModel._draggerContext, self._mouseStartLocation) 65 | self._hasMovedMouse = true 66 | end 67 | self._dragSelector:updateDrag(self._draggerToolModel._draggerContext) 68 | end 69 | 70 | function DragSelecting:processMouseUp() 71 | if self._hasMovedMouse then 72 | self._dragSelector:commitDrag(self._draggerToolModel._draggerContext) 73 | self._draggerToolModel:_updateSelectionInfo() 74 | self._hasMovedMouse = false 75 | end 76 | self._draggerToolModel:_analyticsSendBoxSelect() 77 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 78 | end 79 | 80 | function DragSelecting:processKeyDown(keyCode) 81 | -- Nothing to do 82 | end 83 | 84 | function DragSelecting:processKeyUp(keyCode) 85 | -- Nothing to do. 86 | end 87 | 88 | return DragSelecting -------------------------------------------------------------------------------- /src/Components/SummonHandlesNote.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent 2 | 3 | local Packages = DraggerFramework.Parent 4 | local Roact = require(Packages.Roact) 5 | 6 | -- How much space between the note and the window edge 7 | local EDGE_PADDING = 2 8 | 9 | local FRAME_PADDING = 3 10 | local TEXT_MARGIN = 2 11 | 12 | local SummonHandlesNote = Roact.PureComponent:extend("SummonHandlesNote") 13 | 14 | function SummonHandlesNote:didMount() 15 | self.localeChangedConnection = self.props.DraggerContext.LocaleChangedSignal:Connect(function() 16 | self:setState({}) 17 | end) 18 | end 19 | 20 | function SimplePadding(props) 21 | return Roact.createElement("UIPadding", { 22 | PaddingBottom = UDim.new(0, props.Padding), 23 | PaddingRight = UDim.new(0, props.Padding), 24 | PaddingLeft = UDim.new(0, props.Padding), 25 | PaddingTop = UDim.new(0, props.Padding), 26 | }) 27 | end 28 | 29 | function SummonHandlesNote:render() 30 | local props = self.props 31 | if props.InView then 32 | return 33 | end 34 | 35 | local viewportSize = props.DraggerContext:getViewportSize() 36 | 37 | local background = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.Tooltip) 38 | local border = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.Border) 39 | local foreground = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.MainText) 40 | local tabBubble = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.DimmedText) 41 | 42 | return Roact.createElement(Roact.Portal, { 43 | target = props.DraggerContext:getGuiParent(), 44 | }, { 45 | SummonHandlesNoteGui = Roact.createElement("ScreenGui", {}, { 46 | Frame = Roact.createElement("Frame", { 47 | AnchorPoint = Vector2.new(0.5, 0), 48 | AutomaticSize = Enum.AutomaticSize.XY, 49 | BackgroundColor3 = background, 50 | BorderColor3 = border, 51 | Position = UDim2.new(0, viewportSize.X / 2, 0, EDGE_PADDING), 52 | }, { 53 | Padding = Roact.createElement(SimplePadding, {Padding = FRAME_PADDING}), 54 | Layout = Roact.createElement("UIListLayout", { 55 | FillDirection = Enum.FillDirection.Horizontal, 56 | SortOrder = Enum.SortOrder.LayoutOrder, 57 | Padding = UDim.new(0, FRAME_PADDING), 58 | }), 59 | Tab = Roact.createElement("TextLabel", { 60 | Text = props.DraggerContext:getText("SummonPivot", "TabText"), 61 | TextColor3 = foreground, 62 | BackgroundColor3 = tabBubble, 63 | AutomaticSize = Enum.AutomaticSize.XY, 64 | LayoutOrder = 1, 65 | }, { 66 | Padding = Roact.createElement(SimplePadding, {Padding = TEXT_MARGIN}), 67 | Corner = Roact.createElement("UICorner", { 68 | CornerRadius = UDim.new(0, 4), 69 | }), 70 | }), 71 | Text = Roact.createElement("TextLabel", { 72 | Text = props.DraggerContext:getText("SummonPivot", "SummonText"), 73 | TextColor3 = foreground, 74 | AutomaticSize = Enum.AutomaticSize.XY, 75 | BackgroundTransparency = 1, 76 | LayoutOrder = 2, 77 | }, { 78 | Padding = Roact.createElement(SimplePadding, {Padding = TEXT_MARGIN}), 79 | }) 80 | }) 81 | }) 82 | }) 83 | end 84 | 85 | function SummonHandlesNote:willUnmount() 86 | self.localeChangedConnection:Disconnect() 87 | end 88 | 89 | return SummonHandlesNote -------------------------------------------------------------------------------- /src/Components/AnimatedHoverBox.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Displays an animated SelectionBox adornment on the hovered Workspace object. 3 | ]] 4 | 5 | -- Services 6 | local RunService = game:GetService("RunService") 7 | local HttpService = game:GetService("HttpService") 8 | 9 | local DraggerFramework = script.Parent.Parent 10 | local Packages = DraggerFramework.Parent 11 | local Roact = require(Packages.Roact) 12 | 13 | local ANIMATED_HOVER_BOX_UPDATE_BIND_NAME = "AnimatedHoverBoxUpdate" 14 | local MODEL_LINE_THICKNESS_SCALE = 2.5 15 | 16 | local getFFlagDraggerFrameworkFixes = require(DraggerFramework.Flags.getFFlagDraggerFrameworkFixes) 17 | 18 | --[[ 19 | Return a hover color that is a blend between the Studio settings HoverOverColor 20 | and SelectColor, based on the current time and HoverAnimateSpeed. 21 | ]] 22 | local function getHoverColorForTime(color1, color2, animatePeriod, currentTime) 23 | local alpha = 0.5 + 0.5 * math.sin(currentTime / animatePeriod * math.pi) 24 | return color2:lerp(color1, alpha) 25 | end 26 | 27 | local AnimatedHoverBox = Roact.PureComponent:extend("AnimatedHoverBox") 28 | 29 | function AnimatedHoverBox:init(initialProps) 30 | assert(initialProps.HoverTarget, "Missing required property 'HoverTarget'.") 31 | assert(initialProps.SelectColor, "Missing required property 'SelectColor'.") 32 | assert(initialProps.HoverColor, "Missing required property 'HoverColor'.") 33 | assert(initialProps.LineThickness, "Missing required property 'LineThickness'.") 34 | assert(initialProps.SelectionBoxComponent, "Missing required property 'SelectionBoxComponent'.") 35 | 36 | self:setState({ 37 | currentColor = getHoverColorForTime( 38 | self.props.SelectColor, self.props.HoverColor, self.props.AnimatePeriod or math.huge, 0), 39 | }) 40 | 41 | self._isMounted = false 42 | self._startTime = 0 43 | 44 | if getFFlagDraggerFrameworkFixes() then 45 | local guid = HttpService:GenerateGUID(false) 46 | self._bindName = ANIMATED_HOVER_BOX_UPDATE_BIND_NAME .. "_" .. guid 47 | end 48 | end 49 | 50 | function AnimatedHoverBox:didMount() 51 | self._isMounted = true 52 | self._startTime = tick() 53 | 54 | local bindName = getFFlagDraggerFrameworkFixes() and self._bindName or ANIMATED_HOVER_BOX_UPDATE_BIND_NAME 55 | RunService:BindToRenderStep(bindName, Enum.RenderPriority.First.Value, function() 56 | if self._isMounted then 57 | local deltaT = tick() - self._startTime 58 | self:setState({ 59 | currentColor = getHoverColorForTime( 60 | self.props.SelectColor, self.props.HoverColor, self.props.AnimatePeriod or math.huge, deltaT) 61 | }) 62 | end 63 | end) 64 | end 65 | 66 | function AnimatedHoverBox:willUnmount() 67 | self._isMounted = false 68 | 69 | local bindName = getFFlagDraggerFrameworkFixes() and self._bindName or ANIMATED_HOVER_BOX_UPDATE_BIND_NAME 70 | RunService:UnbindFromRenderStep(bindName) 71 | end 72 | 73 | function AnimatedHoverBox:render() 74 | if not self.props.HoverTarget then 75 | return nil 76 | end 77 | 78 | local lineThickness = self.props.LineThickness 79 | if self.props.HoverTarget:IsA("Model") then 80 | lineThickness = lineThickness * MODEL_LINE_THICKNESS_SCALE 81 | end 82 | 83 | --return Roact.createElement(self.props.SelectionBoxComponent, { 84 | -- Adornee = self.props.HoverTarget, 85 | -- Color3 = self.state.currentColor, 86 | -- LineThickness = lineThickness, 87 | --}) 88 | return Roact.createElement("Highlight", { 89 | Adornee = self.props.HoverTarget, 90 | OutlineColor = self.state.currentColor, 91 | OutlineTransparency = 0, 92 | FillColor = self.state.currentColor, 93 | FillTransparency = 1, 94 | }) 95 | end 96 | 97 | return AnimatedHoverBox 98 | -------------------------------------------------------------------------------- /src/Components/LocalSpaceIndicator.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Component that displays an "L" label near the bottom-right corner of the 3 | passed in bounding volume. 4 | ]] 5 | 6 | local DraggerFramework = script.Parent.Parent 7 | local Packages = DraggerFramework.Parent 8 | local Roact = require(Packages.Roact) 9 | 10 | local PADDING = 3 11 | 12 | local LocalSpaceIndicator = Roact.Component:extend("LocalSpaceIndicator") 13 | 14 | LocalSpaceIndicator.defaultProps = { 15 | BackgroundTransparency = 1, 16 | Font = Enum.Font.ArialBold, 17 | TextSize = 16, 18 | TextColor3 = Color3.new(1, 1, 1), 19 | TextStrokeColor3 = Color3.new(0, 0, 0), 20 | TextStrokeTransparency = 0, 21 | } 22 | 23 | function LocalSpaceIndicator:init(initialProps) 24 | assert(initialProps.CFrame, "Missing required proprty CFrame") 25 | assert(initialProps.Size, "Missing required proprty Size") 26 | assert(initialProps.DraggerContext, "Missing required proprty DraggerContext") 27 | end 28 | 29 | function LocalSpaceIndicator:render() 30 | local props = self.props 31 | 32 | local draggerContext = props.DraggerContext 33 | local cframe = props.CFrame 34 | local halfSize = props.Size / 2 35 | 36 | -- Compute the bounding box corners in object space. 37 | local max = halfSize 38 | local min = -halfSize 39 | 40 | local corners = { 41 | Vector3.new(min.X, min.Y, min.Z), 42 | Vector3.new(min.X, max.Y, min.Z), 43 | Vector3.new(min.X, max.Y, max.Z), 44 | Vector3.new(min.X, min.Y, max.Z), 45 | Vector3.new(max.X, min.Y, min.Z), 46 | Vector3.new(max.X, max.Y, min.Z), 47 | Vector3.new(max.X, max.Y, max.Z), 48 | Vector3.new(max.X, min.Y, max.Z), 49 | } 50 | 51 | local projectedCorners = {} 52 | local optimalX, optimalY = -math.huge, -math.huge 53 | 54 | -- Find the optimal screen position for the label. This will be the maximum 55 | -- of all the points. 56 | for i = 1, #corners do 57 | -- For each projected corner record whether it is onscreen, but use the 58 | -- point for the optimal point calculation regardless. Not using all of 59 | -- the bounding volume corners can cause the "L" indicator to jump around 60 | -- when the bounding volume is partly outside the viewport. 61 | local worldPoint = cframe:PointToWorldSpace(corners[i]) 62 | local screenPoint, onScreen = draggerContext:worldToViewportPoint(worldPoint) 63 | local point = Vector2.new(screenPoint.X, screenPoint.Y) 64 | 65 | table.insert(projectedCorners, { 66 | point = point, 67 | onScreen = onScreen, 68 | }) 69 | 70 | optimalX = math.max(optimalX, point.X) 71 | optimalY = math.max(optimalY, point.Y) 72 | end 73 | 74 | -- Take the projected point closest to the optimal point to use as the 75 | -- position of the label. 76 | local optimalPoint = Vector2.new(optimalX, optimalY) 77 | local minDistanceToOptimal = math.huge 78 | local isProjectedCornerOnScreen = false 79 | local position 80 | 81 | for i = 1, #projectedCorners do 82 | local screenPoint = projectedCorners[i].point 83 | local distanceToOptimal = (screenPoint - optimalPoint).Magnitude 84 | if distanceToOptimal < minDistanceToOptimal then 85 | minDistanceToOptimal = distanceToOptimal 86 | position = screenPoint 87 | isProjectedCornerOnScreen = projectedCorners[i].onScreen 88 | end 89 | end 90 | 91 | if not isProjectedCornerOnScreen then 92 | return nil 93 | end 94 | 95 | -- Label size calculation is an approximation to avoid using TextService 96 | -- to measure a single-character string. 97 | local labelSize = props.TextSize + PADDING * 2 98 | 99 | return Roact.createElement("ScreenGui", {}, { 100 | Roact.createElement("TextLabel", { 101 | BackgroundTransparency = props.BackgroundTransparency, 102 | Position = UDim2.fromOffset(position.X, position.Y), 103 | Size = UDim2.fromOffset(labelSize, labelSize), 104 | Font = props.Font, 105 | TextSize = props.TextSize, 106 | Text = "L", 107 | TextColor3 = props.TextColor3, 108 | TextStrokeColor3 = props.TextStrokeColor3, 109 | TextStrokeTransparency = props.TextStrokeTransparency, 110 | Selectable = false, 111 | }) 112 | }) 113 | end 114 | 115 | return LocalSpaceIndicator 116 | -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/DraggingParts.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | local ChangeHistoryService = game:GetService("ChangeHistoryService") 3 | 4 | local DraggerFramework = script.Parent.Parent.Parent 5 | 6 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 7 | local DragHelper = require(DraggerFramework.Utility.DragHelper) 8 | local PartMover = require(DraggerFramework.Utility.PartMover) 9 | local AttachmentMover = require(DraggerFramework.Utility.AttachmentMover) 10 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 11 | 12 | local DraggingParts = {} 13 | DraggingParts.__index = DraggingParts 14 | 15 | function DraggingParts.new(draggerToolModel, dragInfo) 16 | local t = tick() 17 | draggerToolModel._boundsChangedTracker:uninstall() 18 | local self = setmetatable({ 19 | _draggerToolModel = draggerToolModel, 20 | _freeformDragger = draggerToolModel:getSchema().FreeformDragger.new( 21 | draggerToolModel._draggerContext, draggerToolModel, dragInfo) 22 | }, DraggingParts) 23 | local timeToStartDrag = tick() - t 24 | draggerToolModel:_analyticsRecordFreeformDragBegin(timeToStartDrag) 25 | return self 26 | end 27 | 28 | function DraggingParts:enter() 29 | self:_updateFreeformSelectionDrag() 30 | end 31 | 32 | function DraggingParts:leave() 33 | end 34 | 35 | function DraggingParts:_initIgnoreList(parts) 36 | local filter = table.create(#parts + 1) 37 | for i, part in ipairs(parts) do 38 | filter[i] = part 39 | end 40 | table.insert(filter, self._partMover:getIgnorePart()) 41 | self._raycastFilter = filter 42 | end 43 | 44 | function DraggingParts:render() 45 | self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) 46 | 47 | return self._freeformDragger:render() 48 | end 49 | 50 | function DraggingParts:processSelectionChanged() 51 | if self._alreadyEndingDrag then 52 | return 53 | end 54 | -- If something unexpectedly changes the selection out from underneath us, 55 | -- bail out of the drag. 56 | self:_endFreeformSelectionDrag() 57 | end 58 | 59 | function DraggingParts:processMouseDown() 60 | error("Mouse should already be down while dragging parts.") 61 | end 62 | 63 | function DraggingParts:processViewChanged() 64 | self:_updateFreeformSelectionDrag() 65 | end 66 | 67 | function DraggingParts:processMouseUp() 68 | self:_endFreeformSelectionDrag() 69 | end 70 | 71 | function DraggingParts:processKeyDown(keyCode) 72 | if keyCode == Enum.KeyCode.R then 73 | self._draggerToolModel._sessionAnalytics.dragRotates = self._draggerToolModel._sessionAnalytics.dragRotates + 1 74 | self:_tiltRotateFreeformSelectionDrag(Vector3.new(0, 1, 0)) 75 | elseif keyCode == Enum.KeyCode.T then 76 | self._draggerToolModel._sessionAnalytics.dragTilts = self._draggerToolModel._sessionAnalytics.dragTilts + 1 77 | self:_tiltRotateFreeformSelectionDrag(Vector3.new(1, 0, 0)) 78 | end 79 | end 80 | 81 | function DraggingParts:processKeyUp(keyCode) 82 | end 83 | 84 | function DraggingParts:_tiltRotateFreeformSelectionDrag(axis) 85 | self._freeformDragger:rotate(axis) 86 | 87 | self:_updateFreeformSelectionDrag() 88 | self._draggerToolModel:_scheduleRender() 89 | end 90 | 91 | function DraggingParts:_updateFreeformSelectionDrag() 92 | self._freeformDragger:update() 93 | end 94 | 95 | --[[ 96 | Refresh selection info to reflect the new CFrames of the dragged parts 97 | and return to the Ready state. 98 | ]] 99 | function DraggingParts:_endFreeformSelectionDrag() 100 | self._alreadyEndingDrag = true 101 | local newSelectionInfoHint = self._freeformDragger:destroy() 102 | 103 | self._draggerToolModel._boundsChangedTracker:install() 104 | 105 | self._draggerToolModel:_updateSelectionInfo(newSelectionInfoHint) 106 | 107 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 108 | 109 | self._draggerToolModel:getSchema().addUndoWaypoint( 110 | self._draggerToolModel._draggerContext, 111 | "End Freeform Drag") 112 | self._draggerToolModel:getSchema().setActivePoint( 113 | self._draggerToolModel._draggerContext, 114 | self._draggerToolModel._selectionInfo) 115 | self._alreadyEndingDrag = false 116 | end 117 | 118 | return DraggingParts -------------------------------------------------------------------------------- /src/Components/ScaleHandleView.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Component that displays a spherical scale handle. 3 | ]] 4 | 5 | local Workspace = game:GetService("Workspace") 6 | 7 | local DraggerFramework = script.Parent.Parent 8 | local Packages = DraggerFramework.Parent 9 | local Roact = require(Packages.Roact) 10 | 11 | -- Dragger Framework 12 | local Math = require(DraggerFramework.Utility.Math) 13 | 14 | local CULLING_MODE = Enum.AdornCullingMode.Never 15 | 16 | local ScaleHandleView = Roact.PureComponent:extend("ScaleHandleView") 17 | 18 | local HANDLE_RADIUS = 0.5 19 | local HANDLE_RADIUS_HOVERED_SCALE = 1.15 -- Radius scale when handle is hovered 20 | local HANDLE_HITTEST_RADIUS_SCALE = 2.5 -- Radius scale for hit testing 21 | 22 | local HANDLE_TRANSPARENCY_START = 0.75 23 | local HANDLE_TRANSPARENCY_END = 0.2 24 | local HANDLE_OFFSET = 1.5 25 | 26 | local HANDLE_THIN_BY_FRAC = 0.34 27 | 28 | local function getDebugSettingValue(name, defaultValue) 29 | local setting = Workspace:FindFirstChild(name) 30 | return setting and setting.Value * defaultValue or defaultValue 31 | end 32 | 33 | function ScaleHandleView:render() 34 | -- DEBUG: Allow designers to play with handle settings. 35 | -- Remove before shipping! 36 | HANDLE_OFFSET = getDebugSettingValue("ScaleHandleOffset", 1.5) 37 | HANDLE_RADIUS = getDebugSettingValue("ScaleHandleRadius", 0.5) 38 | HANDLE_TRANSPARENCY_START = getDebugSettingValue("ScaleHandleTransparencyStart", 0.75) 39 | HANDLE_TRANSPARENCY_END = getDebugSettingValue("ScaleHandleTransparencyEnd", 0.2) 40 | 41 | local children = {} 42 | 43 | local color = self.props.Color 44 | local cframe = self.props.HandleCFrame * CFrame.new(0, 0, -HANDLE_OFFSET * self.props.Scale) 45 | local radius = HANDLE_RADIUS * self.props.Scale 46 | 47 | if self.props.Thin then 48 | radius = radius * HANDLE_THIN_BY_FRAC 49 | end 50 | 51 | if not self.props.Hovered then 52 | children.HiddenHandle = Roact.createElement("SphereHandleAdornment", { 53 | Adornee = Workspace.Terrain, 54 | AlwaysOnTop = true, 55 | CFrame = cframe, 56 | Color3 = color, 57 | Radius = radius, 58 | Transparency = HANDLE_TRANSPARENCY_START, 59 | ZIndex = 1, 60 | AdornCullingMode = CULLING_MODE, 61 | }) 62 | end 63 | 64 | local transparencyEnd = HANDLE_TRANSPARENCY_END 65 | 66 | if self.props.Hovered then 67 | radius = radius * HANDLE_RADIUS_HOVERED_SCALE 68 | transparencyEnd = 0 69 | end 70 | 71 | children.Handle = Roact.createElement("SphereHandleAdornment", { 72 | Adornee = Workspace.Terrain, 73 | AlwaysOnTop = self.props.Hovered, 74 | CFrame = cframe, 75 | Color3 = color, 76 | Radius = radius, 77 | Transparency = transparencyEnd, 78 | ZIndex = 0, 79 | AdornCullingMode = CULLING_MODE, 80 | }) 81 | 82 | return Roact.createElement("Folder", {}, children) 83 | end 84 | 85 | --[[ 86 | Check if the mouse is over the scale handle. 87 | DON'T include the hitTest radius. We will deal with the scale handles by 88 | first checking if we hit any of them, and taking that one if we do. 89 | If we don't actually hit any, then we see if we're within the hitTest radius 90 | for any of them, and if we are take the closest one. 91 | ]] 92 | function ScaleHandleView.hitTest(props, mouseRay) 93 | local radius = HANDLE_RADIUS * props.Scale 94 | 95 | local unitRay = mouseRay.Unit 96 | local worldPosition = props.HandleCFrame * Vector3.new(0, 0, -HANDLE_OFFSET * props.Scale) 97 | local result, t = Math.intersectRaySphere(unitRay.Origin, unitRay.Direction, worldPosition, radius) 98 | 99 | if result then 100 | return t 101 | else 102 | return nil 103 | end 104 | end 105 | 106 | function ScaleHandleView.distanceFromHandle(props, mouseRay) 107 | local hitTestRadius = HANDLE_RADIUS * props.Scale * HANDLE_HITTEST_RADIUS_SCALE 108 | local worldPosition = props.HandleCFrame * Vector3.new(0, 0, -HANDLE_OFFSET * props.Scale) 109 | 110 | local rayDir = mouseRay.Direction.Unit 111 | local projectedLength = (worldPosition - mouseRay.Origin):Dot(rayDir) 112 | local projectedPoint = mouseRay.Origin + rayDir * projectedLength 113 | local distanceToRay = (worldPosition - projectedPoint).Magnitude 114 | return distanceToRay - hitTestRadius 115 | end 116 | 117 | return ScaleHandleView 118 | -------------------------------------------------------------------------------- /src/Utility/SelectionHelper.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Provides utility functions related to the selection. 3 | ]] 4 | 5 | -- Services 6 | local Workspace = game:GetService("Workspace") 7 | 8 | local DraggerFramework = script.Parent.Parent 9 | local shouldDragAsFace = require(DraggerFramework.Utility.shouldDragAsFace) 10 | 11 | local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) 12 | 13 | local SelectionHelper = {} 14 | 15 | -- Returns: Did the selection change, The new selection, A change hint 16 | function SelectionHelper.updateSelection(selectable, oldSelection, isExclusive, shouldExtendSelection) 17 | local doExtendSelection = shouldExtendSelection 18 | 19 | if not selectable then 20 | if doExtendSelection then 21 | return false, oldSelection 22 | else 23 | local wasOldSelectionNonempty = (#oldSelection > 0) 24 | return wasOldSelectionNonempty, {} 25 | end 26 | end 27 | 28 | if doExtendSelection and not (getEngineFeatureModelPivotVisual() and isExclusive) then 29 | -- Add or remove from the selection when ctrl or shift is held. 30 | local newSelection = {} 31 | local added, removed = {}, {} 32 | local didRemoveSelectableInstance = false 33 | for _, item in ipairs(oldSelection) do 34 | if item == selectable then 35 | didRemoveSelectableInstance = true 36 | else 37 | table.insert(newSelection, item) 38 | end 39 | end 40 | if didRemoveSelectableInstance then 41 | table.insert(removed, selectable) 42 | else 43 | table.insert(newSelection, selectable) 44 | table.insert(added, selectable) 45 | end 46 | return true, newSelection, {Added = added, Removed = removed} 47 | else 48 | local index = table.find(oldSelection, selectable) 49 | if index and not isExclusive then 50 | -- The instance is already in the selection. If the active instance 51 | -- needs to be updated, and the instance isn't already the last item 52 | -- in the list, move it to the end of the selection. 53 | local lastIndex = #oldSelection 54 | if index < lastIndex then 55 | local newSelection = {} 56 | table.move(oldSelection, 1, index, 1, newSelection) 57 | table.move(oldSelection, index + 1, lastIndex, index, newSelection) 58 | newSelection[lastIndex] = selectable 59 | 60 | -- Remove and then add the selectable to push it to 61 | -- the end of the selection. 62 | local hint = {Added = {selectable}, Removed = {selectable}} 63 | return true, newSelection, hint 64 | end 65 | 66 | -- Otherwise, leave the selection alone. 67 | return false, oldSelection 68 | else 69 | -- The instance is not in the selection and the selection is not being 70 | -- extended; overwrite the old selection. 71 | return true, {selectable} 72 | end 73 | end 74 | end 75 | 76 | function SelectionHelper.updateSelectionWithMultipleSelectables( 77 | selectables, oldSelection, shouldXorSelection, shouldExtendSelection) 78 | 79 | if #selectables == 0 then 80 | return (shouldXorSelection or shouldExtendSelection) and oldSelection or {} 81 | end 82 | 83 | local newSelection 84 | if shouldXorSelection or shouldExtendSelection then 85 | newSelection = {} 86 | -- Add or remove from the selection when ctrl or shift is held. 87 | local alreadySelectedInstances = {} 88 | for _, instance in ipairs(oldSelection) do 89 | alreadySelectedInstances[instance] = true 90 | end 91 | 92 | if shouldXorSelection then 93 | local newInstancesToSelect = {} 94 | for _, instance in ipairs(selectables) do 95 | newInstancesToSelect[instance] = true 96 | end 97 | for _, selectable in ipairs(oldSelection) do 98 | if not newInstancesToSelect[selectable] then 99 | table.insert(newSelection, selectable) 100 | end 101 | end 102 | for _, selectable in ipairs(selectables) do 103 | if not alreadySelectedInstances[selectable] then 104 | table.insert(newSelection, selectable) 105 | end 106 | end 107 | elseif shouldExtendSelection then 108 | for _, selectable in ipairs(oldSelection) do 109 | table.insert(newSelection, selectable) 110 | end 111 | for _, selectable in ipairs(selectables) do 112 | if not alreadySelectedInstances[selectable] then 113 | table.insert(newSelection, selectable) 114 | end 115 | end 116 | end 117 | else 118 | -- The selection is not being extended; overwrite the old selection. 119 | newSelection = selectables 120 | end 121 | return newSelection 122 | end 123 | 124 | return SelectionHelper 125 | -------------------------------------------------------------------------------- /src/Utility/Math.lua: -------------------------------------------------------------------------------- 1 | local Math = {} 2 | 3 | -- Insersect Ray(a + t*b) with plane (origin: o, normal: n), return t of the interesection 4 | function Math.intersectRayPlane(a, b, o, n) 5 | return (o - a):Dot(n) / b:Dot(n) 6 | end 7 | 8 | -- Intersect Ray(a + t*b) with plane (origin: o, normal :n), and return the intersection as Vector3 9 | function Math.intersectRayPlanePoint(a, b, o, n) 10 | local t = Math.intersectRayPlane(a, b, o, n) 11 | return a + t * b; 12 | end 13 | 14 | --[[ 15 | The return value `t` is a number such that `r1o + t * r1d` is the point of 16 | closest approach on the first ray between the two rays specified by the 17 | arguments. 18 | ]] 19 | function Math.intersectRayRay(r1o, r1d, r2o, r2d) 20 | local n = 21 | (r2o - r1o):Dot(r1d) * r2d:Dot(r2d) + 22 | (r1o - r2o):Dot(r2d) * r1d:Dot(r2d) 23 | local d = 24 | r1d:Dot(r1d) * r2d:Dot(r2d) - 25 | r1d:Dot(r2d) * r1d:Dot(r2d) 26 | if d == 0 then 27 | return false 28 | else 29 | return true, n / d 30 | end 31 | end 32 | 33 | --[[ 34 | Returns the point of closest approach on the first ray between the two rays 35 | specified by the arguments. 36 | ]] 37 | function Math.intersectRayRayPoint(r1o, r1d, r2o, r2d) 38 | local r1du = r1d.Unit 39 | local r2du = r2d.Unit 40 | local b, t = Math.intersectRayRay(r1o, r1du, r2o, r2du) 41 | return r1o + t * r1du 42 | end 43 | 44 | --[[ 45 | Intersect a ray (origin + t * direction) 46 | with 47 | A cylinder located at the origin with its axis aligned in the +x direction 48 | having a radius and a height 49 | Return t 50 | ]] 51 | function Math.intersectRayCylinder(origin, direction, radius, height) 52 | local p0 = origin 53 | 54 | local a = direction.Y * direction.Y + direction.Z * direction.Z 55 | local b = direction.Y * p0.Y + direction.Z * p0.Z 56 | local c = p0.Y * p0.Y + p0.Z * p0.Z - radius * radius 57 | 58 | local delta = b * b - a * c; 59 | if delta < 0 then 60 | return false 61 | end 62 | 63 | local t1 = (-b - math.sqrt(delta)) / a 64 | local x1 = p0.X + t1 * direction.X 65 | if math.abs(x1) <= 0.5 * height then 66 | return true, t1 67 | end 68 | 69 | local t2 = (-b + math.sqrt(delta)) / a 70 | local x2 = p0.X + t2 * direction.X 71 | if math.abs(x2) <= 0.5 * height then 72 | return true, t2 73 | end 74 | 75 | return false 76 | end 77 | 78 | --[[ 79 | Returns the closest point of intersection along a ray with a sphere. 80 | 81 | For line-sphere intersection, there are three possible results: no intersection, 82 | one point intersection (tangent), and two point intersection. Since a ray has an 83 | origin and direction, we only need to consider the smaller (and positive) of the 84 | two intersections. 85 | ]] 86 | function Math.intersectRaySphere(rayOrigin, rayDirection, sphereOrigin, radius) 87 | local oc = rayOrigin - sphereOrigin 88 | local a = rayDirection:Dot(rayDirection) 89 | local b = 2 * oc:Dot(rayDirection) 90 | local c = oc:Dot(oc) - radius * radius 91 | local discriminant = b * b - 4 * a * c 92 | if discriminant >= 0 then 93 | local numerator = -b - math.sqrt(discriminant) 94 | if numerator > 0 then 95 | return true, numerator / 2 * a 96 | end 97 | end 98 | return false 99 | end 100 | 101 | function Math.regionFromParts(parts) 102 | local minX, minY, minZ = math.huge, math.huge, math.huge 103 | local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge 104 | for _, part in ipairs(parts) do 105 | local min, max 106 | if part:IsA("BasePart") then 107 | min = part.CFrame.Position - part.Size 108 | max = part.CFrame.Position + part.Size 109 | elseif part:IsA("Model") then 110 | local orientation, size = part:GetBoundingBox() 111 | min = orientation - size 112 | max = orientation + size 113 | end 114 | if min ~= nil and max ~= nil then 115 | minX = math.min(minX, min.X) 116 | minY = math.min(minY, min.Y) 117 | minZ = math.min(minZ, min.Z) 118 | maxX = math.max(maxX, max.X) 119 | maxY = math.max(maxY, max.Y) 120 | maxZ = math.max(maxZ, max.Z) 121 | end 122 | end 123 | return Region3.new(Vector3.new(minX, minY, minZ), Vector3.new(maxX, maxY, maxZ)) 124 | end 125 | 126 | 127 | -- Convert a subset of {X: bool, Y: bool, Z: bool} to a vector with corresponding axes set to 1 128 | function Math.setToVector3(set) 129 | return set and Vector3.new(not set.X and 0 or 1, not set.Y and 0 or 1, not set.Z and 0 or 1) 130 | or Vector3.new(0, 0, 0) 131 | end 132 | 133 | -- Convert vector into array so that its components could be indexed 134 | function Math.vectorToArray(vec) 135 | return {vec.X, vec.Y, vec.Z} 136 | end 137 | 138 | -- Largest component of a vector 139 | function Math.maxComponent(vec) 140 | return math.max(vec.X, vec.Y, vec.Z) 141 | end 142 | 143 | -- Smallest component of a vector 144 | function Math.minComponent(vec) 145 | return math.min(vec.X, vec.Y, vec.Z) 146 | end 147 | 148 | return Math 149 | -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/DraggingHandle.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent.Parent 2 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 3 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 4 | 5 | local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) 6 | local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) 7 | 8 | local DraggingHandle = {} 9 | DraggingHandle.__index = DraggingHandle 10 | 11 | function DraggingHandle.new(draggerToolModel, draggingHandles, draggingHandleId) 12 | local self = setmetatable({ 13 | _draggerToolModel = draggerToolModel, 14 | }, DraggingHandle) 15 | self:_init(draggingHandles, draggingHandleId) 16 | return self 17 | end 18 | 19 | function DraggingHandle:enter() 20 | end 21 | 22 | function DraggingHandle:leave() 23 | end 24 | 25 | function DraggingHandle:_init(draggingHandles, draggingHandleId) 26 | assert(draggingHandleId, "Missing draggingHandleId") 27 | 28 | self._draggerToolModel._sessionAnalytics.handleDrags = self._draggerToolModel._sessionAnalytics.handleDrags + 1 29 | self._draggerToolModel._boundsChangedTracker:uninstall() 30 | draggingHandles:mouseDown(self._draggerToolModel._draggerContext:getMouseRay(), draggingHandleId) 31 | self._draggingHandleId = draggingHandleId 32 | self._draggingHandles = draggingHandles 33 | end 34 | 35 | function DraggingHandle:render() 36 | self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) 37 | 38 | return self._draggingHandles:render(self._draggingHandleId) 39 | end 40 | 41 | function DraggingHandle:processSelectionChanged() 42 | if self._alreadyEndingDrag then 43 | return 44 | end 45 | -- Re-init the drag if the selection changes. 46 | local invokedBySelectionChange = true 47 | self:_endHandleDrag(invokedBySelectionChange) 48 | self:_init(self._draggingHandles, self._draggingHandleId) 49 | end 50 | 51 | function DraggingHandle:processMouseDown() 52 | error("Mouse should already be down while dragging handle.") 53 | end 54 | 55 | function DraggingHandle:processViewChanged() 56 | self._draggingHandles:mouseDrag( 57 | self._draggerToolModel._draggerContext:getMouseRay()) 58 | end 59 | 60 | function DraggingHandle:processMouseUp() 61 | local invokedBySelectionChange = false 62 | self:_endHandleDrag(invokedBySelectionChange) 63 | self._draggerToolModel:transitionToState(DraggerStateType.Ready) 64 | end 65 | 66 | function DraggingHandle:processKeyDown(keyCode) 67 | if getFFlagSummonPivot() then 68 | for _, handles in pairs(self._draggerToolModel:getHandlesList()) do 69 | if handles.keyDown then 70 | if handles:keyDown(keyCode) then 71 | self:processViewChanged() 72 | self._draggerToolModel:_scheduleRender() 73 | end 74 | end 75 | end 76 | else 77 | if self._draggingHandles.keyDown then 78 | if self._draggingHandles:keyDown(keyCode) then 79 | -- Update the drag 80 | self:processViewChanged() 81 | if getEngineFeatureModelPivotVisual() then 82 | self._draggerToolModel:_scheduleRender() 83 | end 84 | end 85 | end 86 | end 87 | end 88 | 89 | function DraggingHandle:processKeyUp(keyCode) 90 | if getFFlagSummonPivot() then 91 | for _, handles in pairs(self._draggerToolModel:getHandlesList()) do 92 | if handles.keyUp then 93 | if handles:keyUp(keyCode) then 94 | self:processViewChanged() 95 | self._draggerToolModel:_scheduleRender() 96 | end 97 | end 98 | end 99 | else 100 | if self._draggingHandles.keyUp then 101 | if self._draggingHandles:keyUp(keyCode) then 102 | -- Update the drag 103 | self:processViewChanged() 104 | if getEngineFeatureModelPivotVisual() then 105 | self._draggerToolModel:_scheduleRender() 106 | end 107 | end 108 | end 109 | end 110 | end 111 | 112 | function DraggingHandle:_endHandleDrag(invokedBySelectionChange: boolean) 113 | self._alreadyEndingDrag = true 114 | -- Commit the results of using the tool 115 | 116 | local newSelectionInfoHint = self._draggingHandles:mouseUp( 117 | self._draggerToolModel._draggerContext:getMouseRay()) 118 | if invokedBySelectionChange then 119 | -- If the drag was ended by a selection change, then our computed 120 | -- selection info hint will be stale, because it applies to the last 121 | -- selection, rather than the new selection. So we don't use it. 122 | self._draggerToolModel:_updateSelectionInfo(nil) 123 | else 124 | -- Use the Implementation's modification to the SelectionInfo hint 125 | self._draggerToolModel:_updateSelectionInfo(newSelectionInfoHint) 126 | end 127 | 128 | self._draggerToolModel._boundsChangedTracker:install() 129 | 130 | self._draggerToolModel:getSchema().setActivePoint( 131 | self._draggerToolModel._draggerContext, 132 | self._draggerToolModel._selectionInfo) 133 | 134 | self._draggerToolModel:_analyticsSendHandleDragged(self._draggingHandleId) 135 | self._alreadyEndingDrag = false 136 | end 137 | 138 | return DraggingHandle -------------------------------------------------------------------------------- /src/Implementation/HoverTracker.lua: -------------------------------------------------------------------------------- 1 | 2 | local Workspace = game:GetService("Workspace") 3 | 4 | local DraggerFramework = script.Parent.Parent 5 | local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) 6 | 7 | --[[ 8 | Ignored handle hits: When the ToolImplementation's shouldBiasTowardsObject 9 | function returns true, then this function will decide whether to ignore 10 | handle clicks where the user clicked outside of the handle's visual, but 11 | still within the handle's invisible hitbox. 12 | ]] 13 | local function isIgnoredHandleHit(handles, mouseRay, selectionInfo, hitItem) 14 | if not handles:shouldBiasTowardsObjects() then 15 | -- Only potentially ignores if we're biased towards parts 16 | return false 17 | end 18 | 19 | if not hitItem or not selectionInfo:doesContainItem(hitItem) then 20 | -- Only bias towards parts when clicking on something in the selection 21 | return false 22 | end 23 | 24 | -- Ignore the hit if when ignoring the extra threshold the handle is no 25 | -- longer hit. 26 | local ignoreExtraThreshold = true 27 | return handles:hitTest(mouseRay, ignoreExtraThreshold) == nil 28 | end 29 | 30 | local HoverTracker = {} 31 | HoverTracker.__index = HoverTracker 32 | 33 | function HoverTracker.new(draggerSchema, handlesList, onHoverExternallyChangedFunction) 34 | assert(type(handlesList) == "table") 35 | assert(type(draggerSchema) == "table") 36 | return setmetatable({ 37 | _draggerSchema = draggerSchema, 38 | _handlesList = handlesList, 39 | _hoverHandleId = nil, 40 | _hoverItem = nil, 41 | _onHoverChanged = onHoverExternallyChangedFunction, 42 | }, HoverTracker) 43 | end 44 | 45 | local function isCloser(distance, isOnTop, currentDistance, currentIsOnTop) 46 | if currentIsOnTop then -- Neat logic 47 | return isOnTop and distance < currentDistance 48 | else 49 | return isOnTop or distance < currentDistance 50 | end 51 | end 52 | 53 | function HoverTracker:update(draggerContext, currentSelection, selectionInfo) 54 | assert(currentSelection ~= nil) 55 | local oldHoverSelectable = self._hoverSelectable 56 | 57 | -- Hover parts in the workspace 58 | local mouseRay = draggerContext:getMouseRay() 59 | local hitSelectable, hitItem, distanceToHover = 60 | self._draggerSchema.getMouseTarget(draggerContext, mouseRay, currentSelection) 61 | self._hoverItem = hitItem 62 | self._hoverSelectable = hitSelectable 63 | self._hoverHandleId = nil 64 | if hitSelectable ~= nil then 65 | self._hoverDistance = distanceToHover 66 | self._hoverPosition = mouseRay.Origin + mouseRay.Direction.Unit * distanceToHover 67 | else 68 | distanceToHover = math.huge 69 | self._hoverDistance = math.huge 70 | self._hoverPosition = nil 71 | end 72 | 73 | self._hoverHandles = nil 74 | local currentIsOnTop = false 75 | for _, handles in pairs(self._handlesList) do 76 | -- Possibly hover a handle instead if we have a handle closer than the part 77 | -- and the hit wasn't ignored by bias towards hovering parts. 78 | local hoverHandleId, hoverHandleDistance, isOnTop = handles:hitTest(mouseRay, false) 79 | if hoverHandleId then 80 | if isCloser(hoverHandleDistance, isOnTop, distanceToHover, currentIsOnTop) and 81 | not isIgnoredHandleHit(handles, mouseRay, selectionInfo, hitItem) then 82 | self._hoverHandles = handles 83 | self._hoverHandleId = hoverHandleId 84 | self._hoverDistance = hoverHandleDistance 85 | self._hoverPosition = nil 86 | distanceToHover = hoverHandleDistance 87 | currentIsOnTop = isOnTop 88 | end 89 | end 90 | end 91 | 92 | if self._hoverHandles then 93 | self._draggerSchema.setHover(draggerContext, nil, nil) 94 | else 95 | self._draggerSchema.setHover(draggerContext, self._hoverSelectable, self._hoverItem) 96 | end 97 | 98 | if self._onHoverChanged and self._hoverSelectable ~= oldHoverSelectable then 99 | self:_freeHoverEscapeDetector() 100 | if self._hoverSelectable then 101 | self._hoverEscapeDetector = 102 | self._draggerSchema.HoverEscapeDetector.new( 103 | draggerContext, self._hoverSelectable, self._onHoverChanged) 104 | end 105 | end 106 | end 107 | 108 | function HoverTracker:_freeHoverEscapeDetector() 109 | if self._hoverEscapeDetector then 110 | self._hoverEscapeDetector:destroy() 111 | self._hoverEscapeDetector = nil 112 | end 113 | end 114 | 115 | function HoverTracker:clearHover(draggerContext) 116 | self:_freeHoverEscapeDetector() 117 | self._hoverItem = nil 118 | self._hoverSelectable = nil 119 | self._hoverPosition = nil 120 | self._hoverHandles = nil 121 | self._hoverHandleId = nil 122 | self._hoverDistance = nil 123 | self._draggerSchema.setHover(draggerContext, nil, nil) 124 | end 125 | 126 | --[[ 127 | Returns: The Id of the hovered handle, the distance to that handle 128 | ]] 129 | function HoverTracker:getHoverHandleId() 130 | return self._hoverHandles, self._hoverHandleId, self._hoverDistance 131 | end 132 | 133 | --[[ 134 | Returns: The hovered instance, and the world position of the hit on it 135 | ]] 136 | function HoverTracker:getHoverItem() 137 | return self._hoverItem, self._hoverPosition 138 | end 139 | 140 | function HoverTracker:getHoverSelectable() 141 | return self._hoverSelectable 142 | end 143 | 144 | return HoverTracker -------------------------------------------------------------------------------- /src/Utility/DragSelector.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | 3 | local DraggerFramework = script.Parent.Parent 4 | 5 | local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) 6 | 7 | local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) 8 | 9 | -- Minimum distance (pixels) required for a drag to select parts. 10 | local DRAG_SELECTION_THRESHOLD = 3 11 | 12 | local DragSelector = {} 13 | DragSelector.__index = DragSelector 14 | 15 | function DragSelector.new(selectionWrapper, beginBoxSelect, endBoxSelect) 16 | assert(selectionWrapper ~= nil) 17 | assert(beginBoxSelect ~= nil) 18 | assert(endBoxSelect ~= nil) 19 | local self = { 20 | _isDragging = false, 21 | _selectionBeforeDrag = {}, 22 | _dragStartLocation = nil, 23 | _dragCandidates = {}, 24 | _selectionWrapper = selectionWrapper, 25 | _beginBoxSelect = beginBoxSelect, 26 | _endBoxSelect = endBoxSelect, 27 | _insertionOrder = {}, 28 | _insertionOrderNext = 1, 29 | } 30 | 31 | return setmetatable(self, DragSelector) 32 | end 33 | 34 | -- Create a frustum described by the selection start and end locations and current camera. 35 | local function getSelectionFrustum(draggerContext, startLocation, endLocation) 36 | local rect = Rect.new(startLocation, endLocation) 37 | 38 | local topLeft = draggerContext:viewportPointToRay(Vector2.new(rect.Min.X, rect.Min.Y)) 39 | local topRight = draggerContext:viewportPointToRay(Vector2.new(rect.Max.X, rect.Min.Y)) 40 | local bottomRight = draggerContext:viewportPointToRay(Vector2.new(rect.Max.X, rect.Max.Y)) 41 | local bottomLeft = draggerContext:viewportPointToRay(Vector2.new(rect.Min.X, rect.Max.Y)) 42 | 43 | if topRight.Direction:FuzzyEq(topLeft.Direction) then 44 | -- Ortho view 45 | local top = (topRight.Origin - topLeft.Origin):Cross(topRight.Direction) 46 | local right = (bottomRight.Origin - topRight.Origin):Cross(bottomRight.Direction) 47 | local bottom = (bottomLeft.Origin - bottomRight.Origin):Cross(bottomLeft.Direction) 48 | local left = (topLeft.Origin - bottomLeft.Origin):Cross(topLeft.Direction) 49 | 50 | return { 51 | {origin = topLeft.Origin, normal = top}, 52 | {origin = topRight.Origin, normal = right}, 53 | {origin = bottomRight.Origin, normal = bottom}, 54 | {origin = bottomLeft.Origin, normal = left} 55 | } 56 | else 57 | -- Perspective view 58 | local left = bottomLeft.Direction:Cross(topLeft.Direction) 59 | local top = topLeft.Direction:Cross(topRight.Direction) 60 | local right = topRight.Direction:Cross(bottomRight.Direction) 61 | local bottom = bottomRight.Direction:Cross(bottomLeft.Direction) 62 | 63 | return { 64 | {origin = topLeft.Origin, normal = top}, 65 | {origin = topRight.Origin, normal = right}, 66 | {origin = bottomRight.Origin, normal = bottom}, 67 | {origin = bottomLeft.Origin, normal = left} 68 | } 69 | end 70 | end 71 | 72 | function DragSelector:getStartLocation() 73 | return self._dragStartLocation 74 | end 75 | 76 | -- Get list of drag candidates from all selectable parts in the workspace. 77 | -- startLocation can override the location the drag is treated as having 78 | -- started at. 79 | function DragSelector:beginDrag(draggerContext, startLocation) 80 | assert(not self._isDragging, "Cannot begin drag when already dragging.") 81 | self._isDragging = true 82 | 83 | self._dragCandidates = self._beginBoxSelect(draggerContext) 84 | self._selectionBeforeDrag = self._selectionWrapper:get() 85 | self._dragStartLocation = startLocation or draggerContext:getMouseLocation() 86 | end 87 | 88 | --[[ 89 | Test selectable parts against the frustum defined by the drag start location 90 | and passed in location. Parts within the frustum are added or removed from 91 | the selection, based on the held modified keys. 92 | ]] 93 | function DragSelector:updateDrag(draggerContext) 94 | assert(self._isDragging, "Cannot update drag when no drag in progress.") 95 | 96 | local shouldXorSelection = draggerContext:shouldExtendSelection() 97 | local shouldDrillSelection = draggerContext:isAltKeyDown() 98 | local location = draggerContext:getMouseLocation() 99 | 100 | local screenMovement = location - self._dragStartLocation 101 | if screenMovement.Magnitude < DRAG_SELECTION_THRESHOLD then 102 | return 103 | end 104 | 105 | local planes = getSelectionFrustum(draggerContext, 106 | self._dragStartLocation, location) 107 | if not planes then 108 | return 109 | end 110 | 111 | local newSelection = {} 112 | local didChangeSelection = false 113 | local insertionOrder = self._insertionOrder 114 | for _, candidate in ipairs(self._dragCandidates) do 115 | local inside = true 116 | for _, plane in ipairs(planes) do 117 | local dot = (candidate.Center - plane.origin):Dot(plane.normal) 118 | if dot < 0 then 119 | inside = false 120 | break 121 | end 122 | end 123 | if inside ~= candidate.Selected then 124 | candidate.Selected = inside 125 | didChangeSelection = true 126 | if getEngineFeatureModelPivotVisual() and inside then 127 | insertionOrder[candidate.Selectable] = self._insertionOrderNext 128 | self._insertionOrderNext += 1 129 | end 130 | end 131 | if inside then 132 | table.insert(newSelection, candidate.Selectable) 133 | end 134 | end 135 | 136 | if didChangeSelection then 137 | if getEngineFeatureModelPivotVisual() then 138 | table.sort(newSelection, function(a, b) 139 | return (insertionOrder[a] or 0) < (insertionOrder[b] or 0) 140 | end) 141 | end 142 | 143 | newSelection = SelectionHelper.updateSelectionWithMultipleSelectables( 144 | newSelection, self._selectionBeforeDrag, 145 | shouldXorSelection) 146 | self._selectionWrapper:set(newSelection) 147 | end 148 | end 149 | 150 | function DragSelector:commitDrag(draggerContext) 151 | self:updateDrag(draggerContext) 152 | 153 | self._endBoxSelect(draggerContext) 154 | 155 | self._selectionBeforeDrag = {} 156 | self._dragStartLocation = nil 157 | self._isDragging = false 158 | end 159 | 160 | return DragSelector 161 | -------------------------------------------------------------------------------- /src/DraggerTools/DraggerToolComponent.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | DraggerToolComponent is a Roact component which drives an internal 3 | DraggerToolModel with inputs in a real-time situation such as ingame or in 4 | studio plugin. 5 | ]] 6 | 7 | -- Services 8 | local RunService = game:GetService("RunService") 9 | local UserInputService = game:GetService("UserInputService") 10 | local HttpService = game:GetService("HttpService") 11 | 12 | local DraggerFramework = script.Parent.Parent 13 | local Packages = DraggerFramework.Parent 14 | local Roact = require(Packages.Roact) 15 | 16 | -- Utilities 17 | local DraggerToolModel = require(DraggerFramework.Implementation.DraggerToolModel) 18 | local ViewChangeDetector = require(DraggerFramework.Utility.ViewChangeDetector) 19 | local shouldDragAsFace = require(DraggerFramework.Utility.shouldDragAsFace) 20 | 21 | local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) 22 | local getFFlagTemporaryPatchDraggerEvents = require(DraggerFramework.Flags.getFFlagTemporaryPatchDraggerEvents) 23 | 24 | -- Constants 25 | local DRAGGER_UPDATE_BIND_NAME = "DraggerToolViewUpdate" 26 | 27 | local DraggerToolComponent = Roact.PureComponent:extend("DraggerToolComponent") 28 | 29 | function DraggerToolComponent:init() 30 | self:setup(self.props) 31 | end 32 | 33 | function DraggerToolComponent:didMount() 34 | end 35 | 36 | function DraggerToolComponent:willUnmount() 37 | self:teardown() 38 | end 39 | 40 | function DraggerToolComponent:willUpdate(nextProps, nextState) 41 | if nextProps ~= self.props then 42 | self:teardown() 43 | self:setup(nextProps) 44 | end 45 | end 46 | 47 | function DraggerToolComponent:render() 48 | return self._draggerToolModel:render() 49 | end 50 | 51 | function DraggerToolComponent:setup(props) 52 | assert(props.DraggerContext) 53 | assert(props.DraggerSchema) 54 | assert(props.DraggerSettings) 55 | 56 | self._selectionBoundsAreDirty = false 57 | self._viewBoundsAreDirty = false 58 | 59 | self._bindName = DRAGGER_UPDATE_BIND_NAME 60 | local guid = HttpService:GenerateGUID(false) 61 | self._bindName = self._bindName .. guid 62 | 63 | local function requestRender() 64 | if self._isMounted then 65 | self:setState({}) -- Force a rerender 66 | end 67 | end 68 | 69 | self._draggerToolModel = 70 | DraggerToolModel.new( 71 | props.DraggerContext, 72 | props.DraggerSchema, 73 | props.DraggerSettings, 74 | requestRender, 75 | function() self._viewBoundsAreDirty = true end, 76 | function() self._selectionBoundsAreDirty = true end) 77 | 78 | -- Select it first before we potentially start feeding input to it 79 | self._draggerToolModel:_processSelected() 80 | 81 | -- This should never return true because we disconnect connections before 82 | -- tearing down our state. 83 | local function bailedAndWarnedHack(_reason) 84 | if self._isMounted then 85 | return false 86 | else 87 | return true 88 | end 89 | end 90 | 91 | local mouse = props.Mouse 92 | self._mouseDownConnection = mouse.Button1Down:Connect(function() 93 | if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("Button1Down") then 94 | return 95 | end 96 | self._draggerToolModel:_processMouseDown() 97 | end) 98 | self._mouseUpConnection = mouse.Button1Up:Connect(function() 99 | if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("Button1Up") then 100 | return 101 | end 102 | self._draggerToolModel:_processMouseUp() 103 | end) 104 | self._keyDownConnection = UserInputService.InputBegan:Connect(function(input, gameProcessedEvent) 105 | if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("KeyDown") then 106 | return 107 | end 108 | if input.UserInputType == Enum.UserInputType.Keyboard then 109 | self._draggerToolModel:_processKeyDown(input.KeyCode) 110 | end 111 | end) 112 | if getFFlagSummonPivot() then 113 | self._keyUpConnection = UserInputService.InputEnded:Connect(function(input, gameProcessedEvent) 114 | if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("KeyUp") then 115 | return 116 | end 117 | if input.UserInputType == Enum.UserInputType.Keyboard then 118 | self._draggerToolModel:_processKeyUp(input.KeyCode) 119 | end 120 | end) 121 | end 122 | 123 | local function dragEnterFunc(instances) 124 | if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("DragEnter") then 125 | return 126 | end 127 | if #instances > 0 then 128 | if #instances == 1 and shouldDragAsFace(instances[1]) then 129 | self._draggerToolModel:_processToolboxInitiatedFaceDrag(instances) 130 | else 131 | self._draggerToolModel:_processToolboxInitiatedFreeformSelectionDrag() 132 | end 133 | end 134 | end 135 | self._dragEnterConnection = mouse.DragEnter:Connect(dragEnterFunc) 136 | 137 | local viewChange = ViewChangeDetector.new(mouse) 138 | local lastUseLocalSpace = props.DraggerContext:shouldUseLocalSpace() 139 | RunService:BindToRenderStep(self._bindName, Enum.RenderPriority.First.Value, function() 140 | if not self._isMounted then 141 | return 142 | end 143 | 144 | self._draggerToolModel:update() 145 | 146 | local shouldUpdateView = false 147 | local shouldUpdateSelection = false 148 | 149 | if viewChange:poll() then 150 | shouldUpdateView = true 151 | end 152 | 153 | if self._selectionBoundsAreDirty then 154 | self._selectionBoundsAreDirty = false 155 | shouldUpdateSelection = true 156 | end 157 | if self._viewBoundsAreDirty then 158 | self._viewBoundsAreDirty = false 159 | shouldUpdateView = true 160 | end 161 | 162 | local currentUseLocalSpace = props.DraggerContext:shouldUseLocalSpace() 163 | if currentUseLocalSpace ~= lastUseLocalSpace then 164 | -- Can't use a changed event for this, since Changed doesn't fire 165 | -- for changes to UseLocalSpace. 166 | shouldUpdateSelection = true 167 | end 168 | 169 | if shouldUpdateSelection then 170 | self._draggerToolModel:_processSelectionChanged() 171 | end 172 | if shouldUpdateView then 173 | self._draggerToolModel:_processViewChanged() 174 | end 175 | 176 | lastUseLocalSpace = currentUseLocalSpace 177 | end) 178 | 179 | if props.InitialMouseDown then 180 | task.defer(function() 181 | if self._isMounted then 182 | self._draggerToolModel:_processMouseDown() 183 | end 184 | end) 185 | end 186 | 187 | self._isMounted = true 188 | end 189 | 190 | function DraggerToolComponent:teardown() 191 | self._isMounted = false 192 | 193 | self._mouseDownConnection:Disconnect() 194 | self._mouseDownConnection = nil 195 | 196 | self._mouseUpConnection:Disconnect() 197 | self._mouseUpConnection = nil 198 | 199 | self._keyDownConnection:Disconnect() 200 | self._keyDownConnection = nil 201 | 202 | if getFFlagSummonPivot() then 203 | self._keyUpConnection:Disconnect() 204 | self._keyUpConnection = nil 205 | end 206 | 207 | self._dragEnterConnection:Disconnect() 208 | self._dragEnterConnection = nil 209 | 210 | RunService:UnbindFromRenderStep(self._bindName) 211 | 212 | -- Deselect after we stop potentially sending events 213 | self._draggerToolModel:_processDeselected() 214 | end 215 | 216 | return DraggerToolComponent -------------------------------------------------------------------------------- /src/Utility/BoundingBox.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | 3 | local function getBoundingBoxInternal(cframe, size, inverseBasis) 4 | local localCFrame = inverseBasis and (inverseBasis * cframe) or cframe 5 | local sx, sy, sz = size.X, size.Y, size.Z 6 | 7 | local _, _, _, 8 | t00, t01, t02, 9 | t10, t11, t12, 10 | t20, t21, t22 = localCFrame:GetComponents() 11 | local hw = 0.5 * (math.abs(sx * t00) + math.abs(sy * t01) + math.abs(sz * t02)) 12 | local hh = 0.5 * (math.abs(sx * t10) + math.abs(sy * t11) + math.abs(sz * t12)) 13 | local hd = 0.5 * (math.abs(sx * t20) + math.abs(sy * t21) + math.abs(sz * t22)) 14 | local x, y, z = localCFrame.X, localCFrame.Y, localCFrame.Z 15 | 16 | local xmin = x - hw 17 | local xmax = x + hw 18 | local ymin = y - hh 19 | local ymax = y + hh 20 | local zmin = z - hd 21 | local zmax = z + hd 22 | 23 | return xmin, xmax, ymin, ymax, zmin, zmax 24 | end 25 | 26 | local BoundingBox = {} 27 | 28 | --[[ 29 | Returns the bounding box that contains the specified objects. 30 | 31 | The bounding box is computed in the given coordinate space, or world space if 32 | no local basis is provided. 33 | 34 | Params: 35 | table objects: An array of BaseParts, Models, and Attachments. 36 | CFrame basisCFrame: Optional local basis. 37 | 38 | Returns: 39 | Tuple (Vector3 offset, Vector3 size). 40 | ]] 41 | function BoundingBox.fromObjects(objects, basisCFrame) 42 | local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil 43 | local xmin, xmax = math.huge, -math.huge 44 | local ymin, ymax = math.huge, -math.huge 45 | local zmin, zmax = math.huge, -math.huge 46 | 47 | local terrain = Workspace.Terrain 48 | 49 | for _, object in ipairs(objects) do 50 | local isModel = object:IsA("Model") 51 | if isModel or (object:IsA("BasePart") and object ~= terrain) then 52 | local cframe, size 53 | if isModel then 54 | cframe, size = object:GetBoundingBox() 55 | else 56 | cframe = object.CFrame 57 | size = object.Size 58 | end 59 | 60 | local xmin1, xmax1, ymin1, ymax1, zmin1, zmax1 = getBoundingBoxInternal( 61 | cframe, size, inverseBasis) 62 | 63 | xmin = math.min(xmin, xmin1) 64 | xmax = math.max(xmax, xmax1) 65 | ymin = math.min(ymin, ymin1) 66 | ymax = math.max(ymax, ymax1) 67 | zmin = math.min(zmin, zmin1) 68 | zmax = math.max(zmax, zmax1) 69 | elseif object:IsA("Attachment") then 70 | local localPosition = basisCFrame:PointToObjectSpace(object.WorldPosition) 71 | local x, y, z = localPosition.X, localPosition.Y, localPosition.Z 72 | xmin = math.min(xmin, x) 73 | xmax = math.max(xmax, x) 74 | ymin = math.min(ymin, y) 75 | ymax = math.max(ymax, y) 76 | zmin = math.min(zmin, z) 77 | zmax = math.max(zmax, z) 78 | end 79 | end 80 | 81 | local offset = Vector3.new( 82 | 0.5 * (xmin + xmax), 83 | 0.5 * (ymin + ymax), 84 | 0.5 * (zmin + zmax) 85 | ) 86 | local size = Vector3.new( 87 | xmax - xmin, 88 | ymax - ymin, 89 | zmax - zmin 90 | ) 91 | 92 | return offset, size 93 | end 94 | 95 | --[[ 96 | Returns the bounding box that contains the specified objects, as well as the 97 | bounding boxes for all BaseParts and Models. 98 | 99 | Bounding boxes are computed in the given coordinate space, or world space if 100 | no local basis is provided. 101 | 102 | Params: 103 | table objects: An array of BaseParts, Models, and Attachments. 104 | CFrame basisCFrame: Optional local basis. 105 | 106 | Returns: 107 | Tuple (Vector3 offset, Vector3 size, table boundingBoxes). 108 | ]] 109 | function BoundingBox.fromObjectsComputeAll(objects, basisCFrame) 110 | local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil 111 | local xmin, xmax = math.huge, -math.huge 112 | local ymin, ymax = math.huge, -math.huge 113 | local zmin, zmax = math.huge, -math.huge 114 | 115 | local terrain = Workspace.Terrain 116 | 117 | local boundingBoxes = {} 118 | 119 | for _, object in ipairs(objects) do 120 | local isModel = object:IsA("Model") 121 | if isModel or object:IsA("BasePart") and object ~= terrain then 122 | local cframe, size 123 | if isModel then 124 | cframe, size = object:GetBoundingBox() 125 | else 126 | cframe = object.CFrame 127 | size = object.Size 128 | end 129 | 130 | local xmin1, xmax1, ymin1, ymax1, zmin1, zmax1 = getBoundingBoxInternal( 131 | cframe, size, inverseBasis) 132 | 133 | xmin = math.min(xmin, xmin1) 134 | xmax = math.max(xmax, xmax1) 135 | ymin = math.min(ymin, ymin1) 136 | ymax = math.max(ymax, ymax1) 137 | zmin = math.min(zmin, zmin1) 138 | zmax = math.max(zmax, zmax1) 139 | 140 | boundingBoxes[object] = { 141 | offset = Vector3.new( 142 | 0.5 * (xmin1 + xmax1), 143 | 0.5 * (ymin1 + ymax1), 144 | 0.5 * (zmin1 + zmax1) 145 | ), 146 | size = Vector3.new( 147 | xmax1 - xmin1, 148 | ymax1 - ymin1, 149 | zmax1 - zmin1 150 | ) 151 | } 152 | elseif object:IsA("Attachment") then 153 | local localPosition = basisCFrame:PointToObjectSpace(object.WorldPosition) 154 | local x, y, z = localPosition.X, localPosition.Y, localPosition.Z 155 | xmin = math.min(xmin, x) 156 | xmax = math.max(xmax, x) 157 | ymin = math.min(ymin, y) 158 | ymax = math.max(ymax, y) 159 | zmin = math.min(zmin, z) 160 | zmax = math.max(zmax, z) 161 | end 162 | end 163 | 164 | local offset = Vector3.new( 165 | 0.5 * (xmin + xmax), 166 | 0.5 * (ymin + ymax), 167 | 0.5 * (zmin + zmax) 168 | ) 169 | local size = Vector3.new( 170 | xmax - xmin, 171 | ymax - ymin, 172 | zmax - zmin 173 | ) 174 | 175 | return offset, size, boundingBoxes 176 | end 177 | 178 | --[[ 179 | Calculate an oriented bounding box for the passed in parts and attachments, 180 | in the supplied basis. 181 | ]] 182 | function BoundingBox.fromPartsAndAttachments(parts, attachments, basisCFrame) 183 | local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil 184 | local xmin, xmax = math.huge, -math.huge 185 | local ymin, ymax = math.huge, -math.huge 186 | local zmin, zmax = math.huge, -math.huge 187 | 188 | local terrain = Workspace.Terrain 189 | 190 | for _, part in ipairs(parts) do 191 | if part ~= terrain then 192 | local xmin1, xmax1, 193 | ymin1, ymax1, 194 | zmin1, zmax1 = getBoundingBoxInternal(part.CFrame, part.Size, inverseBasis) 195 | 196 | xmin = math.min(xmin, xmin1) 197 | xmax = math.max(xmax, xmax1) 198 | ymin = math.min(ymin, ymin1) 199 | ymax = math.max(ymax, ymax1) 200 | zmin = math.min(zmin, zmin1) 201 | zmax = math.max(zmax, zmax1) 202 | end 203 | end 204 | 205 | for _, attachment in ipairs(attachments) do 206 | local localPosition = basisCFrame:PointToObjectSpace(attachment.WorldPosition) 207 | local x, y, z = localPosition.X, localPosition.Y, localPosition.Z 208 | xmin = math.min(xmin, x) 209 | xmax = math.max(xmax, x) 210 | ymin = math.min(ymin, y) 211 | ymax = math.max(ymax, y) 212 | zmin = math.min(zmin, z) 213 | zmax = math.max(zmax, z) 214 | end 215 | 216 | local offset = Vector3.new( 217 | 0.5 * (xmin + xmax), 218 | 0.5 * (ymin + ymax), 219 | 0.5 * (zmin + zmax) 220 | ) 221 | local size = Vector3.new( 222 | xmax - xmin, 223 | ymax - ymin, 224 | zmax - zmin 225 | ) 226 | 227 | return offset, size 228 | end 229 | 230 | return BoundingBox 231 | -------------------------------------------------------------------------------- /src/Components/MoveHandleView.lua: -------------------------------------------------------------------------------- 1 | 2 | local Workspace = game:GetService("Workspace") 3 | local CoreGui = game:GetService("CoreGui") 4 | 5 | local DraggerFramework = script.Parent.Parent 6 | local Plugin = DraggerFramework.Parent.Parent 7 | local Math = require(DraggerFramework.Utility.Math) 8 | local Roact = require(Plugin.Packages.Roact) 9 | 10 | local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) 11 | 12 | local CULLING_MODE = Enum.AdornCullingMode.Never 13 | 14 | local MoveHandleView = Roact.PureComponent:extend("MoveHandleView") 15 | 16 | local BASE_HANDLE_RADIUS = 0.10 17 | local BASE_HANDLE_HITTEST_RADIUS = BASE_HANDLE_RADIUS * 4 -- Handle hittests bigger than it looks 18 | local BASE_HANDLE_OFFSET = 0.60 19 | local BASE_HANDLE_LENGTH = 4.00 20 | local BASE_TIP_OFFSET = 0.20 21 | local BASE_TIP_LENGTH = 0.25 22 | local TIP_RADIUS_MULTIPLIER = 3 23 | local SCREENSPACE_HANDLE_SIZE = 6 24 | local HANDLE_DIM_TRANSPARENCY = 0.45 25 | local HANDLE_THIN_BY_FRAC = 0.34 26 | local HANDLE_THICK_BY_FRAC = 1.5 27 | 28 | function MoveHandleView:init() 29 | end 30 | 31 | function MoveHandleView:render() 32 | local scale = self.props.Scale 33 | 34 | local length = scale * BASE_HANDLE_LENGTH 35 | local radius = scale * BASE_HANDLE_RADIUS 36 | local offset = scale * BASE_HANDLE_OFFSET 37 | if getEngineFeatureModelPivotVisual() then 38 | offset = offset + length * (self.props.Outset or 0) 39 | end 40 | local tipOffset = scale * BASE_TIP_OFFSET 41 | local tipLength = length * BASE_TIP_LENGTH 42 | if self.props.Thin then 43 | radius = radius * HANDLE_THIN_BY_FRAC 44 | end 45 | if self.props.Hovered then 46 | radius = radius * HANDLE_THICK_BY_FRAC 47 | tipLength = tipLength * HANDLE_THICK_BY_FRAC 48 | end 49 | 50 | local coneAtCFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length)) 51 | local tipAt = coneAtCFrame * Vector3.new(0, 0, -tipOffset) 52 | local tipAtScreen, _ = Workspace.CurrentCamera:WorldToScreenPoint(tipAt) 53 | 54 | local children = {} 55 | if not self.props.Hovered then 56 | children.Shaft = Roact.createElement("CylinderHandleAdornment", { 57 | Adornee = Workspace.Terrain, -- Just a neutral anchor point 58 | ZIndex = 0, 59 | Radius = radius, 60 | Height = length, 61 | CFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length * 0.5)), 62 | Color3 = self.props.Color, 63 | AlwaysOnTop = false, 64 | AdornCullingMode = CULLING_MODE, 65 | }) 66 | if not self.props.Thin then 67 | children.Head = Roact.createElement("ConeHandleAdornment", { 68 | Adornee = Workspace.Terrain, 69 | ZIndex = 0, 70 | Radius = TIP_RADIUS_MULTIPLIER * radius, 71 | Height = tipLength, 72 | CFrame = coneAtCFrame, 73 | Color3 = self.props.Color, 74 | AlwaysOnTop = false, 75 | AdornCullingMode = CULLING_MODE, 76 | }) 77 | end 78 | end 79 | 80 | if self.props.AlwaysOnTop then 81 | children.DimmedShaft = Roact.createElement("CylinderHandleAdornment", { 82 | Adornee = Workspace.Terrain, -- Just a neutral anchor point 83 | ZIndex = 0, 84 | Radius = radius, 85 | Height = length, 86 | CFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length * 0.5)), 87 | Color3 = self.props.Color, 88 | AlwaysOnTop = true, 89 | Transparency = self.props.Hovered and 0.0 or HANDLE_DIM_TRANSPARENCY, 90 | AdornCullingMode = CULLING_MODE, 91 | }) 92 | if not self.props.Thin then 93 | children.DimmedHead = Roact.createElement("ConeHandleAdornment", { 94 | Adornee = Workspace.Terrain, 95 | ZIndex = 0, 96 | Radius = 3 * radius, 97 | Height = tipLength, 98 | CFrame = coneAtCFrame, 99 | Color3 = self.props.Color, 100 | AlwaysOnTop = true, 101 | Transparency = self.props.Hovered and 0.0 or HANDLE_DIM_TRANSPARENCY, 102 | AdornCullingMode = CULLING_MODE, 103 | }) 104 | end 105 | elseif not self.props.Thin then 106 | local halfHandleSize = 0.5 * SCREENSPACE_HANDLE_SIZE 107 | 108 | children.ScreenBox = Roact.createElement(Roact.Portal, { 109 | target = CoreGui, 110 | }, { 111 | MoveToolScreenspaceHandle = Roact.createElement("ScreenGui", {}, { 112 | Frame = Roact.createElement("Frame", { 113 | BorderSizePixel = 0, 114 | BackgroundColor3 = self.props.Color, 115 | Position = UDim2.new(0, tipAtScreen.X - halfHandleSize, 0, tipAtScreen.Y - halfHandleSize), 116 | Size = UDim2.new(0, SCREENSPACE_HANDLE_SIZE, 0, SCREENSPACE_HANDLE_SIZE), 117 | AdornCullingMode = CULLING_MODE, 118 | }) 119 | }) 120 | }) 121 | end 122 | return Roact.createElement("Folder", {}, children) 123 | end 124 | 125 | function MoveHandleView.hitTest(props, mouseRay) 126 | local scale = props.Scale 127 | 128 | local length = scale * BASE_HANDLE_LENGTH 129 | local radius = scale * BASE_HANDLE_HITTEST_RADIUS 130 | local tipRadius = radius * TIP_RADIUS_MULTIPLIER 131 | local offset = scale * BASE_HANDLE_OFFSET 132 | if getEngineFeatureModelPivotVisual() then 133 | offset = offset + length * (props.Outset or 0) 134 | end 135 | local tipOffset = scale * BASE_TIP_OFFSET 136 | local tipLength = length * BASE_TIP_LENGTH 137 | local shaftEnd = offset + length 138 | 139 | if not props.AlwaysOnTop then 140 | -- Check the always on top 2D element at the tip of the vector 141 | local tipAt = props.Axis * Vector3.new(0, 0, -(offset + length + tipOffset)) 142 | local tipAtScreen, _ = Workspace.CurrentCamera:WorldToScreenPoint(tipAt) 143 | local mouseAtScreen = Workspace.CurrentCamera:WorldToScreenPoint(mouseRay.Origin) 144 | local halfHandleSize = 0.5 * SCREENSPACE_HANDLE_SIZE 145 | if mouseAtScreen.X > tipAtScreen.X - halfHandleSize and 146 | mouseAtScreen.Y > tipAtScreen.Y - halfHandleSize and 147 | mouseAtScreen.X < tipAtScreen.X + halfHandleSize and 148 | mouseAtScreen.Y < tipAtScreen.Y + halfHandleSize 149 | then 150 | return 0 151 | end 152 | end 153 | 154 | local hasIntersection, hitDistance = 155 | Math.intersectRayRay( 156 | props.Axis.Position, props.Axis.LookVector, 157 | mouseRay.Origin, mouseRay.Direction.Unit) 158 | 159 | if not hasIntersection then 160 | return nil 161 | end 162 | 163 | -- Must have an intersection if the above intersect did 164 | local _, distAlongMouseRay = 165 | Math.intersectRayRay( 166 | mouseRay.Origin, mouseRay.Direction.Unit, 167 | props.Axis.Position, props.Axis.LookVector) 168 | 169 | local hitRadius = 170 | ((props.Axis.Position + props.Axis.LookVector * hitDistance) - 171 | (mouseRay.Origin + mouseRay.Direction.Unit * distAlongMouseRay)).Magnitude 172 | 173 | if hitRadius < radius and hitDistance > offset and hitDistance < shaftEnd then 174 | return distAlongMouseRay 175 | elseif hitRadius < tipRadius and hitDistance > shaftEnd and hitDistance < shaftEnd + tipLength then 176 | return distAlongMouseRay 177 | else 178 | return nil 179 | end 180 | end 181 | 182 | --[[ 183 | Returns: 184 | float Offset - From base CFrame 185 | float Size - Extending from CFrame + Offset 186 | ]] 187 | function MoveHandleView.getHandleDimensionForScale(scale, outset) 188 | local length = scale * BASE_HANDLE_LENGTH 189 | local offset = scale * BASE_HANDLE_OFFSET 190 | if getEngineFeatureModelPivotVisual() then 191 | offset = offset + length * (outset or 0) 192 | end 193 | local tipLength = length * BASE_TIP_LENGTH 194 | return offset, length + tipLength 195 | end 196 | 197 | return MoveHandleView -------------------------------------------------------------------------------- /src/Utility/JointMaker.lua: -------------------------------------------------------------------------------- 1 | 2 | local RunService = game:GetService("RunService") 3 | 4 | local DraggerFramework = script.Parent.Parent 5 | local getGeometry = require(DraggerFramework.Utility.getGeometry) 6 | local JointPairs = require(DraggerFramework.Utility.JointPairs) 7 | local JointUtil = require(DraggerFramework.Utility.JointUtil) 8 | 9 | local getFFlagPreserveMotor6D = require(DraggerFramework.Flags.getFFlagPreserveMotor6D) 10 | 11 | local JointMaker = {} 12 | JointMaker.__index = JointMaker 13 | 14 | function JointMaker.new(isSimulating) 15 | return setmetatable({ 16 | _isSimulating = isSimulating, 17 | }, JointMaker) 18 | end 19 | 20 | local function getConstraintLength(joint) 21 | local a = joint.Attachment0.WorldPosition 22 | local b = joint.Attachment1.WorldPosition 23 | return (b - a).Magnitude 24 | end 25 | 26 | --[[ 27 | Set the parts to compute joints for, precomputing as much info as possible 28 | ]] 29 | function JointMaker:pickUpParts(parts) 30 | local partSet = {} 31 | for _, part in ipairs(parts) do 32 | partSet[part] = true 33 | end 34 | self._partSet = partSet 35 | self._parts = parts 36 | self._rootPartSet = {} -- Intentionally empty, only needed for IK moves 37 | 38 | local FFlagPreserveMotor6D = getFFlagPreserveMotor6D() 39 | 40 | local weldConstraintsToReenableSet = {} 41 | local motor6dsToAdjustAndReenableSet = {} 42 | local jointsToDestroy = {} 43 | local alreadyConnectedToSets = {} 44 | local initiallyTouchingSets = {} 45 | local internalJointSet = {} 46 | local springsToFixupSet = {} 47 | local lengthConstraintsToFixupSet = {} 48 | for _, part in ipairs(parts) do 49 | alreadyConnectedToSets[part] = {} 50 | for _, joint in ipairs(part:GetJoints()) do 51 | if joint:IsA("Constraint") then 52 | local other = JointUtil.getConstraintCounterpart(joint, part) 53 | if other then 54 | alreadyConnectedToSets[part][other] = true 55 | 56 | if joint:IsA("RopeConstraint") or 57 | joint:IsA("RodConstraint") then 58 | lengthConstraintsToFixupSet[joint] = { 59 | Span = getConstraintLength(joint), 60 | Length = joint.Length, 61 | } 62 | elseif joint:IsA("SpringConstraint") then 63 | springsToFixupSet[joint] = { 64 | Span = getConstraintLength(joint), 65 | FreeLength = joint.FreeLength, 66 | } 67 | end 68 | end 69 | elseif joint:IsA("JointInstance") then 70 | local other = JointUtil.getJointInstanceCounterpart(joint, part) 71 | if partSet[other] then 72 | internalJointSet[joint] = joint.Part1 73 | else 74 | if FFlagPreserveMotor6D and joint:IsA("Motor6D") then 75 | joint.Enabled = false 76 | motor6dsToAdjustAndReenableSet[joint] = part.CFrame 77 | alreadyConnectedToSets[part][other] = true 78 | else 79 | table.insert(jointsToDestroy, joint) 80 | end 81 | end 82 | elseif joint:IsA("WeldConstraint") then 83 | local other = JointUtil.getWeldConstraintCounterpart(joint, part) 84 | joint.Enabled = false 85 | alreadyConnectedToSets[part][other] = true 86 | weldConstraintsToReenableSet[joint] = true 87 | elseif joint:IsA("NoCollisionConstraint") then 88 | local other = JointUtil.getNoCollisionConstraintCounterpart(joint, part) 89 | alreadyConnectedToSets[part][other] = true 90 | end 91 | end 92 | 93 | initiallyTouchingSets[part] = {} 94 | for _, otherPart in ipairs(part:GetTouchingParts()) do 95 | initiallyTouchingSets[part][otherPart] = true 96 | end 97 | end 98 | self._lengthConstraintsToFixupSet = lengthConstraintsToFixupSet 99 | self._springsToFixupSet = springsToFixupSet 100 | self._internalJointSet = internalJointSet 101 | self._initiallyTouchingSets = initiallyTouchingSets 102 | self._jointsToDestroy = jointsToDestroy 103 | self._weldConstraintsToReenableSet = weldConstraintsToReenableSet 104 | if FFlagPreserveMotor6D then 105 | self._motor6dsToAdjustAndReenableSet = motor6dsToAdjustAndReenableSet 106 | end 107 | self._alreadyConnectedToSets = alreadyConnectedToSets 108 | self._geometryCache = {} 109 | end 110 | 111 | function JointMaker:anchorParts() 112 | local toUnanchorSet = {} 113 | for _, part in ipairs(self._parts) do 114 | if not part.Anchored then 115 | part.Anchored = true 116 | toUnanchorSet[part] = true 117 | end 118 | end 119 | self._toUnanchorSet = toUnanchorSet 120 | end 121 | 122 | function JointMaker:restoreAnchored() 123 | if self._toUnanchorSet then 124 | for part, _ in pairs(self._toUnanchorSet) do 125 | part.Anchored = false 126 | end 127 | self._toUnanchorSet = nil 128 | end 129 | end 130 | 131 | --[[ 132 | Break existing joints to others 133 | ]] 134 | function JointMaker:breakJointsToOutsiders() 135 | for _, joint in ipairs(self._jointsToDestroy) do 136 | joint.Parent = nil 137 | end 138 | self._jointsToDestroy = {} 139 | end 140 | 141 | --[[ 142 | Break joints between parts in the part list 143 | ]] 144 | function JointMaker:disconnectInternalJoints() 145 | for joint, _ in pairs(self._internalJointSet) do 146 | joint.Part1 = nil 147 | end 148 | end 149 | 150 | --[[ 151 | Reconnect the internal joints between parts with a scale 152 | ]] 153 | function JointMaker:reconnectInternalJointsWithScale(scale) 154 | for joint, part1 in pairs(self._internalJointSet) do 155 | joint.C0 = joint.C0 + joint.C0.Position * (scale - 1) 156 | joint.C1 = joint.C1 + joint.C1.Position * (scale - 1) 157 | joint.Part1 = part1 158 | end 159 | end 160 | 161 | --[[ 162 | Compute the candidate joint pairs for the parts at their current location. 163 | ]] 164 | function JointMaker:computeJointPairs() 165 | local jointPairs = JointPairs.new(self._parts, self._partSet, self._rootPartSet, 166 | CFrame.new(), 167 | self._alreadyConnectedToSets, function(part) 168 | return self:_getGeometry(part) 169 | end) 170 | 171 | if self._isSimulating then 172 | self._geometryCache = {} 173 | end 174 | 175 | return jointPairs 176 | end 177 | 178 | function JointMaker:isColliding(includeInitiallyTouching) 179 | for _, part in ipairs(self._parts) do 180 | for _, otherPart in ipairs(part:GetTouchingParts()) do 181 | if not self._partSet[otherPart] then 182 | if includeInitiallyTouching or not self._initiallyTouchingSets[part][otherPart] then 183 | return true 184 | end 185 | end 186 | end 187 | end 188 | return false 189 | end 190 | 191 | function JointMaker:fixupConstraintLengths() 192 | for constraint, data in pairs(self._lengthConstraintsToFixupSet) do 193 | local scaledBy = getConstraintLength(constraint) / data.Span 194 | constraint.Length = data.Length * scaledBy 195 | end 196 | for constraint, data in pairs(self._springsToFixupSet) do 197 | local scaledBy = getConstraintLength(constraint) / data.Span 198 | constraint.FreeLength = data.FreeLength * scaledBy 199 | end 200 | end 201 | 202 | function JointMaker:putDownParts() 203 | for weld, _ in pairs(self._weldConstraintsToReenableSet) do 204 | weld.Enabled = true 205 | end 206 | if getFFlagPreserveMotor6D() then 207 | for motor6d, originalCFrame in pairs(self._motor6dsToAdjustAndReenableSet) do 208 | if self._partSet[motor6d.Part0] then 209 | -- Modify C0 210 | local part0 = motor6d.Part0 211 | motor6d.C0 = part0.CFrame:Inverse() * originalCFrame * motor6d.C0 212 | else 213 | -- Modify C1 214 | local part1 = motor6d.Part1 215 | motor6d.C1 = part1.CFrame:Inverse() * originalCFrame * motor6d.C1 216 | end 217 | motor6d.Enabled = true 218 | end 219 | self._motor6dsToAdjustAndReenableSet = nil 220 | end 221 | self._weldConstraintsToReenableSet = nil 222 | self._alreadyConnectedToSets = nil 223 | self._geometryCache = nil 224 | self._parts = {} 225 | self._partSet = {} 226 | end 227 | 228 | function JointMaker:_getGeometry(part) 229 | if self._partSet[part] then 230 | -- Scaling, so our geometry might change every step 231 | return getGeometry(part) 232 | else 233 | local geometry = self._geometryCache[part] 234 | if not geometry then 235 | geometry = getGeometry(part) 236 | self._geometryCache[part] = geometry 237 | end 238 | return geometry 239 | end 240 | end 241 | 242 | return JointMaker -------------------------------------------------------------------------------- /src/Utility/Signal.lua: -------------------------------------------------------------------------------- 1 | 2 | local DraggerFramework = script.Parent.Parent 3 | 4 | -- For now use the DeveloperFramework Lua Signal implementation. I want to 5 | -- fix my implementation and return to using it later when I have time. 6 | if true then 7 | --[[ 8 | A limited, simple implementation of a Signal. 9 | 10 | Handlers are fired in order, and (dis)connections are properly handled when 11 | executing an event. 12 | 13 | Signal uses Immutable to avoid invalidating the 'Fire' loop iteration. 14 | ]] 15 | 16 | local function RemoveFromDictionary(dictionary, ...) 17 | local result = {} 18 | 19 | for key, value in pairs(dictionary) do 20 | local found = false 21 | for listKey = 1, select("#", ...) do 22 | if key == select(listKey, ...) then 23 | found = true 24 | break 25 | end 26 | end 27 | if not found then 28 | result[key] = value 29 | end 30 | end 31 | 32 | return result 33 | end 34 | 35 | local function Append(list, ...) 36 | local new = {} 37 | local len = #list 38 | 39 | for key = 1, len do 40 | new[key] = list[key] 41 | end 42 | 43 | for i = 1, select("#", ...) do 44 | new[len + i] = select(i, ...) 45 | end 46 | 47 | return new 48 | end 49 | 50 | local Signal = {} 51 | 52 | Signal.__index = Signal 53 | 54 | function Signal.new() 55 | local self = { 56 | _listeners = {} 57 | } 58 | 59 | setmetatable(self, Signal) 60 | 61 | return self 62 | end 63 | 64 | function Signal:Connect(callback) 65 | local listener = { 66 | callback = callback, 67 | isConnected = true, 68 | } 69 | self._listeners = Append(self._listeners, listener) 70 | 71 | local function disconnect() 72 | listener.isConnected = false 73 | self._listeners = RemoveFromDictionary(self._listeners, listener) 74 | end 75 | 76 | return { 77 | Disconnect = disconnect, 78 | } 79 | end 80 | 81 | function Signal:Fire(...) 82 | for _, listener in ipairs(self._listeners) do 83 | if listener.isConnected then 84 | listener.callback(...) 85 | end 86 | end 87 | end 88 | 89 | 90 | return Signal 91 | else 92 | -------------------------------------------------------------------------------- 93 | -- Batched Yield-Safe Signal Implementation -- 94 | -- This is a signal class which has almost identical behavior to the -- 95 | -- RBXScriptSignal. -- 96 | -- * The first way it differs from RBXScriptSignal is that it passes event -- 97 | -- arguments by reference instead of by value, so if you fire it with a table -- 98 | -- argument the argument won't be serialized or copied, it will be passed by -- 99 | -- reference. -- 100 | -- * The second way it differs from RBXScriptSignal is that the fire() method -- 101 | -- will raise an exception if an exception is raised synchonously within one -- 102 | -- of the connected event handlers. This gives you a full stack trace of the -- 103 | -- code that caused the exception unlike with RBXScriptSignal. -- 104 | -- * It allows you to yield in event handlers without blocking the fire call, -- 105 | -- fire events with nils in the middle of the event argument list, and -- 106 | -- connect / disconnect events safely during event handlers. -- 107 | -- It also uses efficient batching and flags to avoid creating extra threads. -- 108 | -------------------------------------------------------------------------------- 109 | 110 | -- Helper to unpack and call a variable argument list with a function to call 111 | -- as the first argument in the list. 112 | local function callPacked(fn, ...) 113 | fn(...) 114 | end 115 | 116 | -- Coroutine runner to batch non-yielding event handlers together 117 | local isCoRunnerReady = true 118 | local function fnCoRunner(fn, ...) 119 | fn(...) 120 | isCoRunnerReady = true 121 | while true do 122 | callPacked(coroutine.yield()) 123 | isCoRunnerReady = true 124 | end 125 | end 126 | local coRunnerThread = coroutine.create(fnCoRunner) 127 | 128 | -- Connection class 129 | local Connection = {} 130 | Connection.__index = Connection 131 | 132 | function Connection.new(signal, fn) 133 | return setmetatable({ 134 | _connected = true, 135 | _signal = signal, 136 | _fn = fn, 137 | _next = false 138 | }, Connection) 139 | end 140 | 141 | function Connection:Disconnect() 142 | assert(self._connected, "Can't disconnect a connection twice.", 2) 143 | self._connected = false 144 | 145 | -- Unhook the node, but DON'T clear it. That way any fire calls that are 146 | -- currently sitting on this node will be able to iterate forwards off of 147 | -- it, but any subsequent fire calls will not hit it, and it will be GCed 148 | -- when no more fire calls are sitting on it. 149 | if self._signal._handlerListHead == self then 150 | self._signal._handlerListHead = self._next 151 | else 152 | local prev = self._signal._handlerListHead 153 | while prev and prev._next ~= self do 154 | prev = prev._next 155 | end 156 | if prev then 157 | prev._next = self._next 158 | end 159 | end 160 | end 161 | 162 | -- Make Connection strict 163 | setmetatable(Connection, { 164 | __index = function(key) 165 | error(("Attempt to get Connection::%s (not a valid member)"):format(key), 2) 166 | end, 167 | __newindex = function(key, value) 168 | error(("Attempt to set Connection::%s (not a valid member)"):format(key), 2) 169 | end 170 | }) 171 | 172 | -- Signal class 173 | local Signal = {} 174 | Signal.__index = Signal 175 | 176 | function Signal.new() 177 | return setmetatable({ 178 | _handlerListHead = false 179 | }, Signal) 180 | end 181 | 182 | function Signal:Connect(fn) 183 | local connection = Connection.new(self, fn) 184 | if self._handlerListHead then 185 | connection._next = self._handlerListHead 186 | self._handlerListHead = connection 187 | else 188 | self._handlerListHead = connection 189 | end 190 | return connection 191 | end 192 | 193 | -- Signal::Fire(...) implemented by running the handler functions on the 194 | -- coRunnerThread, and any time the resulting thread yielded without returning 195 | -- to us, that means that it yielded to the Roblox scheduler and has been taken 196 | -- over by Roblox scheduling, meaning we have to make a new coroutine runner. 197 | function Signal:Fire(...) 198 | local item = self._handlerListHead 199 | while item do 200 | if item._connected then 201 | isCoRunnerReady = false 202 | local st, err = coroutine.resume(coRunnerThread, item._fn, ...) 203 | if not isCoRunnerReady then 204 | -- The call handler yielded in Roblox yields, so the Roblox 205 | -- thread scheduler will have "stolen" it. We need a new runner. 206 | coRunnerThread = coroutine.create(fnCoRunner) 207 | isCoRunnerReady = true 208 | 209 | -- Check if we encountered an error in the handler, the error 210 | -- case will cause isCoRunnerReady to definitely be false, so 211 | -- we can do the check in this if body. 212 | -- If we did, throw from here. This is a deviation from what 213 | -- RBXScriptSignal does but it vastily improves debuggability 214 | -- by giving us a stack trace of the code that caused the error. 215 | -- NOTE: We have to do this _after_ creating a new CoRunner 216 | -- because the user may handle the error with pcall and continue 217 | -- using the Signal afterwards. 218 | if not st then 219 | error("Error in event handler: "..err, 2) 220 | end 221 | end 222 | end 223 | item = item._next 224 | end 225 | end 226 | 227 | -- Implement Signal::wait() in terms of a temporary connection using 228 | -- a Signal::connect() which disconnects itself. 229 | function Signal:Wait() 230 | local coCurrentlyRunning = coroutine.running() 231 | local cn; 232 | cn = self:connect(function(...) 233 | cn:Disconnect() 234 | coroutine.resume(coCurrentlyRunning, ...) 235 | end) 236 | return coroutine.yield() 237 | end 238 | 239 | -- Make signal strict 240 | setmetatable(Signal, { 241 | __index = function(key) 242 | error(("Attempt to get Signal::%s (not a valid member)"):format(key), 2) 243 | end, 244 | __newindex = function(key, value) 245 | error(("Attempt to set Signal::%s (not a valid member)"):format(key), 2) 246 | end 247 | }) 248 | 249 | return Signal 250 | end 251 | -------------------------------------------------------------------------------- /src/Utility/getGeometry.lua: -------------------------------------------------------------------------------- 1 | local UniformScale = Vector3.new(1, 1, 1) 2 | 3 | local function getShape(part) 4 | if part:IsA('WedgePart') then 5 | return 'Wedge', UniformScale 6 | elseif part:IsA('CornerWedgePart') then 7 | return 'CornerWedge', UniformScale 8 | elseif part:IsA('Terrain') then 9 | return 'Terrain', UniformScale 10 | elseif part:IsA('UnionOperation') then 11 | return 'Brick', UniformScale 12 | elseif part:IsA('MeshPart') then 13 | return 'Brick', UniformScale 14 | elseif part:IsA('Part') then 15 | -- BasePart 16 | if part.Shape == Enum.PartType.Ball then 17 | return 'Sphere', UniformScale 18 | elseif part.Shape == Enum.PartType.Cylinder then 19 | return 'Cylinder', UniformScale 20 | elseif part.Shape == Enum.PartType.Block then 21 | return 'Brick', UniformScale 22 | elseif part.Shape == Enum.PartType.CornerWedge then 23 | return 'CornerWedge', UniformScale 24 | elseif part.Shape == Enum.PartType.Wedge then 25 | return 'Wedge', UniformScale 26 | else 27 | assert(false, "Unreachable") 28 | end 29 | else 30 | return 'Brick', UniformScale 31 | end 32 | end 33 | 34 | return function(part, hit, assumedCFrame) 35 | local cf = assumedCFrame or part.CFrame 36 | local pos = cf.p 37 | 38 | local sx = part.Size.x/2 39 | local sy = part.Size.y/2 40 | local sz = part.Size.z/2 41 | 42 | local xvec = cf.RightVector 43 | local yvec = cf.UpVector 44 | local zvec = -cf.LookVector 45 | 46 | local verts, edges, faces; 47 | 48 | local shape, scale = getShape(part) 49 | 50 | sx = sx * scale.X 51 | sy = sy * scale.Y 52 | sz = sz * scale.Z 53 | 54 | if shape == 'Brick' or shape == 'Sphere' or shape == 'Cylinder' then 55 | --8 vertices 56 | verts = { 57 | pos +xvec*sx +yvec*sy +zvec*sz, --top 4 58 | pos +xvec*sx +yvec*sy -zvec*sz, 59 | pos -xvec*sx +yvec*sy +zvec*sz, 60 | pos -xvec*sx +yvec*sy -zvec*sz, 61 | -- 62 | pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 63 | pos +xvec*sx -yvec*sy -zvec*sz, 64 | pos -xvec*sx -yvec*sy +zvec*sz, 65 | pos -xvec*sx -yvec*sy -zvec*sz, 66 | } 67 | --12 edges 68 | edges = { 69 | {verts[1], verts[2], math.min(2*sx, 2*sy)}, --top 4 70 | {verts[3], verts[4], math.min(2*sx, 2*sy)}, 71 | {verts[1], verts[3], math.min(2*sy, 2*sz)}, 72 | {verts[2], verts[4], math.min(2*sy, 2*sz)}, 73 | -- 74 | {verts[5], verts[6], math.min(2*sx, 2*sy)}, --bottom 4 75 | {verts[7], verts[8], math.min(2*sx, 2*sy)}, 76 | {verts[5], verts[7], math.min(2*sy, 2*sz)}, 77 | {verts[6], verts[8], math.min(2*sy, 2*sz)}, 78 | -- 79 | {verts[1], verts[5], math.min(2*sx, 2*sz)}, --verticals 80 | {verts[2], verts[6], math.min(2*sx, 2*sz)}, 81 | {verts[3], verts[7], math.min(2*sx, 2*sz)}, 82 | {verts[4], verts[8], math.min(2*sx, 2*sz)}, 83 | } 84 | --6 faces 85 | faces = { 86 | {verts[1], xvec, 'RightSurface', zvec, {verts[5], verts[6], verts[2], verts[1]}}, --right 87 | {verts[3], -xvec, 'LeftSurface', zvec, {verts[3], verts[4], verts[8], verts[7]}}, --left 88 | {verts[1], yvec, 'TopSurface', xvec, {verts[1], verts[2], verts[4], verts[3]}}, --top 89 | {verts[5], -yvec, 'BottomSurface', xvec, {verts[7], verts[8], verts[6], verts[5]}}, --bottom 90 | {verts[1], zvec, 'BackSurface', xvec, {verts[1], verts[3], verts[7], verts[5]}}, --back 91 | {verts[2], -zvec, 'FrontSurface', xvec, {verts[6], verts[8], verts[4], verts[2]}}, --front 92 | } 93 | elseif shape == 'Sphere' or shape == 'Cylinder' then 94 | -- Just have one face and vertex, at the hit pos 95 | verts = { hit } 96 | edges = {} --edge can be selected as the normal of the face if the user needs it 97 | local norm = (hit-pos).Unit 98 | local norm2 = norm:Cross(Vector3.new(0,1,0)).Unit 99 | 100 | local surfaceName 101 | if math.abs(norm.X) > math.abs(norm.Y) and math.abs(norm.X) > math.abs(norm.Z) then 102 | surfaceName = (norm.X > 0) and "RightSurface" or "LeftSurface" 103 | elseif math.abs(norm.Y) > math.abs(norm.Z) then 104 | surfaceName = (norm.Y > 0) and "TopSurface" or "BottomSurface" 105 | else 106 | surfaceName = (norm.Z > 0) and "BackSurface" or "FrontSurface" 107 | end 108 | faces = { 109 | {hit, norm, surfaceName, norm2, {}} 110 | } 111 | elseif shape == 'CornerWedge' then 112 | local slantVec1 = ( zvec*sy + yvec*sz).Unit 113 | local slantVec2 = (-xvec*sy + yvec*sx).Unit 114 | -- 5 verts 115 | verts = { 116 | pos +xvec*sx +yvec*sy -zvec*sz, --top 1 117 | -- 118 | pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 119 | pos +xvec*sx -yvec*sy -zvec*sz, 120 | pos -xvec*sx -yvec*sy +zvec*sz, 121 | pos -xvec*sx -yvec*sy -zvec*sz, 122 | } 123 | -- 8 edges 124 | edges = { 125 | {verts[2], verts[3], 0}, -- bottom 4 126 | {verts[3], verts[5], 0}, 127 | {verts[5], verts[4], 0}, 128 | {verts[4], verts[2], 0}, 129 | -- 130 | {verts[1], verts[3], 0}, -- vertical 131 | -- 132 | {verts[1], verts[2], 0}, -- side diagonals 133 | {verts[1], verts[5], 0}, 134 | -- 135 | {verts[1], verts[4], 0}, -- middle diagonal 136 | } 137 | -- 5 faces 138 | faces = { 139 | {verts[2], -yvec, 'BottomSurface', xvec, {verts[2], verts[3], verts[5], verts[4]}}, -- bottom 140 | -- 141 | {verts[1], xvec, 'RightSurface', -yvec, {verts[1], verts[3], verts[2]}}, -- sides 142 | {verts[1], -zvec, 'FrontSurface', -yvec, {verts[1], verts[3], verts[5]}}, 143 | -- 144 | {verts[1], slantVec1, 'BackSurface', xvec, {verts[1], verts[2], verts[4]}}, -- tops 145 | {verts[1], slantVec2, 'LeftSurface', zvec, {verts[1], verts[5], verts[4]}}, 146 | } 147 | 148 | elseif shape == 'Wedge' then 149 | local slantVec = (-zvec*sy + yvec*sz).Unit 150 | --6 vertices 151 | verts = { 152 | pos +xvec*sx +yvec*sy +zvec*sz, --top 2 153 | pos -xvec*sx +yvec*sy +zvec*sz, 154 | -- 155 | pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 156 | pos +xvec*sx -yvec*sy -zvec*sz, 157 | pos -xvec*sx -yvec*sy +zvec*sz, 158 | pos -xvec*sx -yvec*sy -zvec*sz, 159 | } 160 | --9 edges 161 | edges = { 162 | {verts[1], verts[2], math.min(2*sy, 2*sz)}, --top 1 163 | -- 164 | {verts[1], verts[4], math.min(2*sy, 2*sz)}, --slanted 2 165 | {verts[2], verts[6], math.min(2*sy, 2*sz)}, 166 | -- 167 | {verts[3], verts[4], math.min(2*sx, 2*sy)}, --bottom 4 168 | {verts[5], verts[6], math.min(2*sx, 2*sy)}, 169 | {verts[3], verts[5], math.min(2*sy, 2*sz)}, 170 | {verts[4], verts[6], math.min(2*sy, 2*sz)}, 171 | -- 172 | {verts[1], verts[3], math.min(2*sx, 2*sz)}, --vertical 2 173 | {verts[2], verts[5], math.min(2*sx, 2*sz)}, 174 | } 175 | --5 faces 176 | faces = { 177 | {verts[1], xvec, 'RightSurface', zvec, {verts[4], verts[1], verts[3]}}, --right 178 | {verts[2], -xvec, 'LeftSurface', zvec, {verts[2], verts[6], verts[5]}}, --left 179 | {verts[3], -yvec, 'BottomSurface', xvec, {verts[5], verts[6], verts[4], verts[3]}}, --bottom 180 | {verts[1], zvec, 'BackSurface', xvec, {verts[1], verts[2], verts[5], verts[3]}}, --back 181 | {verts[2], slantVec, 'FrontSurface', slantVec:Cross(xvec), {verts[2], verts[1], verts[4], verts[6]}}, --slanted 182 | } 183 | elseif shape == 'Terrain' then 184 | assert(false, "Called GetGeometry on Terrain") 185 | else 186 | assert(false, "Bad shape: "..shape) 187 | end 188 | 189 | local geometry = { 190 | part = part; 191 | shape = (shape == 'Sphere' or shape == 'Cylinder') and shape or 'Mesh'; 192 | vertices = verts; 193 | edges = edges; 194 | faces = faces; 195 | vertexMargin = math.min(sx, sy, sz) * 2; 196 | } 197 | 198 | local geomId = 0 199 | 200 | for _, dat in ipairs(faces) do 201 | geomId = geomId + 1 202 | dat.id = geomId 203 | dat.point = dat[1] 204 | dat.normal = dat[2] 205 | dat.surface = dat[3] 206 | dat.direction = dat[4] 207 | dat.vertices = dat[5] 208 | dat.part = part 209 | dat.type = 'Face' 210 | --avoid Event bug (if both keys + indicies are present keys are discarded when passing tables) 211 | dat[1], dat[2], dat[3], dat[4] = nil, nil, nil, nil 212 | end 213 | for _, dat in ipairs(edges) do 214 | geomId = geomId + 1 215 | dat.id = geomId 216 | dat.a, dat.b = dat[1], dat[2] 217 | dat.direction = (dat.b - dat.a).Unit 218 | dat.length = (dat.b - dat.a).Magnitude 219 | dat.edgeMargin = dat[3] 220 | dat.part = part 221 | dat.vertexMargin = geometry.vertexMargin 222 | dat.type = 'Edge' 223 | --avoid Event bug (if both keys + indicies are present keys are discarded when passing tables) 224 | dat[1], dat[2], dat[3] = nil, nil, nil 225 | end 226 | for i, dat in ipairs(verts) do 227 | geomId = geomId + 1 228 | verts[i] = { 229 | position = dat; 230 | id = geomId; 231 | type = 'Vertex'; 232 | } 233 | end 234 | 235 | return geometry 236 | end -------------------------------------------------------------------------------- /src/Components/RotateHandleView.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Displays rotation gimbal handles. When dragging, start and end radii showing 3 | the central angle of rotation are displayed. 4 | ]] 5 | 6 | local Workspace = game:GetService("Workspace") 7 | 8 | -- Dragger Framework 9 | local DraggerFramework = script.Parent.Parent 10 | local Packages = DraggerFramework.Parent 11 | local Roact = require(Packages.Roact) 12 | local Math = require(DraggerFramework.Utility.Math) 13 | 14 | local CULLING_MODE = Enum.AdornCullingMode.Never 15 | 16 | local RotateHandleView = Roact.PureComponent:extend("RotateHandleView") 17 | 18 | local HANDLE_SEGMENTS = 32 19 | local HANDLE_RADIUS = 4.5 20 | local HANDLE_THICKNESS = 0.15 21 | local ANGLE_DISPLAY_THICKNESS = 0.08 22 | local HANDLE_HITTEST_THICKNESS = HANDLE_THICKNESS * 4 23 | local HANDLE_THIN_BY_FRAC = 0.0 24 | local HANDLE_THICK_BY_FRAC = 1.5 25 | local HANDLE_DIM_TRANSPARENCY = 0.45 26 | local HANDLE_TICK_WIDTH = 0.05 27 | local HANDLE_TICK_WIDE_WIDTH = 0.10 28 | local HANDLE_TICK_RADIUS_FRAC = 0.10 -- Fraction of the radius 29 | local HANDLE_TICK_RADIUS_LONG_FRAC = 0.30 -- Fraction for the primary angles (multiple of 90) 30 | local QUARTER_ROTATION = math.pi / 2 31 | 32 | local function isMultipleOf90Degrees(angle) 33 | local roundedTo90 = math.floor(angle / QUARTER_ROTATION + 0.5) * QUARTER_ROTATION 34 | return math.abs(angle - roundedTo90) < 0.001 35 | end 36 | 37 | function RotateHandleView:render() 38 | -- TODO: DEVTOOLS-3876: [Modeling] Rotate tool enhancements 39 | -- Gimbal arc length should be a function of the viewing angle, and handle 40 | -- should face the camera. 41 | 42 | local radiusOffset = self.props.RadiusOffset or 0.0 43 | local radius = (HANDLE_RADIUS + radiusOffset) * self.props.Scale 44 | if self.props.Hovered then 45 | radius = radius + self.props.Scale * 0.1 46 | end 47 | local thickness = HANDLE_THICKNESS * self.props.Scale 48 | local angleStep = 2 * math.pi / HANDLE_SEGMENTS 49 | local offset = radius * math.cos(angleStep / 2) 50 | 51 | local children = {} 52 | 53 | -- Thinning for drag 54 | if self.props.Thin then 55 | thickness = HANDLE_THIN_BY_FRAC * thickness 56 | end 57 | if self.props.Hovered then 58 | thickness = HANDLE_THICK_BY_FRAC * thickness 59 | end 60 | 61 | -- Draw main rotation gimbal. 62 | local halfThickness = 0.5 * thickness 63 | children["OnTopHandle"] = Roact.createElement("CylinderHandleAdornment", { 64 | Adornee = Workspace.Terrain, 65 | CFrame = self.props.HandleCFrame * CFrame.Angles(self.props.StartAngle or 0, math.pi / 2, math.pi / 2), 66 | Height = thickness, 67 | Radius = radius + halfThickness, 68 | InnerRadius = radius - halfThickness, 69 | Color3 = self.props.Color, 70 | AlwaysOnTop = true, 71 | Transparency = HANDLE_DIM_TRANSPARENCY, 72 | ZIndex = 0, 73 | AdornCullingMode = CULLING_MODE, 74 | }) 75 | children["BrightHandle"] = Roact.createElement("CylinderHandleAdornment", { 76 | Adornee = Workspace.Terrain, 77 | CFrame = self.props.HandleCFrame * CFrame.Angles(self.props.StartAngle or 0, math.pi / 2, math.pi / 2), 78 | Height = thickness, 79 | Radius = radius + halfThickness, 80 | InnerRadius = radius - halfThickness, 81 | Color3 = self.props.Color, 82 | AlwaysOnTop = false, 83 | ZIndex = 0, 84 | AdornCullingMode = CULLING_MODE, 85 | }) 86 | 87 | if self.props.TickAngle then 88 | local angleStep = self.props.TickAngle 89 | local count = math.ceil(math.pi * 2 / angleStep) 90 | local smallTickWidth = HANDLE_TICK_WIDTH * self.props.Scale 91 | local smallTickLength = HANDLE_TICK_RADIUS_FRAC * radius 92 | 93 | -- Information for the primary ticks placed at 90 degree intervals 94 | -- relative to the angle the rotate started at. 95 | local primaryTickWidth = HANDLE_TICK_WIDE_WIDTH * self.props.Scale 96 | local primaryTickLength = HANDLE_TICK_RADIUS_LONG_FRAC * radius 97 | local placementAngleMod = 0 98 | local primaryTickAngleMod = 0 99 | local hasPrimaryTicks = false 100 | if self.props.StartAngle then 101 | placementAngleMod = self.props.EndAngle - self.props.StartAngle 102 | primaryTickAngleMod = self.props.StartAngle 103 | hasPrimaryTicks = true 104 | end 105 | 106 | for i = 1, count do 107 | local angle = math.pi + (i - 1) * angleStep - placementAngleMod 108 | local isPrimaryTick = hasPrimaryTicks and isMultipleOf90Degrees(angle - primaryTickAngleMod) 109 | local tickLength = isPrimaryTick and primaryTickLength or smallTickLength 110 | local tickWidth = isPrimaryTick and primaryTickWidth or smallTickWidth 111 | local cframe = 112 | self.props.HandleCFrame * 113 | CFrame.Angles(angle, 0, 0) * 114 | CFrame.new(0, 0, radius - 0.5 * smallTickLength) 115 | children["Tick" .. tostring(i)] = Roact.createElement("BoxHandleAdornment", { 116 | Adornee = Workspace.Terrain, 117 | AlwaysOnTop = false, 118 | CFrame = cframe, 119 | Color3 = self.props.Color, 120 | Size = Vector3.new(tickWidth, tickWidth, tickLength), 121 | ZIndex = 0, 122 | AdornCullingMode = CULLING_MODE, 123 | }) 124 | children["OnTopTick" .. tostring(i)] = Roact.createElement("BoxHandleAdornment", { 125 | Adornee = Workspace.Terrain, 126 | AlwaysOnTop = true, 127 | Transparency = HANDLE_DIM_TRANSPARENCY, 128 | CFrame = cframe, 129 | Color3 = self.props.Color, 130 | Size = Vector3.new(tickWidth, tickWidth, tickLength), 131 | ZIndex = 0, 132 | AdornCullingMode = CULLING_MODE, 133 | }) 134 | end 135 | end 136 | 137 | -- Draw the swept angle as circular section at the outer edge. The circular 138 | -- section shows the smallest swept angle back to the starting point. 139 | if self.props.StartAngle and self.props.EndAngle then 140 | local smallTickLength = HANDLE_TICK_RADIUS_FRAC * radius 141 | local primaryTickLength = HANDLE_TICK_RADIUS_LONG_FRAC * radius 142 | local outerWidth = 0.5 * (primaryTickLength - smallTickLength) 143 | 144 | local theta = self.props.EndAngle - self.props.StartAngle 145 | local startAngle = self.props.StartAngle 146 | if theta > math.pi then 147 | theta = theta - math.pi * 2 148 | end 149 | if theta < -math.pi then 150 | theta = theta + math.pi * 2 151 | end 152 | if theta < 0 then 153 | startAngle = startAngle + theta 154 | theta = math.abs(theta) 155 | end 156 | if math.abs(theta) > 0.001 then 157 | children.AngleSweepElement = Roact.createElement("CylinderHandleAdornment", { 158 | Adornee = Workspace.Terrain, 159 | CFrame = self.props.HandleCFrame * CFrame.Angles(startAngle - math.pi / 2, math.pi / 2, math.pi / 2), 160 | Height = 0, 161 | Radius = radius, 162 | InnerRadius = 0, 163 | Angle = math.deg(theta), 164 | Color3 = self.props.Color, 165 | AlwaysOnTop = true, 166 | Transparency = 0.6, 167 | ZIndex = 0, 168 | }) 169 | end 170 | 171 | local angleDisplayThickness = ANGLE_DISPLAY_THICKNESS * self.props.Scale 172 | local function createAngleDisplay(angle) 173 | local offset = CFrame.new(0, 0, -(radius + outerWidth) / 2) 174 | local cframe = self.props.HandleCFrame * CFrame.Angles(angle, 0, 0) * offset 175 | return Roact.createElement("CylinderHandleAdornment", { 176 | Adornee = Workspace.Terrain, 177 | AlwaysOnTop = true, 178 | CFrame = cframe, 179 | Color3 = self.props.Color, 180 | Height = radius + outerWidth, 181 | Radius = angleDisplayThickness / 2, 182 | ZIndex = 0, 183 | }) 184 | end 185 | children.EndAngleElement = createAngleDisplay(self.props.EndAngle) 186 | end 187 | 188 | return Roact.createElement("Folder", {}, children) 189 | end 190 | 191 | --[[ 192 | Check if the mouse is over the rotation handle. 193 | 194 | The point of intersection between the mouse ray and plane perpendicular 195 | to the rotation axis is computed. The hit radius (distance from the origin 196 | of rotation to the intersection point) is compared to the gimbal radius, 197 | within a threshold to aid handle selection. 198 | ]] 199 | function RotateHandleView.hitTest(props, mouseRay) 200 | local cframe = props.HandleCFrame 201 | local unitRay = mouseRay.Unit 202 | 203 | local radiusOffset = props.RadiusOffset or 0.0 204 | local radius = (HANDLE_RADIUS + radiusOffset) * props.Scale 205 | local thickness = HANDLE_HITTEST_THICKNESS * props.Scale 206 | local normal = cframe.RightVector 207 | local point = cframe.Position 208 | 209 | local smallestDistance = math.huge 210 | local foundHit = false 211 | local hit, t 212 | 213 | -- Top ring 214 | local topPoint = point + normal * 0.5 * thickness 215 | t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, topPoint, normal) 216 | if t >= 0 and t < smallestDistance then 217 | local mouseWorld = unitRay.Origin + unitRay.Direction * t 218 | local hitRadius = (mouseWorld - topPoint).Magnitude 219 | 220 | local distance = math.abs(hitRadius - radius) 221 | if distance < 0.5 * thickness then 222 | foundHit = true 223 | smallestDistance = t 224 | end 225 | end 226 | 227 | -- Bottom ring 228 | local bottomPoint = point - normal * 0.5 * thickness 229 | t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, bottomPoint, -normal) 230 | if t >= 0 and t < smallestDistance then 231 | local mouseWorld = unitRay.Origin + unitRay.Direction * t 232 | local hitRadius = (mouseWorld - bottomPoint).Magnitude 233 | 234 | local distance = math.abs(hitRadius - radius) 235 | if distance < 0.5 * thickness then 236 | foundHit = true 237 | smallestDistance = t 238 | end 239 | end 240 | 241 | -- Get the ray in local space, so that we can use the intersectRayCylinder 242 | -- call for the intersection. The canonical normal of the cylinder is 243 | -- (1, 0, 0) which is what that call expects. 244 | local o = cframe:PointToObjectSpace(unitRay.Origin) 245 | local d = cframe:VectorToObjectSpace(unitRay.Direction) 246 | 247 | -- Inner Cylinder 248 | local innerRadius = radius - 0.5 * thickness 249 | hit, t = Math.intersectRayCylinder(o, d, innerRadius, thickness) 250 | if hit and t < smallestDistance then 251 | foundHit = true 252 | smallestDistance = t 253 | end 254 | 255 | -- Outer Cylinder 256 | local outerRadius = radius + 0.5 * thickness 257 | hit, t = Math.intersectRayCylinder(o, d, outerRadius, thickness) 258 | if hit and t < smallestDistance then 259 | foundHit = true 260 | smallestDistance = t 261 | end 262 | 263 | if foundHit then 264 | return smallestDistance 265 | else 266 | return nil 267 | end 268 | end 269 | 270 | return RotateHandleView 271 | -------------------------------------------------------------------------------- /src/Implementation/DraggerContext_FixtureImpl.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | DraggerContext is a class which wraps all of the global state which the 3 | dragger needs to access to operate. This is the fixture implementation of 4 | the context, which allows the values which the globals will have to be 5 | expicitly set, to be used in doing testing. 6 | ]] 7 | 8 | local StudioService = game:GetService("StudioService") 9 | local ChangeHistoryService = game:GetService("ChangeHistoryService") 10 | 11 | local DraggerFramework = script.Parent.Parent 12 | 13 | local Colors = require(DraggerFramework.Utility.Colors) 14 | 15 | local MockAnalytics = require(DraggerFramework.Utility.MockAnalytics) 16 | 17 | local DraggerContext = {} 18 | DraggerContext.__index = DraggerContext 19 | 20 | local RAYCAST_DIRECTION_SCALE = 10000 21 | 22 | local VIEWPORT_SIZE = 1000 23 | 24 | local MAX_UNDO_WAYPOINTS = 20 25 | 26 | function DraggerContext.new(guiTarget, selection) 27 | assert(selection ~= nil) 28 | return setmetatable({ 29 | _guiTarget = guiTarget, 30 | _useLocalSpace = false, 31 | _areCollisionsEnabled = true, 32 | _areConstraintsEnabled = false, 33 | _areConstraintDetailsShown = false, 34 | _drawConstraintsOnTop = false, 35 | _shouldJoinSurfaces = true, 36 | _mouseLocation = Vector2.new(), 37 | _mouseUnitRay = Ray.new(Vector3.new(), Vector3.new()), 38 | _cameraCFrame = CFrame.new(), 39 | _cameraSize = 10, 40 | _mouseIcon = "", 41 | _isSimulating = false, 42 | _gridSize = 1, 43 | _rotateIncrement = math.rad(30), 44 | _selection = selection, 45 | _undoWaypoints = {}, 46 | _isAltDown = false, 47 | _isCtrlDown = false, 48 | _isShiftDown = false, 49 | _settingValues = {}, 50 | }, DraggerContext) 51 | end 52 | 53 | -- What instance should the plugin's GUI objects get created under? 54 | function DraggerContext:getGuiParent() 55 | return self._guiTarget 56 | end 57 | 58 | function DraggerContext:setHoverInstance(instance) 59 | self._hoverInstance = instance 60 | end 61 | 62 | function DraggerContext:expectHoverInstance(instance) 63 | if self._hoverInstance ~= instance then 64 | local expected = instance and instance:GetFullName() or "nil" 65 | local got = self._hoverInstance and self._hoverInstance:GetFullName() or "nil" 66 | error("Wrong hover instance,\n Expected: " .. expected .. "\n Got: " .. got) 67 | end 68 | end 69 | 70 | function DraggerContext:shouldUseLocalSpace() 71 | return self._useLocalSpace 72 | end 73 | 74 | function DraggerContext:setUseLocalSpace(value) 75 | self._useLocalSpace = value 76 | end 77 | 78 | function DraggerContext:areCollisionsEnabled() 79 | return self._areCollisionsEnabled 80 | end 81 | 82 | function DraggerContext:setCollisionsEnabled(value) 83 | self._areCollisionsEnabled = value 84 | end 85 | 86 | function DraggerContext:areConstraintsEnabled() 87 | return self._areConstraintsEnabled 88 | end 89 | 90 | function DraggerContext:setConstraintsEnabled(value) 91 | self._areConstraintsEnabled = value 92 | end 93 | 94 | function DraggerContext:areConstraintDetailsShown() 95 | return self._areConstraintDetailsShown 96 | end 97 | 98 | function DraggerContext:setConstraintDetailsShown(value) 99 | self._areConstraintDetailsShown = value 100 | end 101 | 102 | function DraggerContext:shouldDrawConstraintsOnTop() 103 | return self._drawConstraintsOnTop 104 | end 105 | 106 | function DraggerContext:setDrawConstraintsOnTop(value) 107 | self._drawConstraintsOnTop = value 108 | end 109 | 110 | function DraggerContext:shouldJoinSurfaces() 111 | return self._shouldJoinSurfaces 112 | end 113 | 114 | function DraggerContext:setJoinSurfaces(value) 115 | assert(typeof(value) == "boolean") -- Don't try to pass the Enum 116 | self._shouldJoinSurfaces = value 117 | end 118 | 119 | function DraggerContext:shouldShowHover() 120 | return true 121 | end 122 | 123 | function DraggerContext:shouldAnimateHover() 124 | return true 125 | end 126 | 127 | function DraggerContext:shouldSelectScopeByDefault() 128 | return true 129 | end 130 | 131 | function DraggerContext:getHoverAnimationSpeedInSeconds() 132 | return 0.5 133 | end 134 | 135 | function DraggerContext:getHoverBoxColor(isActive) 136 | return Color3.new() 137 | end 138 | 139 | function DraggerContext:getHoverLineThickness() 140 | return 0.04 141 | end 142 | 143 | function DraggerContext:getSelectionBoxColor(isActive) 144 | return Color3.new() 145 | end 146 | 147 | function DraggerContext:getGeometrySnapColor() 148 | return Color3.new() 149 | end 150 | 151 | function DraggerContext:getCameraCFrame() 152 | return self._cameraCFrame 153 | end 154 | 155 | function DraggerContext:setCamera(cframe, size) 156 | self._cameraCFrame = cframe 157 | self._cameraSize = size or 10 158 | end 159 | 160 | function DraggerContext:getHandleScale(focusPoint) 161 | return 1.0 162 | end 163 | 164 | function DraggerContext:getMouseUnitRay() 165 | return self:viewportPointToRay(self._mouseLocation) 166 | end 167 | 168 | function DraggerContext:getMouseRay() 169 | local unitRay = self:getMouseUnitRay() 170 | return Ray.new(unitRay.Origin, unitRay.Direction * RAYCAST_DIRECTION_SCALE) 171 | end 172 | 173 | function DraggerContext:getMouseLocation() 174 | return self._mouseLocation 175 | end 176 | 177 | function DraggerContext:setMouseLocation(location) 178 | self._mouseLocation = location 179 | end 180 | 181 | function DraggerContext:viewportPointToRay(screenPoint) 182 | local x = (screenPoint.X / VIEWPORT_SIZE - 0.5) * self._cameraSize 183 | local y = (screenPoint.Y / VIEWPORT_SIZE - 0.5) * self._cameraSize 184 | local at = self._cameraCFrame:PointToWorldSpace(Vector3.new(x, y, 0)) 185 | return Ray.new(at, self._cameraCFrame.LookVector) 186 | end 187 | 188 | function DraggerContext:worldToViewportPoint(worldPoint) 189 | local point = self._cameraCFrame:Inverse() * worldPoint 190 | local x = (point.X / self._cameraSize + 0.5) * VIEWPORT_SIZE 191 | local y = (point.Y / self._cameraSize + 0.5) * VIEWPORT_SIZE 192 | local onScreen = 193 | (x >= 0 and x <= VIEWPORT_SIZE) and 194 | (y >= 0 and y <= VIEWPORT_SIZE) and 195 | point.Z < 0 196 | return Vector2.new(x, y), onScreen 197 | end 198 | 199 | function DraggerContext:getViewportSize() 200 | return Vector2.new(VIEWPORT_SIZE, VIEWPORT_SIZE) 201 | end 202 | 203 | function DraggerContext:setMouseIcon(icon) 204 | self._mouseIcon = icon 205 | end 206 | 207 | function DraggerContext:expectMouseIcon(icon) 208 | if self._mouseIcon ~= icon then 209 | local expected = icon or "nil" 210 | local got = self._mouseIcon or "nil" 211 | error("Wrong mouse icon,\n Expected: " .. expected .. "\n Got: " .. got) 212 | end 213 | end 214 | 215 | function DraggerContext:getSelection() 216 | return self._selection 217 | end 218 | 219 | -- Are non-anchored parts in the world currently being physically simulated? 220 | -- (i.e. are they moving around of thier own accord) 221 | function DraggerContext:isSimulating() 222 | return self._isSimulating 223 | end 224 | 225 | function DraggerContext:setSimulating(value) 226 | self._isSimulating = value 227 | end 228 | 229 | function DraggerContext:isAltKeyDown() 230 | return self._isAltDown 231 | end 232 | 233 | function DraggerContext:isCtrlKeyDown() 234 | return self._isCtrlDown 235 | end 236 | 237 | function DraggerContext:isShiftKeyDown() 238 | return self._isShiftDown 239 | end 240 | 241 | function DraggerContext:shouldExtendSelection() 242 | return self:isCtrlKeyDown() or self:isShiftKeyDown() 243 | end 244 | 245 | function DraggerContext:setCtrlAltShift(ctrl, alt, shift) 246 | self._isCtrlDown = ctrl 247 | self._isAltDown = alt 248 | self._isShiftDown = shift 249 | end 250 | 251 | function DraggerContext:getGridSize() 252 | return self._gridSize 253 | end 254 | 255 | function DraggerContext:snapToGridSize(distance) 256 | -- Use an exact check here because we're in control of things, we can set 257 | -- exactly grid size = 0 when snapping should be disabled. 258 | if self._gridSize > 0 then 259 | return math.floor(distance / self._gridSize + 0.5) * self._gridSize 260 | else 261 | return distance 262 | end 263 | end 264 | 265 | function DraggerContext:getRotateIncrement() 266 | return self._rotateIncrement 267 | end 268 | 269 | function DraggerContext:setGridSize(value) 270 | self._gridSize = math.max(value, 0.001) 271 | end 272 | 273 | function DraggerContext:setRotateIncrement(value) 274 | self._rotateIncrement = value 275 | end 276 | 277 | function DraggerContext:getAnalytics() 278 | return MockAnalytics 279 | end 280 | 281 | function DraggerContext:gizmoRaycast(origin, direction, raycastParams) 282 | return StudioService:GizmoRaycast(origin, direction, raycastParams) 283 | end 284 | 285 | function DraggerContext:setInsertPoint(location) 286 | self._insertPoint = location 287 | end 288 | 289 | function DraggerContext:expectInsertPoint(location) 290 | if self._insertPoint ~= location then 291 | local expected = tostring(location) 292 | local got = tostring(self._insertPoint) 293 | error("Wrong insert point,\n Expected: " .. expected .. "\n Got: " .. got) 294 | end 295 | end 296 | 297 | function DraggerContext:shouldShowActiveInstanceHighlight() 298 | return true 299 | end 300 | 301 | function DraggerContext:shouldAlignDraggedObjects() 302 | return true 303 | end 304 | 305 | function DraggerContext:addUndoWaypoint(waypointIdentifier, waypointText) 306 | if ChangeHistoryService then 307 | ChangeHistoryService:SetWaypoint(waypointIdentifier) 308 | end 309 | table.insert(self._undoWaypoints, waypointIdentifier) 310 | while #self._undoWaypoints > MAX_UNDO_WAYPOINTS do 311 | table.remove(self._undoWaypoints, 1) 312 | end 313 | end 314 | 315 | function DraggerContext:expectMostRecentUndoWaypoint(waypointIdentifier) 316 | local mostRecent = self._undoWaypoints[#self._undoWaypoints] 317 | if mostRecent ~= waypointIdentifier then 318 | error("Wrong last undo waypoint,\n Expected: " .. waypointIdentifier .. 319 | "\n Got: " .. (mostRecent or "")) 320 | end 321 | end 322 | 323 | function DraggerContext:expectAndUndo(waypointIdentifier) 324 | self:expectMostRecentUndoWaypoint(waypointIdentifier) 325 | self._undoWaypoints[#self._undoWaypoints] = nil 326 | if ChangeHistoryService then 327 | ChangeHistoryService:Undo() 328 | end 329 | end 330 | 331 | function DraggerContext:getText(scope, key, args) 332 | if args then 333 | return ("%s.%s (%s)").format(scope, key, table.concat(args, ",")) 334 | else 335 | return ("%s.%s").format(scope, key) 336 | end 337 | end 338 | 339 | function DraggerContext:getThemeColor(item) 340 | if item == Enum.StudioStyleGuideColor.MainBackground then 341 | return Colors.WHITE 342 | else 343 | return Colors.BLACK 344 | end 345 | end 346 | 347 | function DraggerContext:getSetting(name) 348 | return self._settingValues[name] 349 | end 350 | 351 | function DraggerContext:setSetting(name, value) 352 | self._settingValues[name] = value 353 | end 354 | 355 | function DraggerContext:setPivotIndicator(state) 356 | return false 357 | end 358 | 359 | return DraggerContext 360 | -------------------------------------------------------------------------------- /src/Implementation/DraggerContext_PluginImpl.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | DraggerContext is a class which wraps all of the global state which the 3 | dragger needs to access to operate. This is the plugin implementation of the 4 | context, which pulls those globals from the studio session's services, to 5 | be used by a plugin. 6 | ]] 7 | 8 | local DraggerFramework = script.Parent.Parent 9 | 10 | local Analytics = require(DraggerFramework.Utility.Analytics) 11 | local setInsertPoint = require(DraggerFramework.Utility.setInsertPoint) 12 | 13 | local FallbackLocalizationTable = DraggerFramework.Resources.TranslationDevelopmentTable 14 | local TranslatedLocalizationTable = DraggerFramework.Resources.TranslationReferenceTable 15 | 16 | local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) 17 | 18 | local DraggerContext = {} 19 | DraggerContext.__index = DraggerContext 20 | 21 | local RAYCAST_DIRECTION_SCALE = 10000 22 | local HANDLE_SCALE_FACTOR = 0.05 23 | local FALLBACK_LOCALE = "en_US" 24 | 25 | function DraggerContext.new(plugin, editDataModel, userSettings, selection) 26 | return setmetatable({ 27 | _editDataModel = editDataModel, 28 | _plugin = plugin, 29 | _userSettings = userSettings, 30 | _studioService = editDataModel:GetService("StudioService"), 31 | _draggerService = getFFlagSummonPivot() and editDataModel:GetService("DraggerService") or nil, 32 | _runService = editDataModel:GetService("RunService"), 33 | _studioSettings = userSettings.Studio, 34 | _workspace = editDataModel:GetService("Workspace"), 35 | _userInputService = editDataModel:GetService("UserInputService"), 36 | _changeHistoryService = editDataModel:GetService("ChangeHistoryService"), 37 | _mouse = plugin:GetMouse(), 38 | _selection = selection, 39 | LocaleChangedSignal = editDataModel:GetService("StudioService"):GetPropertyChangedSignal("StudioLocaleId"), 40 | _fallbackTranslators = {}, 41 | _translators = {}, 42 | }, DraggerContext) 43 | end 44 | 45 | -- What instance should the plugin's GUI objects get created under? 46 | function DraggerContext:getGuiParent() 47 | return self._editDataModel:GetService("CoreGui") 48 | end 49 | 50 | function DraggerContext:setHoverInstance(instance) 51 | --self._studioService.HoverInstance = instance 52 | end 53 | 54 | function DraggerContext:shouldUseLocalSpace() 55 | return self._studioService.UseLocalSpace 56 | end 57 | 58 | function DraggerContext:areCollisionsEnabled() 59 | return self._plugin.CollisionEnabled 60 | end 61 | 62 | function DraggerContext:areConstraintsEnabled() 63 | return self._studioService.DraggerSolveConstraints 64 | end 65 | 66 | function DraggerContext:areConstraintDetailsShown() 67 | return self._studioService.ShowConstraintDetails 68 | end 69 | 70 | function DraggerContext:shouldDrawConstraintsOnTop() 71 | return self._studioService.DrawConstraintsOnTop 72 | end 73 | 74 | function DraggerContext:shouldJoinSurfaces() 75 | return self._plugin:GetJoinMode() ~= Enum.JointCreationMode.None 76 | end 77 | 78 | function DraggerContext:shouldShowHover() 79 | return self._studioSettings["Show Hover Over"] 80 | end 81 | 82 | function DraggerContext:shouldAnimateHover() 83 | return self._studioSettings["Animate Hover Over"] 84 | end 85 | 86 | function DraggerContext:shouldSelectScopeByDefault() 87 | return self._studioSettings["Physical Draggers Select Scope By Default"] 88 | end 89 | 90 | function DraggerContext:getHoverAnimationSpeedInSeconds() 91 | local speed = self._studioSettings["Hover Animate Speed"] 92 | if speed == Enum.HoverAnimateSpeed.VerySlow then 93 | return 2 94 | elseif speed == Enum.HoverAnimateSpeed.Slow then 95 | return 1 96 | elseif speed == Enum.HoverAnimateSpeed.Medium then 97 | return 0.5 98 | elseif speed == Enum.HoverAnimateSpeed.Fast then 99 | return 0.25 100 | elseif speed == Enum.HoverAnimateSpeed.VeryFast then 101 | return 0.1 102 | end 103 | return 0 104 | end 105 | 106 | function DraggerContext:getHoverBoxColor(isActive) 107 | if isActive then 108 | return self._studioSettings["Active Hover Over Color"] 109 | else 110 | return self._studioSettings["Hover Over Color"] 111 | end 112 | end 113 | 114 | function DraggerContext:getHoverLineThickness() 115 | if getFFlagSummonPivot() then 116 | return self._draggerService.HoverThickness 117 | else 118 | return 0.04 119 | end 120 | end 121 | 122 | function DraggerContext:getSelectionBoxColor(isActive) 123 | if isActive then 124 | return self._studioSettings["Active Color"] 125 | else 126 | return self._studioSettings["Select Color"] 127 | end 128 | end 129 | 130 | function DraggerContext:getGeometrySnapColor() 131 | return self._draggerService.GeometrySnapColor 132 | end 133 | 134 | function DraggerContext:getCameraCFrame() 135 | return self._workspace.CurrentCamera.CFrame 136 | end 137 | 138 | function DraggerContext:getMouseUnitRay() 139 | return self._mouse.UnitRay 140 | end 141 | 142 | function DraggerContext:getHandleScale(focusPoint) 143 | local distance = (self:getCameraCFrame().Position - focusPoint).Magnitude 144 | local angleFrac = math.sin(math.rad(self._workspace.CurrentCamera.FieldOfView)) 145 | return angleFrac * distance * HANDLE_SCALE_FACTOR 146 | end 147 | 148 | function DraggerContext:getMouseRay() 149 | local unitRay = self:getMouseUnitRay() 150 | return Ray.new(unitRay.Origin, unitRay.Direction * RAYCAST_DIRECTION_SCALE) 151 | end 152 | 153 | function DraggerContext:getMouseLocation() 154 | return self._userInputService:GetMouseLocation() 155 | end 156 | 157 | function DraggerContext:viewportPointToRay(mouseLocation) 158 | return self._workspace.CurrentCamera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y) 159 | end 160 | 161 | function DraggerContext:worldToViewportPoint(worldPoint) 162 | return self._workspace.CurrentCamera:WorldToViewportPoint(worldPoint) 163 | end 164 | 165 | function DraggerContext:getViewportSize() 166 | return self._workspace.CurrentCamera.ViewportSize 167 | end 168 | 169 | function DraggerContext:setMouseIcon(icon) 170 | self._mouse.Icon = icon 171 | end 172 | 173 | function DraggerContext:getSelection() 174 | return self._selection 175 | end 176 | 177 | -- Are non-anchored parts in the world currently being physically simulated? 178 | -- (i.e. are they moving around of thier own accord) 179 | function DraggerContext:isSimulating() 180 | return self._runService:IsRunning() 181 | end 182 | 183 | function DraggerContext:isAltKeyDown() 184 | return self._userInputService:IsKeyDown(Enum.KeyCode.LeftAlt) or 185 | self._userInputService:IsKeyDown(Enum.KeyCode.RightAlt) 186 | end 187 | 188 | function DraggerContext:isCtrlKeyDown() 189 | return self._userInputService:IsKeyDown(Enum.KeyCode.LeftControl) or 190 | self._userInputService:IsKeyDown(Enum.KeyCode.RightControl) 191 | end 192 | 193 | function DraggerContext:isShiftKeyDown() 194 | return self._userInputService:IsKeyDown(Enum.KeyCode.LeftShift) or 195 | self._userInputService:IsKeyDown(Enum.KeyCode.RightShift) 196 | end 197 | 198 | function DraggerContext:shouldExtendSelection() 199 | return self:isShiftKeyDown() 200 | end 201 | 202 | function DraggerContext:getGridSize() 203 | return self._studioService.GridSize 204 | end 205 | 206 | -- Wrapped in a function because of the awkward situation where there's no 207 | -- explicit way to determine whether grid snapping is on or off right now, and 208 | -- the implementation of this may change once there is. 209 | -- Currently DISABLED_GRID_SIZE is what StudioService returns when grid snapping 210 | -- is disabled, so detect based on that. 211 | local DISABLED_GRID_SIZE = 0.01 212 | function DraggerContext:snapToGridSize(distance) 213 | local gridSize = self._studioService.GridSize 214 | if math.abs(gridSize - DISABLED_GRID_SIZE) < 0.001 then 215 | return distance 216 | else 217 | return math.floor(distance / gridSize + 0.5) * gridSize 218 | end 219 | end 220 | 221 | function DraggerContext:getRotateIncrement() 222 | return self._studioService.RotateIncrement 223 | end 224 | 225 | function DraggerContext:getAnalytics() 226 | return Analytics 227 | end 228 | 229 | function DraggerContext:gizmoRaycast(origin, direction, raycastParams) 230 | return self._studioService:GizmoRaycast(origin, direction, raycastParams) 231 | end 232 | 233 | function DraggerContext:setInsertPoint(location) 234 | setInsertPoint(location) 235 | end 236 | 237 | function DraggerContext:shouldShowActiveInstanceHighlight() 238 | return false --self._studioService.ShowActiveInstanceHighlight 239 | end 240 | 241 | function DraggerContext:shouldAlignDraggedObjects() 242 | return true --self._studioService.AlignDraggedObjects 243 | end 244 | 245 | function DraggerContext:addUndoWaypoint(waypointIdentifier, waypointText) 246 | -- Nothing to do with waypoint text currently, but we will need to do 247 | -- something with localizing undo waypoints eventually. 248 | self._changeHistoryService:SetWaypoint(waypointIdentifier) 249 | end 250 | 251 | -- TODO mlangen: Share this code with DevFramework somehow. We don't want to 252 | -- include the entirety of DevFramework just to translate a couple of strings, 253 | -- but we do really want to share just the localization part of DevFramework. 254 | -- For now, this code does mostly the same thing as DevFramework's Localization 255 | -- class. 256 | function DraggerContext:getText(scope, key, args) 257 | key = ("Studio.DraggerFramework.%s.%s"):format(scope, key) 258 | local locale = self._studioService.StudioLocaleId 259 | if locale == FALLBACK_LOCALE then 260 | local fallbackTranslator = self._fallbackTranslators[locale] 261 | if not fallbackTranslator then 262 | fallbackTranslator = FallbackLocalizationTable:GetTranslator(FALLBACK_LOCALE) 263 | self._fallbackTranslators[locale] = fallbackTranslator 264 | end 265 | return fallbackTranslator:FormatByKey(key, args) 266 | else 267 | local referenceTranslator = self._translators[locale] 268 | if not referenceTranslator then 269 | referenceTranslator = TranslatedLocalizationTable:GetTranslator(locale) 270 | self._translators[locale] = referenceTranslator 271 | end 272 | local success, result = pcall(function() 273 | return referenceTranslator:FormatByKey(key, args) 274 | end) 275 | if success then 276 | return result 277 | else 278 | local fallbackTranslator = self._fallbackTranslators[locale] 279 | if not fallbackTranslator then 280 | fallbackTranslator = FallbackLocalizationTable:GetTranslator(FALLBACK_LOCALE) 281 | self._fallbackTranslators[locale] = fallbackTranslator 282 | end 283 | return fallbackTranslator:FormatByKey(key, args) 284 | end 285 | end 286 | end 287 | 288 | -- Takes a StudioStyleGuideColor enum 289 | function DraggerContext:getThemeColor(item) 290 | return self._studioSettings.Theme:GetColor(item) 291 | end 292 | 293 | function DraggerContext:getSetting(name) 294 | return self._plugin:GetSetting(name) 295 | end 296 | 297 | function DraggerContext:setSetting(name, value) 298 | self._plugin:SetSetting(name, value) 299 | end 300 | 301 | function DraggerContext:setPivotIndicator(state) 302 | local oldValue = self._draggerService.ShowPivotIndicator 303 | self._draggerService.ShowPivotIndicator = state 304 | return oldValue 305 | end 306 | 307 | return DraggerContext -------------------------------------------------------------------------------- /src/Implementation/DraggerStates/Ready.lua: -------------------------------------------------------------------------------- 1 | local DraggerFramework = script.Parent.Parent.Parent 2 | local Packages = DraggerFramework.Parent 3 | 4 | local Roact = require(Packages.Roact) 5 | local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) 6 | local AnimatedHoverBox = require(DraggerFramework.Components.AnimatedHoverBox) 7 | local LocalSpaceIndicator = require(DraggerFramework.Components.LocalSpaceIndicator) 8 | local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) 9 | local getGeometry = require(DraggerFramework.Utility.getGeometry) 10 | local getFaceInstance = require(DraggerFramework.Utility.getFaceInstance) 11 | local HoverTracker = require(DraggerFramework.Implementation.HoverTracker) 12 | local StandardCursor = require(DraggerFramework.Utility.StandardCursor) 13 | 14 | local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) 15 | 16 | local getFFlagFlippedScopeSelect = require(DraggerFramework.Flags.getFFlagFlippedScopeSelect) 17 | 18 | local getFFlagUseGetBoundingBox = require(DraggerFramework.Flags.getFFlagUseGetBoundingBox) 19 | 20 | local Ready = {} 21 | Ready.__index = Ready 22 | 23 | function Ready.new(draggerToolModel) 24 | return setmetatable({ 25 | _draggerToolModel = draggerToolModel 26 | }, Ready) 27 | end 28 | 29 | function Ready:enter() 30 | local function onHoverExternallyChanged() 31 | self._draggerToolModel:_processViewChanged() 32 | end 33 | self._hoverTracker = 34 | HoverTracker.new( 35 | self._draggerToolModel:getSchema(), 36 | self._draggerToolModel:getHandlesList(), 37 | onHoverExternallyChanged) 38 | self:_updateHoverTracker() 39 | end 40 | 41 | function Ready:leave() 42 | self._hoverTracker:clearHover(self._draggerToolModel._draggerContext) 43 | end 44 | 45 | function Ready:render() 46 | local elements = {} 47 | 48 | local draggerContext = self._draggerToolModel._draggerContext 49 | 50 | local hoverSelectable = self._hoverTracker:getHoverSelectable() 51 | if draggerContext:shouldShowHover() and hoverSelectable then 52 | -- Calls to the schema to know what kind of component to render the 53 | -- selection box for the hovered object with. 54 | local component = self._draggerToolModel:getSchema().getSelectionBoxComponent( 55 | draggerContext, hoverSelectable) 56 | if component then 57 | local animatePeriod 58 | if draggerContext:shouldAnimateHover() then 59 | animatePeriod = draggerContext:getHoverAnimationSpeedInSeconds() 60 | end 61 | 62 | local isActive = false 63 | if draggerContext:shouldShowActiveInstanceHighlight() then 64 | local activeInstance = 65 | self._draggerToolModel:getSelectionWrapper():getActiveSelectable() 66 | isActive = (hoverSelectable == activeInstance) 67 | end 68 | 69 | elements.HoverBox = Roact.createElement(AnimatedHoverBox, { 70 | -- Configurable component to render the selection box 71 | SelectionBoxComponent = component, 72 | HoverTarget = hoverSelectable, 73 | SelectColor = draggerContext:getSelectionBoxColor(isActive), 74 | LineThickness = draggerContext:getHoverLineThickness(), 75 | HoverColor = draggerContext:getHoverBoxColor(isActive), 76 | AnimatePeriod = animatePeriod, 77 | }) 78 | end 79 | end 80 | 81 | if hoverSelectable or self._hoverTracker:getHoverHandleId() then 82 | self._draggerToolModel:setMouseCursor(StandardCursor.getOpenHand()) 83 | else 84 | self._draggerToolModel:setMouseCursor(StandardCursor.getArrow()) 85 | end 86 | 87 | if self._draggerToolModel:shouldShowLocalSpaceIndicator() then 88 | local selectionInfo = self._draggerToolModel._selectionInfo 89 | if not selectionInfo:isEmpty() and draggerContext:shouldUseLocalSpace() then 90 | local cframe, offset, size 91 | if getFFlagUseGetBoundingBox() then 92 | cframe, offset, size = selectionInfo:getBoundingBox() 93 | else 94 | cframe, offset, size = selectionInfo:getLocalBoundingBox() 95 | end 96 | 97 | elements.LocalSpaceIndicator = Roact.createElement(LocalSpaceIndicator, { 98 | CFrame = cframe * CFrame.new(offset), 99 | Size = size, 100 | TextColor3 = draggerContext:getSelectionBoxColor(), 101 | DraggerContext = draggerContext, 102 | }) 103 | end 104 | end 105 | 106 | local hoverHandles, hoverHandleId = self._hoverTracker:getHoverHandleId() 107 | for i, handles in pairs(self._draggerToolModel:getHandlesList()) do 108 | elements["ImplementationUI" .. i] = 109 | handles:render(hoverHandles == handles and hoverHandleId or nil) 110 | end 111 | 112 | return Roact.createFragment(elements) 113 | end 114 | 115 | function Ready:processSelectionChanged() 116 | -- We expect selection changes while in the ready state 117 | -- when the developer selects objects in the explorer window. 118 | self:_updateHoverTracker() 119 | end 120 | 121 | --[[ 122 | Find the clicked part or constraint system gizmo by raycasting with the 123 | current mouse location and decide what action to take: 124 | 125 | * If the clicked instance is added to or was already in the selection, begin 126 | (maybe) freeform dragging the selected parts. 127 | * If no selectable instance was clicked, begin drag selecting. 128 | * When an Attachment is clicked without a selection modifier key pressed, 129 | begin (maybe) freeform dragging that Attachment. 130 | * When a Constraint is clicked, select it but don't do any form of drag. 131 | ]] 132 | function Ready:processMouseDown(isDoubleClick) 133 | -- We have to do an update here for the edge case where the 3D view just 134 | -- became selected thanks to the mouse down event, so we haven't received 135 | -- a view change event yet. 136 | self:_updateHoverTracker() 137 | 138 | local hoverHandles, hoverHandleId = self._hoverTracker:getHoverHandleId() 139 | if hoverHandleId then 140 | self._draggerToolModel:transitionToState(DraggerStateType.DraggingHandle, 141 | hoverHandles, hoverHandleId) 142 | else 143 | self:_clickInWorld(isDoubleClick) 144 | end 145 | end 146 | 147 | function Ready:processViewChanged() 148 | self:_updateHoverTracker() 149 | end 150 | 151 | function Ready:processMouseUp() 152 | -- Nothing to do. This case can ocurr when the user clicks on a constraint. 153 | end 154 | 155 | function Ready:_scopeSelectChanged() 156 | if self._hoverTracker:getHoverItem() ~= nil then 157 | self:_updateHoverTracker() 158 | self._draggerToolModel:_scheduleRender() 159 | end 160 | end 161 | 162 | function Ready:processKeyDown(keyCode) 163 | if getFFlagFlippedScopeSelect() then 164 | if keyCode == Enum.KeyCode.LeftAlt or keyCode == Enum.KeyCode.RightAlt then 165 | self:_scopeSelectChanged() 166 | end 167 | end 168 | 169 | if getFFlagSummonPivot() then 170 | for _, handles in pairs(self._draggerToolModel:getHandlesList()) do 171 | if handles.keyDown then 172 | if handles:keyDown(keyCode) then 173 | self:processViewChanged() 174 | self._draggerToolModel:_scheduleRender() 175 | end 176 | end 177 | end 178 | end 179 | end 180 | 181 | function Ready:processKeyUp(keyCode) 182 | if getFFlagFlippedScopeSelect() then 183 | if keyCode == Enum.KeyCode.LeftAlt or keyCode == Enum.KeyCode.RightAlt then 184 | self:_scopeSelectChanged() 185 | end 186 | end 187 | 188 | if getFFlagSummonPivot() then 189 | for _, handles in pairs(self._draggerToolModel:getHandlesList()) do 190 | if handles.keyUp then 191 | if handles:keyUp(keyCode) then 192 | self:processViewChanged() 193 | self._draggerToolModel:_scheduleRender() 194 | end 195 | end 196 | end 197 | end 198 | end 199 | 200 | function Ready:_updateHoverTracker() 201 | self._hoverTracker:update( 202 | self._draggerToolModel._draggerContext, 203 | self._draggerToolModel:getSelectionWrapper():get(), 204 | self._draggerToolModel._selectionInfo) 205 | end 206 | 207 | local function contains(list, targetItem) 208 | for _, item in ipairs(list) do 209 | if item == targetItem then 210 | return true 211 | end 212 | end 213 | return false 214 | end 215 | 216 | --[[ 217 | Called when the user clicking in the 3d space (not on a handle) 218 | ]] 219 | function Ready:_clickInWorld(isDoubleClick) 220 | local draggerContext = self._draggerToolModel._draggerContext 221 | local clickedItem, position = self._hoverTracker:getHoverItem() 222 | local clickedSelectable = self._hoverTracker:getHoverSelectable() 223 | local oldSelection = self._draggerToolModel:getSelectionWrapper():get() 224 | local selectionDidContainSelectable = contains(oldSelection, clickedSelectable) 225 | local shouldExtendSelection = draggerContext:shouldExtendSelection() 226 | if isDoubleClick and selectionDidContainSelectable then 227 | -- Special case. Double clicking with extend selection would be a 228 | -- no-op, thanks to adding something to the selection and then 229 | -- immediately removing it again. 230 | -- Prevent this. This also allows us to handle double clicks with 231 | -- shouldExtendSelection activated as a getNextSelectable invocation. 232 | shouldExtendSelection = false 233 | end 234 | local isExclusiveSelectable = 235 | (clickedSelectable ~= nil) and 236 | self._draggerToolModel:getSchema().isExclusiveSelectable( 237 | draggerContext, clickedSelectable, clickedItem) 238 | local selectionDidChange, newSelection, hint = 239 | SelectionHelper.updateSelection( 240 | clickedSelectable, oldSelection, 241 | isExclusiveSelectable, 242 | shouldExtendSelection) 243 | if selectionDidChange then 244 | self._draggerToolModel:getSelectionWrapper():set(newSelection, hint) 245 | 246 | -- Process selection changed only gets called automatically when studio 247 | -- changes the selection, since we just changed the selection manually 248 | -- we need to invoke it here. 249 | self._draggerToolModel:_processSelectionChanged() 250 | 251 | -- If we have objects to transform, then change the insert point to 252 | -- the selection's center. This makes it easier to paste and insert 253 | -- objects at the position of a target object. 254 | self._draggerToolModel:getSchema().setActivePoint( 255 | self._draggerToolModel._draggerContext, 256 | self._draggerToolModel._selectionInfo) 257 | end 258 | 259 | self._draggerToolModel:_analyticsSendClick(clickedItem, selectionDidChange) 260 | 261 | local selectionEvent = { 262 | DoubleClicked = isDoubleClick, 263 | ClickedSelectable = clickedSelectable, 264 | ClickedItem = clickedItem, 265 | ClickedPosition = position, 266 | SelectionDidContainSelectable = selectionDidContainSelectable, 267 | SelectionNowContainsSelectable = contains(newSelection, clickedSelectable), 268 | } 269 | local nextState, extraData = 270 | self._draggerToolModel:getSchema().dispatchWorldClick( 271 | self._draggerToolModel._draggerContext, 272 | self._draggerToolModel, 273 | selectionEvent) 274 | 275 | if nextState == "Ready" then 276 | if clickedSelectable and (not selectionDidChange or isDoubleClick) then 277 | self._draggerToolModel:transitionToState( 278 | DraggerStateType.PendingSelectNext, isDoubleClick, selectionEvent) 279 | else 280 | -- Nothing to do, stay in ready state 281 | end 282 | elseif nextState == "DragSelecting" then 283 | if self._draggerToolModel:doesAllowDragSelect() then 284 | self._draggerToolModel:transitionToState(DraggerStateType.DragSelecting) 285 | end 286 | elseif nextState == "FreeformSelectionDrag" then 287 | self._draggerToolModel:transitionToState( 288 | DraggerStateType.PendingDraggingParts, isDoubleClick, extraData) 289 | else 290 | error("Bad state returned from dispatchWorldClick: `" .. tostring(nextState) .. "`") 291 | end 292 | end 293 | 294 | return Ready -------------------------------------------------------------------------------- /src/Utility/JointPairs.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | 3 | local DraggerFramework = script.Parent.Parent 4 | local Packages = DraggerFramework.Parent 5 | local Roact = require(Packages.Roact) 6 | local Colors = require(DraggerFramework.Utility.Colors) 7 | local Math = require(DraggerFramework.Utility.Math) 8 | 9 | local JointPairs = {} 10 | JointPairs.__index = JointPairs 11 | 12 | local JointTypeToColor = { 13 | Rotate = Colors.RotatingJoint, 14 | RotateV = Colors.RotatingJoint, 15 | RotateP = Colors.RotatingJoint, 16 | Weld = Colors.WeldJoint, 17 | None = Colors.InvalidJoint, 18 | } 19 | 20 | --[[ 21 | How far away from a part to look for other parts to join to. This is purely 22 | an optimization setting, as long as it is larger than JOINT_TOLERANCE it 23 | won't change the PartMover behavior. 24 | ]] 25 | local FUZZY_TOLERANCE = 0.1 26 | 27 | --[[ 28 | How close together the parallel faces of two parts have to be for a joint 29 | to be made between them. 30 | ]] 31 | local JOINT_TOLERANCE = 0.05 32 | 33 | --[[ 34 | How close the dot product of two face normals has to be for a joint to be 35 | made between them. 36 | ]] 37 | local JOINT_ANGLE_TOLERANCE = 0.001 38 | 39 | local function isVertexInFace(vert, face, normal) 40 | for i = 1, #face do 41 | local e1, e2; 42 | if i == 1 then 43 | e1 = face[#face] 44 | e2 = face[1] 45 | else 46 | e1 = face[i - 1] 47 | e2 = face[i] 48 | end 49 | local edge = e2 - e1 50 | local to = vert - e1 51 | -- TODO: Fix this. For very large parts this will lead to erroneous 52 | -- misses because we're comparing to an angle tolerance rather than 53 | -- a distance tolerance by having a tolerance here 54 | if edge:Cross(to):Dot(normal) < 0.01 then 55 | return false 56 | end 57 | end 58 | return true 59 | end 60 | 61 | local function faceHasVertsInFace(face, containingFace, normal) 62 | for _, vert in ipairs(face) do 63 | if isVertexInFace(vert, containingFace, normal) then 64 | return true 65 | end 66 | end 67 | return false 68 | end 69 | 70 | local function edgeIntersectsEdge(a1, a2, b1, b2, bnormal) 71 | local arun, brun = (a2 - a1), (b2 - b1) 72 | local alen, blen = arun.Magnitude, brun.Magnitude 73 | local edgeDot = arun:Dot(brun) / alen / blen 74 | local athres = 0.01 / alen 75 | local bthres = 0.01 / blen 76 | 77 | --[[ 78 | More common case, edges coincident over a non-zero length section. 79 | We also have a consistent winding direction on our geometry, so if the 80 | edges are going in the same direction, they are from an edge that is 81 | touching but not thanks to a touching face. For example: 82 | [] 83 | [] Those two blocks touch at one edge, but not at any faces. 84 | ]] 85 | if math.abs(math.abs(edgeDot) - 1) < 0.0001 then 86 | --parallel case 87 | if edgeDot > 0 then 88 | -- They are going in the same direction, they can't be 89 | -- an edge pair to join on 90 | return false 91 | else 92 | -- They are going in opposite directions, this might be an exactly 93 | -- coincident edge. 94 | 95 | -- First check if they are cooincident 96 | local inB = (a1 - b1):Dot(brun) / blen / blen 97 | local dist = (a1 - (b1 + inB * brun)).Magnitude 98 | if dist > 0.001 then 99 | return false 100 | end 101 | 102 | -- Now we need to see if they overlap 103 | local inB2 = (a2 - b1):Dot(brun) / blen / blen 104 | local interval = math.clamp(inB, 0, 1) - math.clamp(inB2, 0, 1) 105 | return interval > 0.001 -- They are coincident and share an interval 106 | end 107 | end 108 | 109 | --[[ 110 | Less common case, edges that cross one and other. Imagine two long thin 111 | parts arranged in an X shape. They should be joined, and that joint comes 112 | from this case, where their edges intersect at the middle of the X. 113 | ]] 114 | local intersects, s = Math.intersectRayRay(a1, arun, b1, brun) 115 | if not intersects or s < athres or s > 1 - athres then 116 | return false 117 | end 118 | local intersects2, t = Math.intersectRayRay(b1, brun, a1, arun) 119 | assert(intersects2) -- Must be true if intersects was true 120 | return t >= bthres and t <= 1 - bthres 121 | end 122 | 123 | local function edgesIntersectsEdges(face, otherFace, otherNormal) 124 | for i = 1, #face do 125 | local a1, a2; 126 | if i == 1 then 127 | a1, a2 = face[#face], face[1] 128 | else 129 | a1, a2 = face[i - 1], face[i] 130 | end 131 | for j = 1, #otherFace do 132 | local b1, b2; 133 | if j == 1 then 134 | b1, b2 = otherFace[#otherFace], otherFace[1] 135 | else 136 | b1, b2 = otherFace[j - 1], otherFace[j] 137 | end 138 | if edgeIntersectsEdge(a1, a2, b1, b2, otherNormal) then 139 | return true 140 | end 141 | end 142 | end 143 | return false 144 | end 145 | 146 | local function canMakeJointBetweenFaces(part, face, otherPart, otherFace, otherNormal) 147 | -- Can join if there are any touching faces. This can be divided into: 148 | -- 1. Obviously touching if verts of one face are contained in the other face 149 | -- 2. If edges of one face intersect the other, for example, when two long 150 | -- parts are arranged in an X shape. 151 | return faceHasVertsInFace(face.vertices, otherFace.vertices, otherNormal) or 152 | faceHasVertsInFace(otherFace.vertices, face.vertices, -otherNormal) or 153 | edgesIntersectsEdges(face.vertices, otherFace.vertices, otherNormal) 154 | end 155 | 156 | local function getFaceCenter(face) 157 | if not face.center then 158 | local total = Vector3.new() 159 | for _, vert in ipairs(face.vertices) do 160 | total = total + vert 161 | end 162 | face.center = total / #face.vertices 163 | end 164 | return face.center 165 | end 166 | 167 | local function buildJoint(part0, face0, part1, jointType) 168 | -- Determine the C0 and C1 for the joint. Center it around the center of 169 | -- the relevant face, since that's where the surface axis gizmo appears. 170 | local centerCFrame = 171 | CFrame.fromMatrix( 172 | getFaceCenter(face0), 173 | face0.direction, face0.normal:Cross(face0.direction)) 174 | return { 175 | ClassName = jointType, 176 | Part0 = part0, 177 | C0 = part0.CFrame:Inverse() * centerCFrame, 178 | Part1 = part1, 179 | C1 = part1.CFrame:Inverse() * centerCFrame, 180 | } 181 | end 182 | 183 | local function buildInvalidJoint() 184 | return { 185 | ClassName = "None", 186 | } 187 | end 188 | 189 | --[[ 190 | Called once we already know that there is a joint between two faces, but 191 | don't know what _kind_ of joint it is yet or which part will be the 192 | part0 and which will be the part1. 193 | 194 | `part` is the part being moved, and its face surface type takes priority 195 | as far as determining the type of joint. 196 | ]] 197 | local function buildAppropriateJoint(part, otherPart, shape, otherShape, face, otherFace) 198 | local isFaceAcceptable = 199 | (shape == "Mesh") or 200 | (shape == "Cylinder" and 201 | (face.surface == "RightSurface" or face.surface == "LeftSurface")) 202 | 203 | local isOtherFaceAcceptable = 204 | (otherShape == "Mesh") or 205 | (otherShape == "Cylinder" and 206 | (otherFace.surface == "RightSurface" or otherFace.surface == "LeftSurface")) 207 | 208 | if isFaceAcceptable and isOtherFaceAcceptable then 209 | return buildJoint(part, face, otherPart, "Weld") 210 | else 211 | -- Only planar meshes and the flat ends of cylinders are allowed to 212 | -- form surface welds. 213 | return nil 214 | end 215 | end 216 | 217 | --[[ 218 | Check if a joint is possible between two specific parts and return the joint 219 | if there is a possible joint. 220 | Updates facesToHighlightSet with both the possible joint if there is one, 221 | and with an invalid joint if there's touching faces that can't be joined. 222 | ]] 223 | local function tryToCreateJointPair(transform, part, otherPart, facesToHighlightSet, 224 | getGeometryFunction, isAHumanoidModelFunction) 225 | local partGeometry = getGeometryFunction(part) 226 | local otherGeometry = getGeometryFunction(otherPart) 227 | 228 | -- Transform faces of the 229 | local tempTransformedFaces = {} 230 | 231 | -- Easy case first, just handle mesh-mesh for now 232 | for _, partFace in ipairs(partGeometry.faces) do 233 | for _, otherFace in ipairs(otherGeometry.faces) do 234 | local transformedNormal = transform:VectorToWorldSpace(partFace.normal) 235 | local transformedDirection = transform:VectorToWorldSpace(partFace.direction) 236 | local dot = transformedNormal:Dot(otherFace.normal) 237 | if dot < -(1 - JOINT_ANGLE_TOLERANCE) then 238 | local newPoint = transform:PointToWorldSpace(partFace.point) 239 | local dist = (newPoint - otherFace.point):Dot(otherFace.normal) 240 | if math.abs(dist) < JOINT_TOLERANCE then 241 | local tempTransformedFace = tempTransformedFaces[partFace] 242 | if not tempTransformedFace then 243 | local transformedFaceVerts = {} 244 | for _, vertex in ipairs(partFace.vertices) do 245 | table.insert(transformedFaceVerts, transform * vertex) 246 | end 247 | tempTransformedFace = { 248 | id = partFace.id, 249 | vertices = transformedFaceVerts, 250 | normal = transformedNormal, 251 | direction = transformedDirection, 252 | surface = partFace.surface, 253 | -- Note: There are more fields, we're only copying 254 | -- over the modified ones we need. 255 | } 256 | tempTransformedFaces[partFace] = tempTransformedFace 257 | end 258 | local canJoin = canMakeJointBetweenFaces( 259 | part, tempTransformedFace, 260 | otherPart, otherFace, 261 | otherFace.normal) 262 | if canJoin then 263 | -- The faces are aligned and close to cooincident 264 | local joint = buildAppropriateJoint( 265 | part, otherPart, 266 | partGeometry.shape, otherGeometry.shape, 267 | tempTransformedFace, otherFace) 268 | if joint and not isAHumanoidModelFunction(part.Parent) and 269 | not isAHumanoidModelFunction(otherPart.Parent) then 270 | -- This if exists because of the extra weird case of 271 | -- surface hinges/motors blocking joints on the rest 272 | -- of the surface that they're on. 273 | -- Also block joints between parts when either part 274 | -- is inside of a humanoid. 275 | facesToHighlightSet[tempTransformedFace] = joint 276 | facesToHighlightSet[otherFace] = joint 277 | return true, joint 278 | else 279 | -- If the joint doesn't exist, we draw an invalid 280 | -- joint pair (red) between the parts. 281 | joint = buildInvalidJoint() 282 | facesToHighlightSet[tempTransformedFace] = joint 283 | facesToHighlightSet[otherFace] = joint 284 | return false 285 | end 286 | end 287 | end 288 | end 289 | end 290 | end 291 | return false 292 | end 293 | 294 | --[[ 295 | Compute the joint pairs between a collection of parts and the rest of the 296 | world. Return a JointPairs object storing information about the computed 297 | joints for further use. 298 | 299 | partList - A list of the parts 300 | partSet -- The same parts, organized as a set rather than a list. 301 | rootPartSet -- A set of the root parts of the parts in the partList. 302 | globalTransform -- 303 | A transform to apply to the partSet, including any getGeometry results 304 | returned for them. 305 | alreadyConnectedToSets -- 306 | alreadyConnectedToSets[part][otherPart] is true if part and other are 307 | already connected with a joint. 308 | getGeometryFunction -- 309 | A function that takes a part and returns the getGeometry() for it. 310 | ]] 311 | function JointPairs.new(partList, partSet, rootPartSet, globalTransform, alreadyConnectedToSets, getGeometryFunction) 312 | local self = setmetatable({}, JointPairs) 313 | 314 | local isAHumanoidModel = {} 315 | local function isAHumanoidModelFunction(object) 316 | if not object then 317 | return false 318 | end 319 | local status = isAHumanoidModel[object] 320 | if not status then 321 | status = 322 | (object:FindFirstChildWhichIsA("Humanoid") ~= nil) or 323 | isAHumanoidModelFunction(object.Parent) 324 | isAHumanoidModel[object] = status 325 | end 326 | return status 327 | end 328 | 329 | local terrain = Workspace.Terrain 330 | local facesToHighlightSet = {} 331 | local jointPairs = {} 332 | 333 | for _, part in ipairs(partList) do 334 | local radius = part.Size.Magnitude / 2 335 | local radiusVector = 336 | Vector3.new(radius + FUZZY_TOLERANCE, radius + FUZZY_TOLERANCE, radius + FUZZY_TOLERANCE) 337 | local nearbyParts = Workspace:FindPartsInRegion3WithIgnoreList( 338 | Region3.new(part.Position - radiusVector, part.Position + radiusVector), 339 | {}, --partList could go here but costs too much to repeatedly reflect 340 | 10000) 341 | 342 | -- Terrain joint case. If we are touching the terrain at all then 343 | -- create a joint pair with it, regardless of surface geometry. 344 | for _, touchingPart in ipairs(part:GetTouchingParts()) do 345 | if touchingPart == terrain then 346 | if not alreadyConnectedToSets[part][terrain] then 347 | table.insert(jointPairs, { 348 | ClassName = "Weld", 349 | C0 = CFrame.new(), 350 | C1 = part.CFrame, 351 | Part0 = part, 352 | Part1 = terrain, 353 | }) 354 | end 355 | break 356 | end 357 | end 358 | 359 | for _, otherPart in ipairs(nearbyParts) do 360 | if not partSet[otherPart] and not rootPartSet[otherPart:GetRootPart()] then 361 | if otherPart ~= terrain then 362 | local otherRadius = otherPart.Size.Magnitude / 2 363 | if (otherPart.Position - part.Position).Magnitude <= (radius + otherRadius) + JOINT_TOLERANCE then 364 | -- This is a very uncommon condition (happens only for 365 | -- constraints joining a part in the selection and 366 | -- one not in the selection and only when those two parts 367 | -- are touching) which is why I put it innermost. 368 | if not alreadyConnectedToSets[part][otherPart] then 369 | local success, joint = tryToCreateJointPair(globalTransform, 370 | part, otherPart, 371 | facesToHighlightSet, 372 | getGeometryFunction, isAHumanoidModelFunction) 373 | if success then 374 | table.insert(jointPairs, joint) 375 | end 376 | end 377 | end 378 | end 379 | end 380 | end 381 | end 382 | 383 | self._jointPairs = jointPairs 384 | self._facesToHighlightSet = facesToHighlightSet 385 | 386 | return self 387 | end 388 | 389 | --[[ 390 | Return a Roact component visually displaying the joint pairs to be created. 391 | ]] 392 | function JointPairs:renderJoints(scale) 393 | local faceViews = {} 394 | for face, joint in pairs(self._facesToHighlightSet) do 395 | local edgeViews = {} 396 | for i = 1, #face.vertices do 397 | local v0, v1; 398 | if i == 1 then 399 | v0, v1 = face.vertices[#face.vertices], face.vertices[1] 400 | else 401 | v0, v1 = face.vertices[i - 1], face.vertices[i] 402 | end 403 | local edgeLength = (v0 - v1).Magnitude 404 | edgeViews[i] = Roact.createElement("CylinderHandleAdornment", { 405 | CFrame = CFrame.new(v0, v1) * CFrame.new(0, 0, -edgeLength / 2) * CFrame.Angles(0, 0, math.pi / 2), 406 | Color3 = JointTypeToColor[joint.ClassName], 407 | Radius = 0.05 + 0.05 * scale, 408 | Height = edgeLength, 409 | Adornee = Workspace.Terrain 410 | }) 411 | end 412 | 413 | -- Using the face table's hash as key here. There's no other good way to 414 | -- uniquely identify the face, because they key is really the part that 415 | -- the face is being shown for, but we can't use a part as a key here. 416 | faceViews[tostring(face)] = Roact.createElement("Folder", {}, edgeViews) 417 | end 418 | return Roact.createFragment(faceViews) 419 | end 420 | 421 | --[[ 422 | Create the joint pairs and parent them to the Workspace. 423 | ]] 424 | function JointPairs:createJoints() 425 | for _, joint in ipairs(self._jointPairs) do 426 | local jointInstance = Instance.new(joint.ClassName) 427 | jointInstance.Part0 = joint.Part0 428 | jointInstance.C0 = joint.C0 429 | jointInstance.Part1 = joint.Part1 430 | jointInstance.C1 = joint.C1 431 | jointInstance.Parent = joint.Part0 432 | end 433 | end 434 | 435 | return JointPairs -------------------------------------------------------------------------------- /src/Handles/RotateHandles.lua: -------------------------------------------------------------------------------- 1 | local Workspace = game:GetService("Workspace") 2 | 3 | -- Libraries 4 | local DraggerFramework = script.Parent.Parent 5 | local Plugin = DraggerFramework.Parent.Parent 6 | local Roact = require(Plugin.Packages.Roact) 7 | 8 | -- Dragger Framework 9 | local Colors = require(DraggerFramework.Utility.Colors) 10 | local Math = require(DraggerFramework.Utility.Math) 11 | local StandaloneSelectionBox = require(DraggerFramework.Components.StandaloneSelectionBox) 12 | local roundRotation = require(DraggerFramework.Utility.roundRotation) 13 | local snapRotationToPrimaryDirection = require(DraggerFramework.Utility.snapRotationToPrimaryDirection) 14 | 15 | local RotateHandleView = require(DraggerFramework.Components.RotateHandleView) 16 | local SummonHandlesNote = require(DraggerFramework.Components.SummonHandlesNote) 17 | local SummonHandlesHider = require(DraggerFramework.Components.SummonHandlesHider) 18 | local DraggedPivot = require(DraggerFramework.Components.DraggedPivot) 19 | 20 | local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) 21 | 22 | local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) 23 | 24 | -- The minimum rotate increment to display snapping increments for (below this 25 | -- increment there are so many points that they become visual noise) 26 | local MIN_ROTATE_INCREMENT = 5.0 27 | 28 | local RIGHT_ANGLE = math.pi / 2 29 | local RIGHT_ANGLE_EXACT_THRESHOLD = 0.001 30 | 31 | local RotateHandles = {} 32 | RotateHandles.__index = RotateHandles 33 | 34 | --[[ 35 | Axis of rotation is the CFrame right vector. 36 | RadiusOffset slightly bumps the arc radii so that we can control which one 37 | shows up on top where they intersect. 38 | ]] 39 | local RotateHandleDefinitions = { 40 | XAxis = { 41 | Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1)), 42 | Color = Colors.X_AXIS, 43 | RadiusOffset = 0.00, 44 | HideWhenTempPart = true, 45 | }, 46 | YAxis = { 47 | Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0)), 48 | Color = Colors.Y_AXIS, 49 | RadiusOffset = 0.01, 50 | }, 51 | ZAxis = { 52 | Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0)), 53 | Color = Colors.Z_AXIS, 54 | RadiusOffset = 0.02, 55 | HideWhenTempPart = true, 56 | }, 57 | } 58 | 59 | local function isRightAngle(angleDelta) 60 | local snappedTo90 = math.floor((angleDelta / RIGHT_ANGLE) + 0.5) * RIGHT_ANGLE 61 | return math.abs(snappedTo90 - angleDelta) < RIGHT_ANGLE_EXACT_THRESHOLD 62 | end 63 | 64 | local function getRotationTransform(mainCFrame, axisVector, delta, rotateIncrement) 65 | local localAxis = mainCFrame:VectorToObjectSpace(axisVector) 66 | local rotationCFrame = CFrame.fromAxisAngle(localAxis, delta) 67 | 68 | -- Special case rotations in 90 degree increments as a permutation of 69 | -- the identity matrix rather than numerically calculating an axis 70 | -- rotation which would introduce floating point error. 71 | if rotateIncrement > 0 and isRightAngle(delta) then 72 | -- Since we know that this is already almost a right angle rotation 73 | -- thanks to the isRightAngle check, we can find the pure 74 | -- permutation rotation matrix simply by rounding the rotation 75 | -- matrix elements to the nearest integer. 76 | rotationCFrame = roundRotation(rotationCFrame) 77 | end 78 | 79 | -- Convert the rotation to a global space transformation 80 | return mainCFrame * rotationCFrame * mainCFrame:Inverse() 81 | end 82 | 83 | --[[ 84 | Find the angle around the rotation axis where the mouse ray intersects the 85 | plane perpendicular to the rotation axis. 86 | ]] 87 | local function rotationAngleFromRay(cframe, unitRay) 88 | local t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, cframe.Position, cframe.RightVector) 89 | if t >= 0 then 90 | local mouseWorld = unitRay.Origin + unitRay.Direction * t 91 | local direction = (mouseWorld - cframe.Position).Unit 92 | local rx = cframe.LookVector:Dot(direction) 93 | local ry = cframe.UpVector:Dot(direction) 94 | 95 | -- Remap into [0, 2pi] for better snapping behavior with not 96 | -- evenly divisible snapping angles. 97 | local theta = math.atan2(ry, rx) 98 | if theta < 0 then 99 | return 2 * math.pi + theta 100 | else 101 | return theta 102 | end 103 | end 104 | return nil 105 | end 106 | 107 | local function snapToRotateIncrementIfNeeded(angle, rotateIncrement) 108 | if rotateIncrement > 0 then 109 | local angleIncrement = math.rad(rotateIncrement) 110 | local snappedAngle = math.floor(angle / angleIncrement + 0.5) * angleIncrement 111 | local deltaFromCompleteRotation = math.abs(angle - math.pi * 2) 112 | local deltaFromSnapPoint = math.abs(angle - snappedAngle) 113 | if deltaFromCompleteRotation < deltaFromSnapPoint then 114 | -- For rotate increments which don't evenly divide the 115 | -- circle, there won't be a snap point at 360 degrees, so 116 | -- this if statement manually adds a special case for that 117 | -- additional snap point. 118 | return 0 119 | else 120 | return snappedAngle 121 | end 122 | else 123 | return angle 124 | end 125 | end 126 | 127 | function RotateHandles.new(draggerContext, props, implementation) 128 | local self = {} 129 | self._draggerContext = draggerContext 130 | self._handles = {} 131 | self._props = props or { 132 | Summonable = true, 133 | } 134 | self._implementation = implementation 135 | self._tabKeyDown = false 136 | return setmetatable(self, RotateHandles) 137 | end 138 | 139 | -- Summon handles to the current mouse hover location 140 | function RotateHandles:_summonHandles() 141 | if not self._props.Summonable then 142 | return false 143 | end 144 | 145 | local mouseRay = self._draggerContext:getMouseRay() 146 | local hitSelectable, hitItem, distance = self._schema.getMouseTarget(self._draggerContext, mouseRay, {}) 147 | if hitItem then 148 | local hitPoint = mouseRay.Origin + mouseRay.Direction.Unit * distance 149 | self._summonBasisOffset = CFrame.new(self._boundingBox.CFrame:PointToObjectSpace(hitPoint)) 150 | 151 | if self._implementation.findSummonSnap then 152 | local snappedHitCFrame, isOnSurface = self._implementation:findSummonSnap(hitPoint, hitItem) 153 | if snappedHitCFrame then 154 | local snappedBasisOffset = self._boundingBox.CFrame:ToObjectSpace(snappedHitCFrame) 155 | local toOldBasisOffset = snappedBasisOffset:Inverse() * self._summonBasisOffset 156 | 157 | self._summonBasisOffset = snappedBasisOffset * snapRotationToPrimaryDirection(toOldBasisOffset) 158 | self._summonWasSnapped = true 159 | self._summonWasSnappedToSurface = isOnSurface 160 | end 161 | end 162 | end 163 | end 164 | 165 | function RotateHandles:_endSummon() 166 | if self._summonBasisOffset then 167 | self._summonBasisOffset = nil 168 | self._summonWasSnapped = false 169 | self._summonWasSnappedToSurface = false 170 | end 171 | end 172 | 173 | function RotateHandles:_getBasisOffset() 174 | return self._summonBasisOffset or self._basisOffset 175 | end 176 | 177 | function RotateHandles:update(draggerToolModel, selectionInfo) 178 | if not self._draggingHandleId then 179 | if getFFlagSummonPivot() and not self._tabKeyDown then 180 | self:_endSummon() 181 | end 182 | 183 | local cframe, offset, size = selectionInfo:getBoundingBox() 184 | self._boundingBox = { 185 | Size = size, 186 | CFrame = cframe * CFrame.new(offset), 187 | } 188 | self._basisOffset = CFrame.new(-offset) 189 | self._selectionInfo = selectionInfo 190 | self._selectionWrapper = draggerToolModel:getSelectionWrapper() 191 | self._schema = draggerToolModel:getSchema() 192 | if getEngineFeatureModelPivotVisual() then 193 | if getFFlagSummonPivot() then 194 | self._scale = self._draggerContext:getHandleScale((self._boundingBox.CFrame * self:_getBasisOffset()).Position) 195 | else 196 | self._scale = self._draggerContext:getHandleScale(cframe.Position) 197 | end 198 | else 199 | self._scale = self._draggerContext:getHandleScale(self._boundingBox.CFrame.Position) 200 | end 201 | end 202 | self:_updateHandles() 203 | end 204 | 205 | function RotateHandles:shouldBiasTowardsObjects() 206 | return false 207 | end 208 | 209 | function RotateHandles:hitTest(mouseRay, ignoreExtraThreshold) 210 | local closestHandleId, closestHandleDistance = nil, math.huge 211 | for handleId, handleProps in pairs(self._handles) do 212 | local distance = RotateHandleView.hitTest(handleProps, mouseRay) 213 | if distance and distance < closestHandleDistance then 214 | closestHandleDistance = distance 215 | closestHandleId = handleId 216 | end 217 | end 218 | 219 | local alwaysOnTop = true 220 | return closestHandleId, closestHandleDistance, alwaysOnTop 221 | end 222 | 223 | function RotateHandles:render(hoveredHandleId) 224 | local children = {} 225 | 226 | local increment = self._draggerContext:getRotateIncrement() 227 | local tickAngle 228 | if increment >= MIN_ROTATE_INCREMENT then 229 | tickAngle = math.rad(increment) 230 | end 231 | 232 | if self._draggingHandleId and self._handles[self._draggingHandleId] then 233 | local handleProps = self._handles[self._draggingHandleId] 234 | children[self._draggingHandleId] = Roact.createElement(RotateHandleView, { 235 | HandleCFrame = handleProps.HandleCFrame, 236 | Color = handleProps.Color, 237 | StartAngle = self._startAngle - self._draggingLastGoodDelta, 238 | EndAngle = self._startAngle, 239 | Scale = self._scale, 240 | Hovered = false, 241 | RadiusOffset = handleProps.RadiusOffset, 242 | TickAngle = tickAngle, 243 | }) 244 | 245 | -- Show the other handles, but thinner 246 | for handleId, otherHandleProps in pairs(self._handles) do 247 | if handleId ~= self._draggingHandleId then 248 | local offset = RotateHandleDefinitions[handleId].Offset 249 | children[handleId] = Roact.createElement(RotateHandleView, { 250 | HandleCFrame = self._boundingBox.CFrame * offset, 251 | Color = Colors.makeDimmed(otherHandleProps.Color), 252 | Scale = self._scale, 253 | Thin = true, 254 | RadiusOffset = handleProps.RadiusOffset, 255 | }) 256 | end 257 | end 258 | 259 | if getFFlagSummonPivot() then 260 | children.ImplementationRendered = 261 | self._implementation:render(self._lastGlobalTransformForRender) 262 | else 263 | children.ImplementationRendered = 264 | self._implementation:render(self._boundingBox.CFrame * self._basisOffset) 265 | end 266 | else 267 | for handleId, handleProps in pairs(self._handles) do 268 | local color = handleProps.Color 269 | local hovered = (handleId == hoveredHandleId) 270 | local tickAngleToUse 271 | if hovered then 272 | tickAngleToUse = tickAngle 273 | else 274 | color = Colors.makeDimmed(color) 275 | end 276 | children[handleId] = Roact.createElement(RotateHandleView, { 277 | HandleCFrame = handleProps.HandleCFrame, 278 | Color = color, 279 | Scale = self._scale, 280 | Hovered = hovered, 281 | RadiusOffset = handleProps.RadiusOffset, 282 | TickAngle = tickAngleToUse, 283 | }) 284 | end 285 | end 286 | 287 | if self._props.ShowBoundingBox and #self._selectionWrapper:get() > 1 then 288 | children.SelectionBoundingBox = Roact.createElement(StandaloneSelectionBox, { 289 | CFrame = self._boundingBox.CFrame, 290 | Size = self._boundingBox.Size, 291 | Color = self._draggerContext:getSelectionBoxColor(), 292 | LineThickness = self._draggerContext:getHoverLineThickness(), 293 | Container = self._draggerContext:getGuiParent(), 294 | }) 295 | end 296 | 297 | if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() and self._props.Summonable then 298 | if self._summonBasisOffset then 299 | if self._summonWasSnapped then 300 | children.SummonSnap = Roact.createElement("BoxHandleAdornment", { 301 | Adornee = Workspace.Terrain, 302 | Color3 = self._draggerContext:getGeometrySnapColor(), 303 | CFrame = self._boundingBox.CFrame * self._summonBasisOffset, 304 | Size = Vector3.new(0.5, 0.5, 0.5) * self._scale, 305 | AlwaysOnTop = not self._summonWasSnappedToSurface, 306 | Transparency = self._summonWasSnappedToSurface and 0.0 or 0.5, 307 | ZIndex = 0, 308 | }) 309 | else 310 | children.SummonedPivot = Roact.createElement(DraggedPivot, { 311 | DraggerContext = self._draggerContext, 312 | CFrame = self._boundingBox.CFrame * self:_getBasisOffset(), 313 | IsActive = self._draggerContext:shouldShowActiveInstanceHighlight() and (#self._selectionWrapper:get() == 1), 314 | }) 315 | end 316 | end 317 | 318 | if not self._draggingHandleId then 319 | -- Show / hide the summon handles note 320 | if self._summonBasisOffset then 321 | children.SummonHandlesHider = Roact.createElement(SummonHandlesHider, { 322 | DraggerContext = self._draggerContext, 323 | }) 324 | elseif not SummonHandlesHider.hasSeenEnough(self._draggerContext) then 325 | local worldPosition = (self._boundingBox.CFrame * self._basisOffset).Position 326 | local screenPosition, inView = self._draggerContext:worldToViewportPoint(worldPosition) 327 | if screenPosition.Z > 0 then 328 | children.SummonHandlesNote = Roact.createElement(SummonHandlesNote, { 329 | Position = Vector2.new(screenPosition.X, screenPosition.Y), 330 | InView = inView, 331 | DraggerContext = self._draggerContext, 332 | }) 333 | end 334 | end 335 | end 336 | end 337 | 338 | return Roact.createElement("Folder", {}, children) 339 | end 340 | 341 | function RotateHandles:mouseDown(mouseRay, handleId) 342 | -- Attempted to re-drag a handle which no longer exists 343 | -- (happens if the selection changes in the middle of the drag in a way 344 | -- which causes the previously dragged handle to no longer exist) 345 | if not self._handles[handleId] then 346 | return 347 | end 348 | 349 | -- Check if we can find a starting angle 350 | local handleCFrame 351 | if getEngineFeatureModelPivotVisual() then 352 | handleCFrame = self._handles[handleId].HandleCFrame 353 | else 354 | local offset = RotateHandleDefinitions[handleId].Offset 355 | handleCFrame = self._boundingBox.CFrame * offset 356 | end 357 | local angle = rotationAngleFromRay(handleCFrame, mouseRay.Unit) 358 | if not angle then 359 | return 360 | end 361 | 362 | -- We can start a drag as a result of this mouse down 363 | self._draggingHandleId = handleId 364 | self._handleCFrame = handleCFrame 365 | self._lastGlobalTransformForRender = CFrame.new() 366 | self._draggingLastGoodDelta = 0 367 | self._originalBoundingBoxCFrame = self._boundingBox.CFrame 368 | self._startAngle = snapToRotateIncrementIfNeeded( 369 | angle, self._draggerContext:getRotateIncrement()) 370 | 371 | self._implementation:beginDrag(self._selectionWrapper:get(), self._selectionInfo) 372 | end 373 | 374 | function RotateHandles:mouseDrag(mouseRay) 375 | -- We never started this drag in the first place 376 | if not self._handles[self._draggingHandleId] then 377 | return 378 | end 379 | 380 | local angle = rotationAngleFromRay(self._handleCFrame, mouseRay.Unit) 381 | if not angle then 382 | return 383 | end 384 | local snappedAngle = 385 | snapToRotateIncrementIfNeeded(angle, self._draggerContext:getRotateIncrement()) 386 | 387 | local snappedDelta = snappedAngle - self._startAngle 388 | local candidateGlobalTransform = getRotationTransform( 389 | getEngineFeatureModelPivotVisual() and self._handleCFrame or self._originalBoundingBoxCFrame, 390 | self._handleCFrame.RightVector, 391 | snappedDelta, 392 | self._draggerContext:getRotateIncrement()) 393 | 394 | local appliedGlobalTransform = 395 | self._implementation:updateDrag(candidateGlobalTransform) 396 | 397 | -- Adjust the bounding box 398 | self._boundingBox.CFrame = appliedGlobalTransform * self._originalBoundingBoxCFrame 399 | self._lastGlobalTransformForRender = appliedGlobalTransform 400 | 401 | -- Derive the applied rotation angle (we need to display this in the 402 | -- user interface) 403 | local rotatedAxis = appliedGlobalTransform:VectorToObjectSpace(self._handleCFrame.LookVector) 404 | local ry = self._handleCFrame.UpVector:Dot(rotatedAxis) 405 | local rx = self._handleCFrame.LookVector:Dot(rotatedAxis) 406 | self._draggingLastGoodDelta = -math.atan2(ry, rx) 407 | end 408 | 409 | function RotateHandles:mouseUp(mouseRay) 410 | -- We never started this drag in the first place 411 | if not self._draggingHandleId then 412 | return 413 | end 414 | if getFFlagSummonPivot() and not self._tabKeyDown then 415 | self:_endSummon() 416 | end 417 | 418 | self._draggingHandleId = nil 419 | local newSelectionInfoHint = self._implementation:endDrag() 420 | self._schema.addUndoWaypoint(self._draggerContext, "Axis Rotate Selection") 421 | return newSelectionInfoHint 422 | end 423 | 424 | function RotateHandles:_updateHandles() 425 | if self._selectionInfo:isEmpty() then 426 | self._handles = {} 427 | else 428 | for handleId, handleDefinition in pairs(RotateHandleDefinitions) do 429 | if not handleDefinition.HideWhenTempPart or true then 430 | self._handles[handleId] = { 431 | HandleCFrame = getEngineFeatureModelPivotVisual() and 432 | (self._boundingBox.CFrame * self:_getBasisOffset() * handleDefinition.Offset) or 433 | (self._boundingBox.CFrame * handleDefinition.Offset), 434 | Color = handleDefinition.Color, 435 | RadiusOffset = handleDefinition.RadiusOffset, 436 | Scale = self._scale, 437 | } 438 | else 439 | self._handles[handleId] = nil 440 | end 441 | end 442 | end 443 | end 444 | 445 | if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() then 446 | function RotateHandles:keyDown(keyCode) 447 | if keyCode == Enum.KeyCode.Tab then 448 | self._tabKeyDown = true 449 | if not self._draggingHandleId then 450 | self:_summonHandles() 451 | return true 452 | end 453 | end 454 | return false 455 | end 456 | 457 | function RotateHandles:keyUp(keyCode) 458 | if keyCode == Enum.KeyCode.Tab then 459 | self._tabKeyDown = false 460 | if not self._draggingHandleId then 461 | self:_endSummon() 462 | return true 463 | end 464 | end 465 | return false 466 | end 467 | end 468 | 469 | return RotateHandles 470 | --------------------------------------------------------------------------------