From 48bd9bc62f091823fa2952e0a376c8f9506e8a0a Mon Sep 17 00:00:00 2001 From: Mark Langen Date: Mon, 4 Sep 2023 01:29:41 -0700 Subject: [PATCH] Initial commit --- default.project.json | 6 + src/Components/AnimatedHoverBox.lua | 97 +++ src/Components/DragSelectionView.lua | 75 ++ src/Components/DraggedPivot.lua | 21 + src/Components/LocalSpaceIndicator.lua | 115 +++ src/Components/MoveHandleView.lua | 197 +++++ src/Components/RotateHandleView.lua | 270 +++++++ src/Components/ScaleHandleView.lua | 117 +++ src/Components/SelectionDot.lua | 42 + src/Components/StandaloneSelectionBox.lua | 87 ++ src/Components/SummonHandlesHider.lua | 45 ++ src/Components/SummonHandlesNote.lua | 89 +++ src/DraggerTools/DraggerToolComponent.lua | 216 +++++ src/DraggerTools/DraggerToolFixture.lua | 93 +++ .../getEngineFeatureModelPivotVisual.lua | 3 + src/Flags/getFFlagBoxSelectNoPivot.lua | 5 + src/Flags/getFFlagDraggerFrameworkFixes.lua | 5 + ...tFFlagFixDraggerMovingInWrongDirection.lua | 5 + ...ixScalingToolBoundingBoxForLargeModels.lua | 5 + src/Flags/getFFlagFlippedScopeSelect.lua | 5 + .../getFFlagIgnoreSpuriousViewChange.lua | 5 + src/Flags/getFFlagLimitScaling.lua | 5 + src/Flags/getFFlagMoreLuaDraggerFixes.lua | 5 + src/Flags/getFFlagMultiSelectionPivot.lua | 5 + src/Flags/getFFlagOnlyGetGeometryOnce.lua | 5 + src/Flags/getFFlagPreserveMotor6D.lua | 5 + src/Flags/getFFlagSummonPivot.lua | 5 + .../getFFlagTemporaryPatchDraggerEvents.lua | 5 + src/Flags/getFFlagUseGetBoundingBox.lua | 5 + src/Handles/ExtrudeHandles.lua | 746 ++++++++++++++++++ src/Handles/MoveHandles.lua | 540 +++++++++++++ src/Handles/RotateHandles.lua | 469 +++++++++++ .../DraggerContext_FixtureImpl.lua | 359 +++++++++ .../DraggerContext_PluginImpl.lua | 307 +++++++ src/Implementation/DraggerStateType.lua | 19 + .../DraggerStates/DragSelecting.lua | 88 +++ .../DraggerStates/DraggingFaceInstance.lua | 81 ++ .../DraggerStates/DraggingHandle.lua | 138 ++++ .../DraggerStates/DraggingParts.lua | 118 +++ .../DraggerStates/PendingDraggingParts.lua | 68 ++ .../DraggerStates/PendingSelectNext.lua | 63 ++ src/Implementation/DraggerStates/Ready.lua | 294 +++++++ src/Implementation/DraggerToolModel.lua | 582 ++++++++++++++ src/Implementation/HoverTracker.lua | 144 ++++ src/Resources/TranslationDevelopmentTable.csv | 3 + src/Resources/TranslationReferenceTable.csv | 3 + src/Utility/Analytics.lua | 46 ++ src/Utility/AttachmentMover.lua | 44 ++ src/Utility/BoundingBox.lua | 230 ++++++ src/Utility/Colors.lua | 21 + src/Utility/DragHelper.lua | 491 ++++++++++++ src/Utility/DragSelector.lua | 160 ++++ src/Utility/JointMaker.lua | 242 ++++++ src/Utility/JointPairs.lua | 435 ++++++++++ src/Utility/JointUtil.lua | 49 ++ src/Utility/Math.lua | 148 ++++ src/Utility/MockAnalytics.lua | 16 + src/Utility/PartMover.lua | 578 ++++++++++++++ src/Utility/SelectionHelper.lua | 124 +++ src/Utility/SelectionWrapper.lua | 70 ++ src/Utility/Signal.lua | 250 ++++++ src/Utility/StandardCursor.lua | 43 + src/Utility/TemporaryTransparency.lua | 27 + src/Utility/ViewChangeDetector.lua | 51 ++ src/Utility/assertGoodCFrame.lua | 48 ++ src/Utility/classifyPivot.lua | 35 + src/Utility/computeDraggedDistance.lua | 40 + src/Utility/getBoundingBoxScale.lua | 21 + src/Utility/getFaceInstance.lua | 33 + src/Utility/getGeometry.lua | 236 ++++++ src/Utility/isProtectedInstance.lua | 30 + src/Utility/roundRotation.lua | 22 + src/Utility/setInsertPoint.lua | 10 + src/Utility/shouldDragAsFace.lua | 3 + .../snapRotationToPrimaryDirection.lua | 72 ++ src/init.lua | 1 + wally.lock | 8 + wally.toml | 10 + 78 files changed, 9159 insertions(+) create mode 100644 default.project.json create mode 100644 src/Components/AnimatedHoverBox.lua create mode 100644 src/Components/DragSelectionView.lua create mode 100644 src/Components/DraggedPivot.lua create mode 100644 src/Components/LocalSpaceIndicator.lua create mode 100644 src/Components/MoveHandleView.lua create mode 100644 src/Components/RotateHandleView.lua create mode 100644 src/Components/ScaleHandleView.lua create mode 100644 src/Components/SelectionDot.lua create mode 100644 src/Components/StandaloneSelectionBox.lua create mode 100644 src/Components/SummonHandlesHider.lua create mode 100644 src/Components/SummonHandlesNote.lua create mode 100644 src/DraggerTools/DraggerToolComponent.lua create mode 100644 src/DraggerTools/DraggerToolFixture.lua create mode 100644 src/Flags/getEngineFeatureModelPivotVisual.lua create mode 100644 src/Flags/getFFlagBoxSelectNoPivot.lua create mode 100644 src/Flags/getFFlagDraggerFrameworkFixes.lua create mode 100644 src/Flags/getFFlagFixDraggerMovingInWrongDirection.lua create mode 100644 src/Flags/getFFlagFixScalingToolBoundingBoxForLargeModels.lua create mode 100644 src/Flags/getFFlagFlippedScopeSelect.lua create mode 100644 src/Flags/getFFlagIgnoreSpuriousViewChange.lua create mode 100644 src/Flags/getFFlagLimitScaling.lua create mode 100644 src/Flags/getFFlagMoreLuaDraggerFixes.lua create mode 100644 src/Flags/getFFlagMultiSelectionPivot.lua create mode 100644 src/Flags/getFFlagOnlyGetGeometryOnce.lua create mode 100644 src/Flags/getFFlagPreserveMotor6D.lua create mode 100644 src/Flags/getFFlagSummonPivot.lua create mode 100644 src/Flags/getFFlagTemporaryPatchDraggerEvents.lua create mode 100644 src/Flags/getFFlagUseGetBoundingBox.lua create mode 100644 src/Handles/ExtrudeHandles.lua create mode 100644 src/Handles/MoveHandles.lua create mode 100644 src/Handles/RotateHandles.lua create mode 100644 src/Implementation/DraggerContext_FixtureImpl.lua create mode 100644 src/Implementation/DraggerContext_PluginImpl.lua create mode 100644 src/Implementation/DraggerStateType.lua create mode 100644 src/Implementation/DraggerStates/DragSelecting.lua create mode 100644 src/Implementation/DraggerStates/DraggingFaceInstance.lua create mode 100644 src/Implementation/DraggerStates/DraggingHandle.lua create mode 100644 src/Implementation/DraggerStates/DraggingParts.lua create mode 100644 src/Implementation/DraggerStates/PendingDraggingParts.lua create mode 100644 src/Implementation/DraggerStates/PendingSelectNext.lua create mode 100644 src/Implementation/DraggerStates/Ready.lua create mode 100644 src/Implementation/DraggerToolModel.lua create mode 100644 src/Implementation/HoverTracker.lua create mode 100644 src/Resources/TranslationDevelopmentTable.csv create mode 100644 src/Resources/TranslationReferenceTable.csv create mode 100644 src/Utility/Analytics.lua create mode 100644 src/Utility/AttachmentMover.lua create mode 100644 src/Utility/BoundingBox.lua create mode 100644 src/Utility/Colors.lua create mode 100644 src/Utility/DragHelper.lua create mode 100644 src/Utility/DragSelector.lua create mode 100644 src/Utility/JointMaker.lua create mode 100644 src/Utility/JointPairs.lua create mode 100644 src/Utility/JointUtil.lua create mode 100644 src/Utility/Math.lua create mode 100644 src/Utility/MockAnalytics.lua create mode 100644 src/Utility/PartMover.lua create mode 100644 src/Utility/SelectionHelper.lua create mode 100644 src/Utility/SelectionWrapper.lua create mode 100644 src/Utility/Signal.lua create mode 100644 src/Utility/StandardCursor.lua create mode 100644 src/Utility/TemporaryTransparency.lua create mode 100644 src/Utility/ViewChangeDetector.lua create mode 100644 src/Utility/assertGoodCFrame.lua create mode 100644 src/Utility/classifyPivot.lua create mode 100644 src/Utility/computeDraggedDistance.lua create mode 100644 src/Utility/getBoundingBoxScale.lua create mode 100644 src/Utility/getFaceInstance.lua create mode 100644 src/Utility/getGeometry.lua create mode 100644 src/Utility/isProtectedInstance.lua create mode 100644 src/Utility/roundRotation.lua create mode 100644 src/Utility/setInsertPoint.lua create mode 100644 src/Utility/shouldDragAsFace.lua create mode 100644 src/Utility/snapRotationToPrimaryDirection.lua create mode 100644 src/init.lua create mode 100644 wally.lock create mode 100644 wally.toml diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..6f5cf08 --- /dev/null +++ b/default.project.json @@ -0,0 +1,6 @@ +{ + "name": "draggerframework", + "tree": { + "$path": "src" + } +} \ No newline at end of file diff --git a/src/Components/AnimatedHoverBox.lua b/src/Components/AnimatedHoverBox.lua new file mode 100644 index 0000000..1633209 --- /dev/null +++ b/src/Components/AnimatedHoverBox.lua @@ -0,0 +1,97 @@ +--[[ + Displays an animated SelectionBox adornment on the hovered Workspace object. +]] + +-- Services +local RunService = game:GetService("RunService") +local HttpService = game:GetService("HttpService") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local ANIMATED_HOVER_BOX_UPDATE_BIND_NAME = "AnimatedHoverBoxUpdate" +local MODEL_LINE_THICKNESS_SCALE = 2.5 + +local getFFlagDraggerFrameworkFixes = require(DraggerFramework.Flags.getFFlagDraggerFrameworkFixes) + +--[[ + Return a hover color that is a blend between the Studio settings HoverOverColor + and SelectColor, based on the current time and HoverAnimateSpeed. +]] +local function getHoverColorForTime(color1, color2, animatePeriod, currentTime) + local alpha = 0.5 + 0.5 * math.sin(currentTime / animatePeriod * math.pi) + return color2:lerp(color1, alpha) +end + +local AnimatedHoverBox = Roact.PureComponent:extend("AnimatedHoverBox") + +function AnimatedHoverBox:init(initialProps) + assert(initialProps.HoverTarget, "Missing required property 'HoverTarget'.") + assert(initialProps.SelectColor, "Missing required property 'SelectColor'.") + assert(initialProps.HoverColor, "Missing required property 'HoverColor'.") + assert(initialProps.LineThickness, "Missing required property 'LineThickness'.") + assert(initialProps.SelectionBoxComponent, "Missing required property 'SelectionBoxComponent'.") + + self:setState({ + currentColor = getHoverColorForTime( + self.props.SelectColor, self.props.HoverColor, self.props.AnimatePeriod or math.huge, 0), + }) + + self._isMounted = false + self._startTime = 0 + + if getFFlagDraggerFrameworkFixes() then + local guid = HttpService:GenerateGUID(false) + self._bindName = ANIMATED_HOVER_BOX_UPDATE_BIND_NAME .. "_" .. guid + end +end + +function AnimatedHoverBox:didMount() + self._isMounted = true + self._startTime = tick() + + local bindName = getFFlagDraggerFrameworkFixes() and self._bindName or ANIMATED_HOVER_BOX_UPDATE_BIND_NAME + RunService:BindToRenderStep(bindName, Enum.RenderPriority.First.Value, function() + if self._isMounted then + local deltaT = tick() - self._startTime + self:setState({ + currentColor = getHoverColorForTime( + self.props.SelectColor, self.props.HoverColor, self.props.AnimatePeriod or math.huge, deltaT) + }) + end + end) +end + +function AnimatedHoverBox:willUnmount() + self._isMounted = false + + local bindName = getFFlagDraggerFrameworkFixes() and self._bindName or ANIMATED_HOVER_BOX_UPDATE_BIND_NAME + RunService:UnbindFromRenderStep(bindName) +end + +function AnimatedHoverBox:render() + if not self.props.HoverTarget then + return nil + end + + local lineThickness = self.props.LineThickness + if self.props.HoverTarget:IsA("Model") then + lineThickness = lineThickness * MODEL_LINE_THICKNESS_SCALE + end + + --return Roact.createElement(self.props.SelectionBoxComponent, { + -- Adornee = self.props.HoverTarget, + -- Color3 = self.state.currentColor, + -- LineThickness = lineThickness, + --}) + return Roact.createElement("Highlight", { + Adornee = self.props.HoverTarget, + OutlineColor = self.state.currentColor, + OutlineTransparency = 0, + FillColor = self.state.currentColor, + FillTransparency = 1, + }) +end + +return AnimatedHoverBox diff --git a/src/Components/DragSelectionView.lua b/src/Components/DragSelectionView.lua new file mode 100644 index 0000000..454f861 --- /dev/null +++ b/src/Components/DragSelectionView.lua @@ -0,0 +1,75 @@ +--[[ + Component that displays a rubber band-style selection frame. +]] + +local GuiService = game:GetService("GuiService") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +-- Utilities +local Colors = require(DraggerFramework.Utility.Colors) + +local DragSelectionView = Roact.PureComponent:extend("DragSelectionView") + +DragSelectionView.defaultProps = { + BackgroundColor3 = Colors.BLACK, + BackgroundTransparency = 1, + BorderColor3 = Colors.GRAY, +} + +function DragSelectionView:init(initialProps) + assert(initialProps.DragStartLocation, "Missing required property 'DragStartLocation'.") + assert(initialProps.DragEndLocation, "Missing required property 'DragEndLocation'.") +end + +function DragSelectionView:render() + local min = self.props.DragStartLocation + local max = self.props.DragEndLocation + if not min or not max then + return nil + end + + -- Adjust by GUI inset + local topInset = GuiService:GetGuiInset() + + local rect = Rect.new(min - topInset, max - topInset) + + return Roact.createElement("ScreenGui", {}, { + Roact.createElement("Frame", { + Position = UDim2.new(0, rect.Min.X, 0, rect.Min.Y), + Size = UDim2.new(0, rect.Width, 0, rect.Height), + BackgroundColor3 = self.props.BackgroundColor3, + BackgroundTransparency = self.props.BackgroundTransparency, + BorderSizePixel = 0, + }, { + Left = Roact.createElement("Frame", { + Size = UDim2.new(0, 1, 1, 0), + BackgroundColor3 = self.props.BorderColor3, + BorderSizePixel = 0, + }), + Top = Roact.createElement("Frame", { + Size = UDim2.new(1, 0, 0, 1), + BackgroundColor3 = self.props.BorderColor3, + BorderSizePixel = 0, + }), + Right = Roact.createElement("Frame", { + AnchorPoint = Vector2.new(1, 0), + Position = UDim2.new(1, 0, 0, 0), + Size = UDim2.new(0, 1, 1, 0), + BackgroundColor3 = self.props.BorderColor3, + BorderSizePixel = 0, + }), + Bottom = Roact.createElement("Frame", { + AnchorPoint = Vector2.new(0, 1), + Position = UDim2.new(0, 0, 1, 0), + Size = UDim2.new(1, 0, 0, 1), + BackgroundColor3 = self.props.BorderColor3, + BorderSizePixel = 0, + }), + }) + }) +end + +return DragSelectionView diff --git a/src/Components/DraggedPivot.lua b/src/Components/DraggedPivot.lua new file mode 100644 index 0000000..e23f2f3 --- /dev/null +++ b/src/Components/DraggedPivot.lua @@ -0,0 +1,21 @@ +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Roact = require(Plugin.Packages.Roact) + +local MAIN_SPHERE_RADIUS = 0.4 +local MAIN_SPHERE_TRANSPARENCY = 0.5 + +return function(props) + local handleScale = props.DraggerContext:getHandleScale(props.CFrame.Position) + return Roact.createElement("SphereHandleAdornment", { + Adornee = Workspace.Terrain, + CFrame = props.CFrame, + Radius = handleScale * MAIN_SPHERE_RADIUS, + ZIndex = 0, + AlwaysOnTop = false, + Transparency = MAIN_SPHERE_TRANSPARENCY, + Color3 = props.DraggerContext:getSelectionBoxColor(props.IsActive), + }) +end \ No newline at end of file diff --git a/src/Components/LocalSpaceIndicator.lua b/src/Components/LocalSpaceIndicator.lua new file mode 100644 index 0000000..d17e7b1 --- /dev/null +++ b/src/Components/LocalSpaceIndicator.lua @@ -0,0 +1,115 @@ +--[[ + Component that displays an "L" label near the bottom-right corner of the + passed in bounding volume. +]] + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local PADDING = 3 + +local LocalSpaceIndicator = Roact.Component:extend("LocalSpaceIndicator") + +LocalSpaceIndicator.defaultProps = { + BackgroundTransparency = 1, + Font = Enum.Font.ArialBold, + TextSize = 16, + TextColor3 = Color3.new(1, 1, 1), + TextStrokeColor3 = Color3.new(0, 0, 0), + TextStrokeTransparency = 0, +} + +function LocalSpaceIndicator:init(initialProps) + assert(initialProps.CFrame, "Missing required proprty CFrame") + assert(initialProps.Size, "Missing required proprty Size") + assert(initialProps.DraggerContext, "Missing required proprty DraggerContext") +end + +function LocalSpaceIndicator:render() + local props = self.props + + local draggerContext = props.DraggerContext + local cframe = props.CFrame + local halfSize = props.Size / 2 + + -- Compute the bounding box corners in object space. + local max = halfSize + local min = -halfSize + + local corners = { + Vector3.new(min.X, min.Y, min.Z), + Vector3.new(min.X, max.Y, min.Z), + Vector3.new(min.X, max.Y, max.Z), + Vector3.new(min.X, min.Y, max.Z), + Vector3.new(max.X, min.Y, min.Z), + Vector3.new(max.X, max.Y, min.Z), + Vector3.new(max.X, max.Y, max.Z), + Vector3.new(max.X, min.Y, max.Z), + } + + local projectedCorners = {} + local optimalX, optimalY = -math.huge, -math.huge + + -- Find the optimal screen position for the label. This will be the maximum + -- of all the points. + for i = 1, #corners do + -- For each projected corner record whether it is onscreen, but use the + -- point for the optimal point calculation regardless. Not using all of + -- the bounding volume corners can cause the "L" indicator to jump around + -- when the bounding volume is partly outside the viewport. + local worldPoint = cframe:PointToWorldSpace(corners[i]) + local screenPoint, onScreen = draggerContext:worldToViewportPoint(worldPoint) + local point = Vector2.new(screenPoint.X, screenPoint.Y) + + table.insert(projectedCorners, { + point = point, + onScreen = onScreen, + }) + + optimalX = math.max(optimalX, point.X) + optimalY = math.max(optimalY, point.Y) + end + + -- Take the projected point closest to the optimal point to use as the + -- position of the label. + local optimalPoint = Vector2.new(optimalX, optimalY) + local minDistanceToOptimal = math.huge + local isProjectedCornerOnScreen = false + local position + + for i = 1, #projectedCorners do + local screenPoint = projectedCorners[i].point + local distanceToOptimal = (screenPoint - optimalPoint).Magnitude + if distanceToOptimal < minDistanceToOptimal then + minDistanceToOptimal = distanceToOptimal + position = screenPoint + isProjectedCornerOnScreen = projectedCorners[i].onScreen + end + end + + if not isProjectedCornerOnScreen then + return nil + end + + -- Label size calculation is an approximation to avoid using TextService + -- to measure a single-character string. + local labelSize = props.TextSize + PADDING * 2 + + return Roact.createElement("ScreenGui", {}, { + Roact.createElement("TextLabel", { + BackgroundTransparency = props.BackgroundTransparency, + Position = UDim2.fromOffset(position.X, position.Y), + Size = UDim2.fromOffset(labelSize, labelSize), + Font = props.Font, + TextSize = props.TextSize, + Text = "L", + TextColor3 = props.TextColor3, + TextStrokeColor3 = props.TextStrokeColor3, + TextStrokeTransparency = props.TextStrokeTransparency, + Selectable = false, + }) + }) +end + +return LocalSpaceIndicator diff --git a/src/Components/MoveHandleView.lua b/src/Components/MoveHandleView.lua new file mode 100644 index 0000000..96fd412 --- /dev/null +++ b/src/Components/MoveHandleView.lua @@ -0,0 +1,197 @@ + +local Workspace = game:GetService("Workspace") +local CoreGui = game:GetService("CoreGui") + +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Math = require(DraggerFramework.Utility.Math) +local Roact = require(Plugin.Packages.Roact) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) + +local CULLING_MODE = Enum.AdornCullingMode.Never + +local MoveHandleView = Roact.PureComponent:extend("MoveHandleView") + +local BASE_HANDLE_RADIUS = 0.10 +local BASE_HANDLE_HITTEST_RADIUS = BASE_HANDLE_RADIUS * 4 -- Handle hittests bigger than it looks +local BASE_HANDLE_OFFSET = 0.60 +local BASE_HANDLE_LENGTH = 4.00 +local BASE_TIP_OFFSET = 0.20 +local BASE_TIP_LENGTH = 0.25 +local TIP_RADIUS_MULTIPLIER = 3 +local SCREENSPACE_HANDLE_SIZE = 6 +local HANDLE_DIM_TRANSPARENCY = 0.45 +local HANDLE_THIN_BY_FRAC = 0.34 +local HANDLE_THICK_BY_FRAC = 1.5 + +function MoveHandleView:init() +end + +function MoveHandleView:render() + local scale = self.props.Scale + + local length = scale * BASE_HANDLE_LENGTH + local radius = scale * BASE_HANDLE_RADIUS + local offset = scale * BASE_HANDLE_OFFSET + if getEngineFeatureModelPivotVisual() then + offset = offset + length * (self.props.Outset or 0) + end + local tipOffset = scale * BASE_TIP_OFFSET + local tipLength = length * BASE_TIP_LENGTH + if self.props.Thin then + radius = radius * HANDLE_THIN_BY_FRAC + end + if self.props.Hovered then + radius = radius * HANDLE_THICK_BY_FRAC + tipLength = tipLength * HANDLE_THICK_BY_FRAC + end + + local coneAtCFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length)) + local tipAt = coneAtCFrame * Vector3.new(0, 0, -tipOffset) + local tipAtScreen, _ = Workspace.CurrentCamera:WorldToScreenPoint(tipAt) + + local children = {} + if not self.props.Hovered then + children.Shaft = Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, -- Just a neutral anchor point + ZIndex = 0, + Radius = radius, + Height = length, + CFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length * 0.5)), + Color3 = self.props.Color, + AlwaysOnTop = false, + AdornCullingMode = CULLING_MODE, + }) + if not self.props.Thin then + children.Head = Roact.createElement("ConeHandleAdornment", { + Adornee = Workspace.Terrain, + ZIndex = 0, + Radius = TIP_RADIUS_MULTIPLIER * radius, + Height = tipLength, + CFrame = coneAtCFrame, + Color3 = self.props.Color, + AlwaysOnTop = false, + AdornCullingMode = CULLING_MODE, + }) + end + end + + if self.props.AlwaysOnTop then + children.DimmedShaft = Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, -- Just a neutral anchor point + ZIndex = 0, + Radius = radius, + Height = length, + CFrame = self.props.Axis * CFrame.new(0, 0, -(offset + length * 0.5)), + Color3 = self.props.Color, + AlwaysOnTop = true, + Transparency = self.props.Hovered and 0.0 or HANDLE_DIM_TRANSPARENCY, + AdornCullingMode = CULLING_MODE, + }) + if not self.props.Thin then + children.DimmedHead = Roact.createElement("ConeHandleAdornment", { + Adornee = Workspace.Terrain, + ZIndex = 0, + Radius = 3 * radius, + Height = tipLength, + CFrame = coneAtCFrame, + Color3 = self.props.Color, + AlwaysOnTop = true, + Transparency = self.props.Hovered and 0.0 or HANDLE_DIM_TRANSPARENCY, + AdornCullingMode = CULLING_MODE, + }) + end + elseif not self.props.Thin then + local halfHandleSize = 0.5 * SCREENSPACE_HANDLE_SIZE + + children.ScreenBox = Roact.createElement(Roact.Portal, { + target = CoreGui, + }, { + MoveToolScreenspaceHandle = Roact.createElement("ScreenGui", {}, { + Frame = Roact.createElement("Frame", { + BorderSizePixel = 0, + BackgroundColor3 = self.props.Color, + Position = UDim2.new(0, tipAtScreen.X - halfHandleSize, 0, tipAtScreen.Y - halfHandleSize), + Size = UDim2.new(0, SCREENSPACE_HANDLE_SIZE, 0, SCREENSPACE_HANDLE_SIZE), + AdornCullingMode = CULLING_MODE, + }) + }) + }) + end + return Roact.createElement("Folder", {}, children) +end + +function MoveHandleView.hitTest(props, mouseRay) + local scale = props.Scale + + local length = scale * BASE_HANDLE_LENGTH + local radius = scale * BASE_HANDLE_HITTEST_RADIUS + local tipRadius = radius * TIP_RADIUS_MULTIPLIER + local offset = scale * BASE_HANDLE_OFFSET + if getEngineFeatureModelPivotVisual() then + offset = offset + length * (props.Outset or 0) + end + local tipOffset = scale * BASE_TIP_OFFSET + local tipLength = length * BASE_TIP_LENGTH + local shaftEnd = offset + length + + if not props.AlwaysOnTop then + -- Check the always on top 2D element at the tip of the vector + local tipAt = props.Axis * Vector3.new(0, 0, -(offset + length + tipOffset)) + local tipAtScreen, _ = Workspace.CurrentCamera:WorldToScreenPoint(tipAt) + local mouseAtScreen = Workspace.CurrentCamera:WorldToScreenPoint(mouseRay.Origin) + local halfHandleSize = 0.5 * SCREENSPACE_HANDLE_SIZE + if mouseAtScreen.X > tipAtScreen.X - halfHandleSize and + mouseAtScreen.Y > tipAtScreen.Y - halfHandleSize and + mouseAtScreen.X < tipAtScreen.X + halfHandleSize and + mouseAtScreen.Y < tipAtScreen.Y + halfHandleSize + then + return 0 + end + end + + local hasIntersection, hitDistance = + Math.intersectRayRay( + props.Axis.Position, props.Axis.LookVector, + mouseRay.Origin, mouseRay.Direction.Unit) + + if not hasIntersection then + return nil + end + + -- Must have an intersection if the above intersect did + local _, distAlongMouseRay = + Math.intersectRayRay( + mouseRay.Origin, mouseRay.Direction.Unit, + props.Axis.Position, props.Axis.LookVector) + + local hitRadius = + ((props.Axis.Position + props.Axis.LookVector * hitDistance) - + (mouseRay.Origin + mouseRay.Direction.Unit * distAlongMouseRay)).Magnitude + + if hitRadius < radius and hitDistance > offset and hitDistance < shaftEnd then + return distAlongMouseRay + elseif hitRadius < tipRadius and hitDistance > shaftEnd and hitDistance < shaftEnd + tipLength then + return distAlongMouseRay + else + return nil + end +end + +--[[ + Returns: + float Offset - From base CFrame + float Size - Extending from CFrame + Offset +]] +function MoveHandleView.getHandleDimensionForScale(scale, outset) + local length = scale * BASE_HANDLE_LENGTH + local offset = scale * BASE_HANDLE_OFFSET + if getEngineFeatureModelPivotVisual() then + offset = offset + length * (outset or 0) + end + local tipLength = length * BASE_TIP_LENGTH + return offset, length + tipLength +end + +return MoveHandleView \ No newline at end of file diff --git a/src/Components/RotateHandleView.lua b/src/Components/RotateHandleView.lua new file mode 100644 index 0000000..88867d4 --- /dev/null +++ b/src/Components/RotateHandleView.lua @@ -0,0 +1,270 @@ +--[[ + Displays rotation gimbal handles. When dragging, start and end radii showing + the central angle of rotation are displayed. +]] + +local Workspace = game:GetService("Workspace") + +-- Dragger Framework +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) +local Math = require(DraggerFramework.Utility.Math) + +local CULLING_MODE = Enum.AdornCullingMode.Never + +local RotateHandleView = Roact.PureComponent:extend("RotateHandleView") + +local HANDLE_SEGMENTS = 32 +local HANDLE_RADIUS = 4.5 +local HANDLE_THICKNESS = 0.15 +local ANGLE_DISPLAY_THICKNESS = 0.08 +local HANDLE_HITTEST_THICKNESS = HANDLE_THICKNESS * 4 +local HANDLE_THIN_BY_FRAC = 0.0 +local HANDLE_THICK_BY_FRAC = 1.5 +local HANDLE_DIM_TRANSPARENCY = 0.45 +local HANDLE_TICK_WIDTH = 0.05 +local HANDLE_TICK_WIDE_WIDTH = 0.10 +local HANDLE_TICK_RADIUS_FRAC = 0.10 -- Fraction of the radius +local HANDLE_TICK_RADIUS_LONG_FRAC = 0.30 -- Fraction for the primary angles (multiple of 90) +local QUARTER_ROTATION = math.pi / 2 + +local function isMultipleOf90Degrees(angle) + local roundedTo90 = math.floor(angle / QUARTER_ROTATION + 0.5) * QUARTER_ROTATION + return math.abs(angle - roundedTo90) < 0.001 +end + +function RotateHandleView:render() + -- TODO: DEVTOOLS-3876: [Modeling] Rotate tool enhancements + -- Gimbal arc length should be a function of the viewing angle, and handle + -- should face the camera. + + local radiusOffset = self.props.RadiusOffset or 0.0 + local radius = (HANDLE_RADIUS + radiusOffset) * self.props.Scale + if self.props.Hovered then + radius = radius + self.props.Scale * 0.1 + end + local thickness = HANDLE_THICKNESS * self.props.Scale + local angleStep = 2 * math.pi / HANDLE_SEGMENTS + local offset = radius * math.cos(angleStep / 2) + + local children = {} + + -- Thinning for drag + if self.props.Thin then + thickness = HANDLE_THIN_BY_FRAC * thickness + end + if self.props.Hovered then + thickness = HANDLE_THICK_BY_FRAC * thickness + end + + -- Draw main rotation gimbal. + local halfThickness = 0.5 * thickness + children["OnTopHandle"] = Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, + CFrame = self.props.HandleCFrame * CFrame.Angles(self.props.StartAngle or 0, math.pi / 2, math.pi / 2), + Height = thickness, + Radius = radius + halfThickness, + InnerRadius = radius - halfThickness, + Color3 = self.props.Color, + AlwaysOnTop = true, + Transparency = HANDLE_DIM_TRANSPARENCY, + ZIndex = 0, + AdornCullingMode = CULLING_MODE, + }) + children["BrightHandle"] = Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, + CFrame = self.props.HandleCFrame * CFrame.Angles(self.props.StartAngle or 0, math.pi / 2, math.pi / 2), + Height = thickness, + Radius = radius + halfThickness, + InnerRadius = radius - halfThickness, + Color3 = self.props.Color, + AlwaysOnTop = false, + ZIndex = 0, + AdornCullingMode = CULLING_MODE, + }) + + if self.props.TickAngle then + local angleStep = self.props.TickAngle + local count = math.ceil(math.pi * 2 / angleStep) + local smallTickWidth = HANDLE_TICK_WIDTH * self.props.Scale + local smallTickLength = HANDLE_TICK_RADIUS_FRAC * radius + + -- Information for the primary ticks placed at 90 degree intervals + -- relative to the angle the rotate started at. + local primaryTickWidth = HANDLE_TICK_WIDE_WIDTH * self.props.Scale + local primaryTickLength = HANDLE_TICK_RADIUS_LONG_FRAC * radius + local placementAngleMod = 0 + local primaryTickAngleMod = 0 + local hasPrimaryTicks = false + if self.props.StartAngle then + placementAngleMod = self.props.EndAngle - self.props.StartAngle + primaryTickAngleMod = self.props.StartAngle + hasPrimaryTicks = true + end + + for i = 1, count do + local angle = math.pi + (i - 1) * angleStep - placementAngleMod + local isPrimaryTick = hasPrimaryTicks and isMultipleOf90Degrees(angle - primaryTickAngleMod) + local tickLength = isPrimaryTick and primaryTickLength or smallTickLength + local tickWidth = isPrimaryTick and primaryTickWidth or smallTickWidth + local cframe = + self.props.HandleCFrame * + CFrame.Angles(angle, 0, 0) * + CFrame.new(0, 0, radius - 0.5 * smallTickLength) + children["Tick" .. tostring(i)] = Roact.createElement("BoxHandleAdornment", { + Adornee = Workspace.Terrain, + AlwaysOnTop = false, + CFrame = cframe, + Color3 = self.props.Color, + Size = Vector3.new(tickWidth, tickWidth, tickLength), + ZIndex = 0, + AdornCullingMode = CULLING_MODE, + }) + children["OnTopTick" .. tostring(i)] = Roact.createElement("BoxHandleAdornment", { + Adornee = Workspace.Terrain, + AlwaysOnTop = true, + Transparency = HANDLE_DIM_TRANSPARENCY, + CFrame = cframe, + Color3 = self.props.Color, + Size = Vector3.new(tickWidth, tickWidth, tickLength), + ZIndex = 0, + AdornCullingMode = CULLING_MODE, + }) + end + end + + -- Draw the swept angle as circular section at the outer edge. The circular + -- section shows the smallest swept angle back to the starting point. + if self.props.StartAngle and self.props.EndAngle then + local smallTickLength = HANDLE_TICK_RADIUS_FRAC * radius + local primaryTickLength = HANDLE_TICK_RADIUS_LONG_FRAC * radius + local outerWidth = 0.5 * (primaryTickLength - smallTickLength) + + local theta = self.props.EndAngle - self.props.StartAngle + local startAngle = self.props.StartAngle + if theta > math.pi then + theta = theta - math.pi * 2 + end + if theta < -math.pi then + theta = theta + math.pi * 2 + end + if theta < 0 then + startAngle = startAngle + theta + theta = math.abs(theta) + end + if math.abs(theta) > 0.001 then + children.AngleSweepElement = Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, + CFrame = self.props.HandleCFrame * CFrame.Angles(startAngle - math.pi / 2, math.pi / 2, math.pi / 2), + Height = 0, + Radius = radius, + InnerRadius = 0, + Angle = math.deg(theta), + Color3 = self.props.Color, + AlwaysOnTop = true, + Transparency = 0.6, + ZIndex = 0, + }) + end + + local angleDisplayThickness = ANGLE_DISPLAY_THICKNESS * self.props.Scale + local function createAngleDisplay(angle) + local offset = CFrame.new(0, 0, -(radius + outerWidth) / 2) + local cframe = self.props.HandleCFrame * CFrame.Angles(angle, 0, 0) * offset + return Roact.createElement("CylinderHandleAdornment", { + Adornee = Workspace.Terrain, + AlwaysOnTop = true, + CFrame = cframe, + Color3 = self.props.Color, + Height = radius + outerWidth, + Radius = angleDisplayThickness / 2, + ZIndex = 0, + }) + end + children.EndAngleElement = createAngleDisplay(self.props.EndAngle) + end + + return Roact.createElement("Folder", {}, children) +end + +--[[ + Check if the mouse is over the rotation handle. + + The point of intersection between the mouse ray and plane perpendicular + to the rotation axis is computed. The hit radius (distance from the origin + of rotation to the intersection point) is compared to the gimbal radius, + within a threshold to aid handle selection. +]] +function RotateHandleView.hitTest(props, mouseRay) + local cframe = props.HandleCFrame + local unitRay = mouseRay.Unit + + local radiusOffset = props.RadiusOffset or 0.0 + local radius = (HANDLE_RADIUS + radiusOffset) * props.Scale + local thickness = HANDLE_HITTEST_THICKNESS * props.Scale + local normal = cframe.RightVector + local point = cframe.Position + + local smallestDistance = math.huge + local foundHit = false + local hit, t + + -- Top ring + local topPoint = point + normal * 0.5 * thickness + t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, topPoint, normal) + if t >= 0 and t < smallestDistance then + local mouseWorld = unitRay.Origin + unitRay.Direction * t + local hitRadius = (mouseWorld - topPoint).Magnitude + + local distance = math.abs(hitRadius - radius) + if distance < 0.5 * thickness then + foundHit = true + smallestDistance = t + end + end + + -- Bottom ring + local bottomPoint = point - normal * 0.5 * thickness + t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, bottomPoint, -normal) + if t >= 0 and t < smallestDistance then + local mouseWorld = unitRay.Origin + unitRay.Direction * t + local hitRadius = (mouseWorld - bottomPoint).Magnitude + + local distance = math.abs(hitRadius - radius) + if distance < 0.5 * thickness then + foundHit = true + smallestDistance = t + end + end + + -- Get the ray in local space, so that we can use the intersectRayCylinder + -- call for the intersection. The canonical normal of the cylinder is + -- (1, 0, 0) which is what that call expects. + local o = cframe:PointToObjectSpace(unitRay.Origin) + local d = cframe:VectorToObjectSpace(unitRay.Direction) + + -- Inner Cylinder + local innerRadius = radius - 0.5 * thickness + hit, t = Math.intersectRayCylinder(o, d, innerRadius, thickness) + if hit and t < smallestDistance then + foundHit = true + smallestDistance = t + end + + -- Outer Cylinder + local outerRadius = radius + 0.5 * thickness + hit, t = Math.intersectRayCylinder(o, d, outerRadius, thickness) + if hit and t < smallestDistance then + foundHit = true + smallestDistance = t + end + + if foundHit then + return smallestDistance + else + return nil + end +end + +return RotateHandleView diff --git a/src/Components/ScaleHandleView.lua b/src/Components/ScaleHandleView.lua new file mode 100644 index 0000000..562de3d --- /dev/null +++ b/src/Components/ScaleHandleView.lua @@ -0,0 +1,117 @@ +--[[ + Component that displays a spherical scale handle. +]] + +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +-- Dragger Framework +local Math = require(DraggerFramework.Utility.Math) + +local CULLING_MODE = Enum.AdornCullingMode.Never + +local ScaleHandleView = Roact.PureComponent:extend("ScaleHandleView") + +local HANDLE_RADIUS = 0.5 +local HANDLE_RADIUS_HOVERED_SCALE = 1.15 -- Radius scale when handle is hovered +local HANDLE_HITTEST_RADIUS_SCALE = 2.5 -- Radius scale for hit testing + +local HANDLE_TRANSPARENCY_START = 0.75 +local HANDLE_TRANSPARENCY_END = 0.2 +local HANDLE_OFFSET = 1.5 + +local HANDLE_THIN_BY_FRAC = 0.34 + +local function getDebugSettingValue(name, defaultValue) + local setting = Workspace:FindFirstChild(name) + return setting and setting.Value * defaultValue or defaultValue +end + +function ScaleHandleView:render() + -- DEBUG: Allow designers to play with handle settings. + -- Remove before shipping! + HANDLE_OFFSET = getDebugSettingValue("ScaleHandleOffset", 1.5) + HANDLE_RADIUS = getDebugSettingValue("ScaleHandleRadius", 0.5) + HANDLE_TRANSPARENCY_START = getDebugSettingValue("ScaleHandleTransparencyStart", 0.75) + HANDLE_TRANSPARENCY_END = getDebugSettingValue("ScaleHandleTransparencyEnd", 0.2) + + local children = {} + + local color = self.props.Color + local cframe = self.props.HandleCFrame * CFrame.new(0, 0, -HANDLE_OFFSET * self.props.Scale) + local radius = HANDLE_RADIUS * self.props.Scale + + if self.props.Thin then + radius = radius * HANDLE_THIN_BY_FRAC + end + + if not self.props.Hovered then + children.HiddenHandle = Roact.createElement("SphereHandleAdornment", { + Adornee = Workspace.Terrain, + AlwaysOnTop = true, + CFrame = cframe, + Color3 = color, + Radius = radius, + Transparency = HANDLE_TRANSPARENCY_START, + ZIndex = 1, + AdornCullingMode = CULLING_MODE, + }) + end + + local transparencyEnd = HANDLE_TRANSPARENCY_END + + if self.props.Hovered then + radius = radius * HANDLE_RADIUS_HOVERED_SCALE + transparencyEnd = 0 + end + + children.Handle = Roact.createElement("SphereHandleAdornment", { + Adornee = Workspace.Terrain, + AlwaysOnTop = self.props.Hovered, + CFrame = cframe, + Color3 = color, + Radius = radius, + Transparency = transparencyEnd, + ZIndex = 0, + AdornCullingMode = CULLING_MODE, + }) + + return Roact.createElement("Folder", {}, children) +end + +--[[ + Check if the mouse is over the scale handle. + DON'T include the hitTest radius. We will deal with the scale handles by + first checking if we hit any of them, and taking that one if we do. + If we don't actually hit any, then we see if we're within the hitTest radius + for any of them, and if we are take the closest one. +]] +function ScaleHandleView.hitTest(props, mouseRay) + local radius = HANDLE_RADIUS * props.Scale + + local unitRay = mouseRay.Unit + local worldPosition = props.HandleCFrame * Vector3.new(0, 0, -HANDLE_OFFSET * props.Scale) + local result, t = Math.intersectRaySphere(unitRay.Origin, unitRay.Direction, worldPosition, radius) + + if result then + return t + else + return nil + end +end + +function ScaleHandleView.distanceFromHandle(props, mouseRay) + local hitTestRadius = HANDLE_RADIUS * props.Scale * HANDLE_HITTEST_RADIUS_SCALE + local worldPosition = props.HandleCFrame * Vector3.new(0, 0, -HANDLE_OFFSET * props.Scale) + + local rayDir = mouseRay.Direction.Unit + local projectedLength = (worldPosition - mouseRay.Origin):Dot(rayDir) + local projectedPoint = mouseRay.Origin + rayDir * projectedLength + local distanceToRay = (worldPosition - projectedPoint).Magnitude + return distanceToRay - hitTestRadius +end + +return ScaleHandleView diff --git a/src/Components/SelectionDot.lua b/src/Components/SelectionDot.lua new file mode 100644 index 0000000..ef52c7e --- /dev/null +++ b/src/Components/SelectionDot.lua @@ -0,0 +1,42 @@ +--[[ + Component that displays a dot of fixed size at the given position. + Intended to be used to show the center of the current selection. +]] +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local Colors = require(DraggerFramework.Utility.Colors) + +local SelectionDot = Roact.Component:extend("SelectionDot") + +SelectionDot.defaultProps = { + BackgroundColor3 = Colors.WHITE, + BorderColor3 = Colors.BLACK, + Position = Vector3.new(), + Size = 3, +} + +function SelectionDot:render() + local screenPosition, onScreen = Workspace.CurrentCamera:WorldToScreenPoint(self.props.Position) + if not onScreen then + return nil + end + + local size = self.props.Size + + return Roact.createElement("ScreenGui", {}, { + Roact.createElement("Frame", { + BackgroundColor3 = self.props.BackgroundColor3, + BorderColor3 = self.props.BorderColor3, + BorderSizePixel = 1, + Position = UDim2.new(0, screenPosition.X, 0, screenPosition.Y), + Selectable = false, + Size = UDim2.new(0, size, 0, size), + }) + }) +end + +return SelectionDot diff --git a/src/Components/StandaloneSelectionBox.lua b/src/Components/StandaloneSelectionBox.lua new file mode 100644 index 0000000..99da3d4 --- /dev/null +++ b/src/Components/StandaloneSelectionBox.lua @@ -0,0 +1,87 @@ +--[[ + Component that displays a SelectionBox with an arbitrary position and size, + without having to create an adornee. + + Internally, StandaloneSelectionBox creates a transparent adornee with the + correct position/size and parents it to CoreGui to prevent implementation + details from leaking into the workspace. +]] + +local CoreGui = game:GetService("CoreGui") + +local DraggerFramework = script.Parent.Parent + +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local StandaloneSelectionBox = Roact.PureComponent:extend("StandaloneSelectionBox") + +local getFFlagFixScalingToolBoundingBoxForLargeModels = require(DraggerFramework.Flags.getFFlagFixScalingToolBoundingBoxForLargeModels) + +function StandaloneSelectionBox:init() + self._dummyPartRef = Roact.createRef() +end + +function StandaloneSelectionBox:render() + local ones = Vector3.new(1, 1, 1) + local dummyPartSize = self.props.Size:Min(ones) + local dummyPartOffset = self.props.Size / 2 - dummyPartSize / 2 + local container = self.props.Container or CoreGui + -- Fix for MOD-628 + if (getFFlagFixScalingToolBoundingBoxForLargeModels()) then + return Roact.createElement(Roact.Portal, { + target = container, + }, { + DummyModel = Roact.createElement("Model",{ + [Roact.Ref] = self._dummyPartRef, + }, { + DummyPart1 = Roact.createElement("Part", { + Shape = Enum.PartType.Block, + Anchored = true, + CanCollide = false, + CFrame = self.props.CFrame * CFrame.new(-dummyPartOffset), + Size = dummyPartSize, + Transparency = 0, + }), + DummyPart2 = Roact.createElement("Part", { + Shape = Enum.PartType.Block, + Anchored = true, + CanCollide = false, + CFrame = self.props.CFrame * CFrame.new(dummyPartOffset), + Size = dummyPartSize, + Transparency = 0, + }) + }), + SelectionBox = Roact.createElement("SelectionBox", { + Adornee = self._dummyPartRef, + Color3 = self.props.Color, + LineThickness = self.props.LineThickness, + SurfaceTransparency = 1, + Transparency = 0, + }) + }) + else + return Roact.createElement(Roact.Portal, { + target = container, + }, { + DummyPart = Roact.createElement("Part", { + Shape = Enum.PartType.Block, + Anchored = true, + CanCollide = false, + CFrame = self.props.CFrame, + Size = self.props.Size, + Transparency = 1, + [Roact.Ref] = self._dummyPartRef, + }), + SelectionBox = Roact.createElement("SelectionBox", { + Adornee = self._dummyPartRef, + Color3 = self.props.Color, + LineThickness = self.props.LineThickness, + SurfaceTransparency = 1, + Transparency = 0, + }) + }) + end +end + +return StandaloneSelectionBox diff --git a/src/Components/SummonHandlesHider.lua b/src/Components/SummonHandlesHider.lua new file mode 100644 index 0000000..f945e62 --- /dev/null +++ b/src/Components/SummonHandlesHider.lua @@ -0,0 +1,45 @@ +local DraggerFramework = script.Parent.Parent + +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local SummonHandlesHider = Roact.PureComponent:extend("SummonHandlesHider") + +-- When the user has summoned the handles for at duration, the hint that they +-- can be summoned will permanently become hidden. We do it this way so that if +-- you just slightly tap the tab key you won't lose the hint, you actually have +-- to hold it down. +local sTotalSummonHintTime = 2 + +-- Was the hint hidden in a previous session? +local SETTING_NAME = "CoreDraggersSummonHintHidden" +local sWasPreviouslyHidden = nil + +function SummonHandlesHider:didMount() + self._startTime = os.clock() +end + +function SummonHandlesHider:willUnmount() + local duration = os.clock() - self._startTime + sTotalSummonHintTime -= duration + if sWasPreviouslyHidden == nil then + sWasPreviouslyHidden = self.props.DraggerContext:getSetting(SETTING_NAME) + end + if sTotalSummonHintTime <= 0 and not sWasPreviouslyHidden then + self.props.DraggerContext:setSetting(SETTING_NAME, true) + sWasPreviouslyHidden = true + end +end + +function SummonHandlesHider:render() + return nil +end + +function SummonHandlesHider.hasSeenEnough(draggerContext) + if sWasPreviouslyHidden == nil then + sWasPreviouslyHidden = draggerContext:getSetting(SETTING_NAME) + end + return sWasPreviouslyHidden or sTotalSummonHintTime <= 0 +end + +return SummonHandlesHider \ No newline at end of file diff --git a/src/Components/SummonHandlesNote.lua b/src/Components/SummonHandlesNote.lua new file mode 100644 index 0000000..4e84ca0 --- /dev/null +++ b/src/Components/SummonHandlesNote.lua @@ -0,0 +1,89 @@ +local DraggerFramework = script.Parent.Parent + +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +-- How much space between the note and the window edge +local EDGE_PADDING = 2 + +local FRAME_PADDING = 3 +local TEXT_MARGIN = 2 + +local SummonHandlesNote = Roact.PureComponent:extend("SummonHandlesNote") + +function SummonHandlesNote:didMount() + self.localeChangedConnection = self.props.DraggerContext.LocaleChangedSignal:Connect(function() + self:setState({}) + end) +end + +function SimplePadding(props) + return Roact.createElement("UIPadding", { + PaddingBottom = UDim.new(0, props.Padding), + PaddingRight = UDim.new(0, props.Padding), + PaddingLeft = UDim.new(0, props.Padding), + PaddingTop = UDim.new(0, props.Padding), + }) +end + +function SummonHandlesNote:render() + local props = self.props + if props.InView then + return + end + + local viewportSize = props.DraggerContext:getViewportSize() + + local background = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.Tooltip) + local border = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.Border) + local foreground = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.MainText) + local tabBubble = props.DraggerContext:getThemeColor(Enum.StudioStyleGuideColor.DimmedText) + + return Roact.createElement(Roact.Portal, { + target = props.DraggerContext:getGuiParent(), + }, { + SummonHandlesNoteGui = Roact.createElement("ScreenGui", {}, { + Frame = Roact.createElement("Frame", { + AnchorPoint = Vector2.new(0.5, 0), + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundColor3 = background, + BorderColor3 = border, + Position = UDim2.new(0, viewportSize.X / 2, 0, EDGE_PADDING), + }, { + Padding = Roact.createElement(SimplePadding, {Padding = FRAME_PADDING}), + Layout = Roact.createElement("UIListLayout", { + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, FRAME_PADDING), + }), + Tab = Roact.createElement("TextLabel", { + Text = props.DraggerContext:getText("SummonPivot", "TabText"), + TextColor3 = foreground, + BackgroundColor3 = tabBubble, + AutomaticSize = Enum.AutomaticSize.XY, + LayoutOrder = 1, + }, { + Padding = Roact.createElement(SimplePadding, {Padding = TEXT_MARGIN}), + Corner = Roact.createElement("UICorner", { + CornerRadius = UDim.new(0, 4), + }), + }), + Text = Roact.createElement("TextLabel", { + Text = props.DraggerContext:getText("SummonPivot", "SummonText"), + TextColor3 = foreground, + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + LayoutOrder = 2, + }, { + Padding = Roact.createElement(SimplePadding, {Padding = TEXT_MARGIN}), + }) + }) + }) + }) +end + +function SummonHandlesNote:willUnmount() + self.localeChangedConnection:Disconnect() +end + +return SummonHandlesNote \ No newline at end of file diff --git a/src/DraggerTools/DraggerToolComponent.lua b/src/DraggerTools/DraggerToolComponent.lua new file mode 100644 index 0000000..efc3ae4 --- /dev/null +++ b/src/DraggerTools/DraggerToolComponent.lua @@ -0,0 +1,216 @@ +--[[ + DraggerToolComponent is a Roact component which drives an internal + DraggerToolModel with inputs in a real-time situation such as ingame or in + studio plugin. +]] + +-- Services +local RunService = game:GetService("RunService") +local UserInputService = game:GetService("UserInputService") +local HttpService = game:GetService("HttpService") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +-- Utilities +local DraggerToolModel = require(DraggerFramework.Implementation.DraggerToolModel) +local ViewChangeDetector = require(DraggerFramework.Utility.ViewChangeDetector) +local shouldDragAsFace = require(DraggerFramework.Utility.shouldDragAsFace) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) +local getFFlagTemporaryPatchDraggerEvents = require(DraggerFramework.Flags.getFFlagTemporaryPatchDraggerEvents) + +-- Constants +local DRAGGER_UPDATE_BIND_NAME = "DraggerToolViewUpdate" + +local DraggerToolComponent = Roact.PureComponent:extend("DraggerToolComponent") + +function DraggerToolComponent:init() + self:setup(self.props) +end + +function DraggerToolComponent:didMount() +end + +function DraggerToolComponent:willUnmount() + self:teardown() +end + +function DraggerToolComponent:willUpdate(nextProps, nextState) + if nextProps ~= self.props then + self:teardown() + self:setup(nextProps) + end +end + +function DraggerToolComponent:render() + return self._draggerToolModel:render() +end + +function DraggerToolComponent:setup(props) + assert(props.DraggerContext) + assert(props.DraggerSchema) + assert(props.DraggerSettings) + + self._selectionBoundsAreDirty = false + self._viewBoundsAreDirty = false + + self._bindName = DRAGGER_UPDATE_BIND_NAME + local guid = HttpService:GenerateGUID(false) + self._bindName = self._bindName .. guid + + local function requestRender() + if self._isMounted then + self:setState({}) -- Force a rerender + end + end + + self._draggerToolModel = + DraggerToolModel.new( + props.DraggerContext, + props.DraggerSchema, + props.DraggerSettings, + requestRender, + function() self._viewBoundsAreDirty = true end, + function() self._selectionBoundsAreDirty = true end) + + -- Select it first before we potentially start feeding input to it + self._draggerToolModel:_processSelected() + + -- This should never return true because we disconnect connections before + -- tearing down our state. + local function bailedAndWarnedHack(_reason) + if self._isMounted then + return false + else + return true + end + end + + local mouse = props.Mouse + self._mouseDownConnection = mouse.Button1Down:Connect(function() + if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("Button1Down") then + return + end + self._draggerToolModel:_processMouseDown() + end) + self._mouseUpConnection = mouse.Button1Up:Connect(function() + if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("Button1Up") then + return + end + self._draggerToolModel:_processMouseUp() + end) + self._keyDownConnection = UserInputService.InputBegan:Connect(function(input, gameProcessedEvent) + if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("KeyDown") then + return + end + if input.UserInputType == Enum.UserInputType.Keyboard then + self._draggerToolModel:_processKeyDown(input.KeyCode) + end + end) + if getFFlagSummonPivot() then + self._keyUpConnection = UserInputService.InputEnded:Connect(function(input, gameProcessedEvent) + if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("KeyUp") then + return + end + if input.UserInputType == Enum.UserInputType.Keyboard then + self._draggerToolModel:_processKeyUp(input.KeyCode) + end + end) + end + + local function dragEnterFunc(instances) + if getFFlagTemporaryPatchDraggerEvents() and bailedAndWarnedHack("DragEnter") then + return + end + if #instances > 0 then + if #instances == 1 and shouldDragAsFace(instances[1]) then + self._draggerToolModel:_processToolboxInitiatedFaceDrag(instances) + else + self._draggerToolModel:_processToolboxInitiatedFreeformSelectionDrag() + end + end + end + self._dragEnterConnection = mouse.DragEnter:Connect(dragEnterFunc) + + local viewChange = ViewChangeDetector.new(mouse) + local lastUseLocalSpace = props.DraggerContext:shouldUseLocalSpace() + RunService:BindToRenderStep(self._bindName, Enum.RenderPriority.First.Value, function() + if not self._isMounted then + return + end + + self._draggerToolModel:update() + + local shouldUpdateView = false + local shouldUpdateSelection = false + + if viewChange:poll() then + shouldUpdateView = true + end + + if self._selectionBoundsAreDirty then + self._selectionBoundsAreDirty = false + shouldUpdateSelection = true + end + if self._viewBoundsAreDirty then + self._viewBoundsAreDirty = false + shouldUpdateView = true + end + + local currentUseLocalSpace = props.DraggerContext:shouldUseLocalSpace() + if currentUseLocalSpace ~= lastUseLocalSpace then + -- Can't use a changed event for this, since Changed doesn't fire + -- for changes to UseLocalSpace. + shouldUpdateSelection = true + end + + if shouldUpdateSelection then + self._draggerToolModel:_processSelectionChanged() + end + if shouldUpdateView then + self._draggerToolModel:_processViewChanged() + end + + lastUseLocalSpace = currentUseLocalSpace + end) + + if props.InitialMouseDown then + task.defer(function() + if self._isMounted then + self._draggerToolModel:_processMouseDown() + end + end) + end + + self._isMounted = true +end + +function DraggerToolComponent:teardown() + self._isMounted = false + + self._mouseDownConnection:Disconnect() + self._mouseDownConnection = nil + + self._mouseUpConnection:Disconnect() + self._mouseUpConnection = nil + + self._keyDownConnection:Disconnect() + self._keyDownConnection = nil + + if getFFlagSummonPivot() then + self._keyUpConnection:Disconnect() + self._keyUpConnection = nil + end + + self._dragEnterConnection:Disconnect() + self._dragEnterConnection = nil + + RunService:UnbindFromRenderStep(self._bindName) + + -- Deselect after we stop potentially sending events + self._draggerToolModel:_processDeselected() +end + +return DraggerToolComponent \ No newline at end of file diff --git a/src/DraggerTools/DraggerToolFixture.lua b/src/DraggerTools/DraggerToolFixture.lua new file mode 100644 index 0000000..895bff2 --- /dev/null +++ b/src/DraggerTools/DraggerToolFixture.lua @@ -0,0 +1,93 @@ +--[[ + DraggerToolFixture is a class containing a DraggerToolModel which is to be + driven with syntheic inputs in order to perform testing. +]] + +local DraggerFramework = script.Parent.Parent + +local DraggerToolModel = require(DraggerFramework.Implementation.DraggerToolModel) + +local DraggerToolFixture = {} +DraggerToolFixture.__index = DraggerToolFixture + +function DraggerToolFixture.new(draggerContext, draggerSchema, draggerSettings) + draggerSettings = draggerSettings or {} + + local self = setmetatable({ + _draggerContext = draggerContext, + _viewBoundsDirty = true, + _selectionBoundsDirty = true, + }, DraggerToolFixture) + + self._draggerToolModel = + DraggerToolModel.new( + draggerContext, + draggerSchema, + draggerSettings, + function() end, + function() self._viewBoundsDirty = true end, + function() self._selectionBoundsDirty = true end) + + return self +end + +function DraggerToolFixture:getModel() + return self._draggerToolModel +end + +function DraggerToolFixture:_update() + if self._selectionBoundsDirty then + self._selectionBoundsDirty = false + self._draggerToolModel:_processSelectionChanged() + end + if self._viewBoundsDirty then + self._viewBoundsDirty = false + self._draggerToolModel:_processViewChanged() + end +end + +function DraggerToolFixture:select() + assert(not self._selected, "select called while already selected") + self._selected = true + self._draggerToolModel:_processSelected() + self:_update() +end + +function DraggerToolFixture:mouseDown() + assert(self._selected, "must call select before beginDrag") + self._draggerToolModel:_processMouseDown() + self:_update() +end + +function DraggerToolFixture:mouseMove(mouseX, mouseY) + assert(self._selected, "must call select before moveMouse") + local viewportSize = self._draggerContext:getViewportSize() + self._draggerContext:setMouseLocation( + Vector2.new(viewportSize.X * mouseX, viewportSize.Y * mouseY)) + self._draggerToolModel:_processViewChanged() + self:_update() +end + +function DraggerToolFixture:mouseUp() + assert(self._selected, "must call select before endDrag") + self._draggerToolModel:_processMouseUp() + self:_update() +end + +function DraggerToolFixture:keyPress(key) + assert(typeof(key) == "EnumItem", "keyPress takes an Enum.KeyCode") + assert(self._selected, "must call select before keyPress") + self._draggerToolModel:_processKeyDown(key) + self:_update() + self._draggerToolModel:_processKeyUp(key) + self:_update() +end + +function DraggerToolFixture:deselect() + assert(self._selected, "deselect called while not selected") + self._selected = false + self._draggerToolModel:_processDeselected() + self:_update() +end + +return DraggerToolFixture \ No newline at end of file diff --git a/src/Flags/getEngineFeatureModelPivotVisual.lua b/src/Flags/getEngineFeatureModelPivotVisual.lua new file mode 100644 index 0000000..62370a8 --- /dev/null +++ b/src/Flags/getEngineFeatureModelPivotVisual.lua @@ -0,0 +1,3 @@ +return function() + return true +end diff --git a/src/Flags/getFFlagBoxSelectNoPivot.lua b/src/Flags/getFFlagBoxSelectNoPivot.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagBoxSelectNoPivot.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagDraggerFrameworkFixes.lua b/src/Flags/getFFlagDraggerFrameworkFixes.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagDraggerFrameworkFixes.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagFixDraggerMovingInWrongDirection.lua b/src/Flags/getFFlagFixDraggerMovingInWrongDirection.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagFixDraggerMovingInWrongDirection.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagFixScalingToolBoundingBoxForLargeModels.lua b/src/Flags/getFFlagFixScalingToolBoundingBoxForLargeModels.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagFixScalingToolBoundingBoxForLargeModels.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagFlippedScopeSelect.lua b/src/Flags/getFFlagFlippedScopeSelect.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagFlippedScopeSelect.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagIgnoreSpuriousViewChange.lua b/src/Flags/getFFlagIgnoreSpuriousViewChange.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagIgnoreSpuriousViewChange.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagLimitScaling.lua b/src/Flags/getFFlagLimitScaling.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagLimitScaling.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagMoreLuaDraggerFixes.lua b/src/Flags/getFFlagMoreLuaDraggerFixes.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagMoreLuaDraggerFixes.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagMultiSelectionPivot.lua b/src/Flags/getFFlagMultiSelectionPivot.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagMultiSelectionPivot.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagOnlyGetGeometryOnce.lua b/src/Flags/getFFlagOnlyGetGeometryOnce.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagOnlyGetGeometryOnce.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagPreserveMotor6D.lua b/src/Flags/getFFlagPreserveMotor6D.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagPreserveMotor6D.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagSummonPivot.lua b/src/Flags/getFFlagSummonPivot.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagSummonPivot.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagTemporaryPatchDraggerEvents.lua b/src/Flags/getFFlagTemporaryPatchDraggerEvents.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagTemporaryPatchDraggerEvents.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Flags/getFFlagUseGetBoundingBox.lua b/src/Flags/getFFlagUseGetBoundingBox.lua new file mode 100644 index 0000000..ed5fe2e --- /dev/null +++ b/src/Flags/getFFlagUseGetBoundingBox.lua @@ -0,0 +1,5 @@ + + +return function() + return true +end diff --git a/src/Handles/ExtrudeHandles.lua b/src/Handles/ExtrudeHandles.lua new file mode 100644 index 0000000..ad7e0fd --- /dev/null +++ b/src/Handles/ExtrudeHandles.lua @@ -0,0 +1,746 @@ +--[[ + Scale tool implementation. Responsible for handle state and handle views. +]] + +-- Libraries +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Roact = require(Plugin.Packages.Roact) + +-- Dragger Framework +local Colors = require(DraggerFramework.Utility.Colors) +local Math = require(DraggerFramework.Utility.Math) +local StandaloneSelectionBox = require(DraggerFramework.Components.StandaloneSelectionBox) +local ScaleHandleView = require(DraggerFramework.Components.ScaleHandleView) +local DraggedPivot = require(DraggerFramework.Components.DraggedPivot) + +local computeDraggedDistance = require(DraggerFramework.Utility.computeDraggedDistance) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) +local getFFlagLimitScaling = require(DraggerFramework.Flags.getFFlagLimitScaling) +local getFFlagFixDraggerMovingInWrongDirection = require(DraggerFramework.Flags.getFFlagFixDraggerMovingInWrongDirection) + +local RoadManager = require(script.Parent.Parent.Parent.Parent.RoadManager) + +local ExtrudeHandle = {} +ExtrudeHandle.__index = ExtrudeHandle + +local NormalId = { + X_AXIS = 1, + Y_AXIS = 2, + Z_AXIS = 3, +} + +local ScaleHandleDefinitions = { + MinusX = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1)), + Color = Colors.X_AXIS, + NormalId = NormalId.X_AXIS, + }, + PlusX = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, -1)), + Color = Colors.X_AXIS, + NormalId = NormalId.X_AXIS, + }, + MinusY = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0)), + Color = Colors.Y_AXIS, + NormalId = NormalId.Y_AXIS, + HideWhenRoad = true, + HideWhenIntersection = true, + }, + PlusY = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(-1, 0, 0)), + Color = Colors.Y_AXIS, + NormalId = NormalId.Y_AXIS, + HideWhenRoad = true, + HideWhenIntersection =true, + }, + MinusZ = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0)), + Color = Colors.Z_AXIS, + NormalId = NormalId.Z_AXIS, + HideWhenRoad = true, + }, + PlusZ = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, -1, 0)), + Color = Colors.Z_AXIS, + NormalId = NormalId.Z_AXIS, + HideWhenRoad = true, + }, +} + +local BOX_CORNERS = { + Vector3.new(0.5, 0.5, 0.5), + Vector3.new(0.5, 0.5, -0.5), + Vector3.new(0.5, -0.5, 0.5), + Vector3.new(0.5, -0.5, -0.5), + Vector3.new(-0.5, 0.5, 0.5), + Vector3.new(-0.5, 0.5, -0.5), + Vector3.new(-0.5, -0.5, 0.5), + Vector3.new(-0.5, -0.5, -0.5), +} + +local function nextNormalId(normalId) + return normalId % 3 + 1 +end + +function ExtrudeHandle.new(draggerContext, props, implementation) + local self = {} + self._handles = {} + self._props = props or {} + self._draggerContext = draggerContext + self._implementation = implementation + return setmetatable(self, ExtrudeHandle) +end + +-- Summon handles to the current mouse hover location +function ExtrudeHandle:_summonHandles() + if not self._props.Summonable then + return false + end + + local mouseRay = self._draggerContext:getMouseRay() + local _, hitItem, distance = self._schema.getMouseTarget(self._draggerContext, mouseRay, {}) + if hitItem and self._selectionInfo:doesContainItem(hitItem) then + local hitPoint = mouseRay.Origin + mouseRay.Direction.Unit * distance + local point = self._boundingBox.CFrame:PointToObjectSpace(hitPoint) + local halfSize = self._boundingBox.Size / 2 + self._summonBasisOffset = CFrame.new(point:Max(-halfSize):Min(halfSize)) + else + -- Try to pick a point on the selection closest to the cursor + local cursorOnScreen = self._draggerContext:getMouseLocation() + local closestWorldPoint + local closestDistanceOnScreen = math.huge + for _, cornerOffset in ipairs(BOX_CORNERS) do + local cornerInWorld = self._boundingBox.CFrame * CFrame.new(self._boundingBox.Size * cornerOffset) + local cornerOnScreen, inView = self._draggerContext:worldToViewportPoint(cornerInWorld.Position) + local distance = (cursorOnScreen - Vector2.new(cornerOnScreen.X, cornerOnScreen.Y)).Magnitude + if inView and distance < closestDistanceOnScreen then + closestDistanceOnScreen = distance + closestWorldPoint = cornerInWorld + end + end + if closestWorldPoint then + self._summonBasisOffset = self._boundingBox.CFrame:ToObjectSpace(closestWorldPoint) + end + end +end + +function ExtrudeHandle:_endSummon() + if self._summonBasisOffset then + self._summonBasisOffset = nil + end +end + +function ExtrudeHandle:_getBasisOffset() + return self._summonBasisOffset or self._basisOffset +end + +function ExtrudeHandle:update(draggerToolModel, selectionInfo) + if not self._draggingHandleId then + -- Don't clobber these fields while we're dragging because we're + -- updating the bounding box in a smart way given how we're moving the + -- parts. + + self._selectionInfo = selectionInfo + self._schema = draggerToolModel:getSchema() + self._selectionWrapper = draggerToolModel:getSelectionWrapper() + + local cframe, offset, size = self._implementation:getBoundingBox( + self._selectionWrapper:get(), self._selectionInfo) + if getEngineFeatureModelPivotVisual() then + self._basisOffset = CFrame.new(-offset) -- negative since relative to bounding box + end + self._boundingBox = { + Size = size, + CFrame = cframe * CFrame.new(offset), + } + end + self:_updateHandles() +end + +function ExtrudeHandle:shouldBiasTowardsObjects() + return true +end + +function ExtrudeHandle:_rememberCurrentBoundsAsOriginal() + self._originalBoundingBoxSize = self._boundingBox.Size + self._originalBoundingBoxCFrame = self._boundingBox.CFrame + if getFFlagSummonPivot() then + self._originalBasisOffset = self:_getBasisOffset().Position + else + self._originalBasisOffset = self._basisOffset.Position + end + local axis = self._handles[self._draggingHandleId].Axis + local perpendicularMovement = self._originalBasisOffset + perpendicularMovement = perpendicularMovement - axis * perpendicularMovement:Dot(axis) + self._perpendicularMovement = perpendicularMovement +end + +-- Called when the settings we're using to drag change (when we press/release +-- the key(s) for resize from center / maintain aspect ratio) +function ExtrudeHandle:_refreshDrag() + self._committedDeltaSize = self._lastDeltaSize + self._committedOffset = self._lastOffset + + if getEngineFeatureModelPivotVisual() then + self:_rememberCurrentBoundsAsOriginal() + else + self._originalBoundingBoxSize = self._boundingBox.Size + self._originalBoundingBoxCFrame = self._boundingBox.CFrame + end + + -- The edge case where you rotate your camera exactly into alignment with + -- the axis you started the drag on and then press Ctrl will act a bit weird + -- here, but that it's almost impossible to hit that edge case (You have to + -- key-bind a camera alignment command). + local hasDistance, distance = + self:_getDistanceAlongAxis(self._draggerContext:getMouseRay()) + self._startDistance = hasDistance and distance or 0 +end + +function ExtrudeHandle:_getExtrudeMode() + local keepAspectRatio = self._implementation:shouldKeepAspectRatio( + self._selectionWrapper:get(), self._selectionInfo, self._normalId) + local resizeFromCenter = self._implementation:shouldScaleFromCenter( + self._selectionWrapper:get(), self._selectionInfo, self._normalId) + return keepAspectRatio, resizeFromCenter +end + +local function areAxesSame(old, new) + return (old == new) or + (old and new and + -- false and nil mean the same thing + (not old.X == not new.X) and + (not old.Y == not new.Y) and + (not old.Z == not new.Z)) +end + +-- "Extrude Mode" is the state of the tool that controls how scaling is done: +-- self._lastAxesToScale specifies which axes get scaled (uniform/non-uniform scale) +-- self._lastResizeFromCenter specifies whether to resize on both sides or just one +-- self._minimumSize and self._maximumSize control scaling limits (which depend on the axes scaled) +-- The Extrude Mode is determined by e.g. whether the user holds down keys such as Ctrl or Shift, +-- and can change during scaling; the Implementation controls this behavior and computes these +-- values from the state of controls. +-- This function updates the Extrude Mode that this tool uses, and returns whether it changed +-- from the last time it was computed. +function ExtrudeHandle:_updateExtrudeMode() + local selection = self._selectionWrapper:get() + local selectionInfo = self._selectionInfo + local normalId = self._normalId + local resizeFromCenter = self._implementation:shouldScaleFromCenter(selection, selectionInfo, normalId) + local axesToScale = self._implementation:axesToScale(selectionInfo, normalId) + self._minimumSize, self._maximumSize = self._implementation:getMinMaxSizes(selectionInfo, axesToScale, self._boundingBox.Size) + local axesChanged = not areAxesSame(axesToScale, self._lastAxesToScale) + local resizeFromCenterChanged = resizeFromCenter ~= self._lastResizeFromCenter + self._lastAxesToScale = axesToScale + self._lastResizeFromCenter = resizeFromCenter + return axesChanged or resizeFromCenterChanged +end + +-- Returns, whether we did need to refresh the drag +function ExtrudeHandle:_refreshDragIfNeeded() + local refreshNeeded = false + if getFFlagLimitScaling() then + refreshNeeded = self:_updateExtrudeMode() + if refreshNeeded and self._handles[self._draggingHandleId] then + self:_refreshDrag() + end + return refreshNeeded + else + local keepAspectRatio, resizeFromCenter = self:_getExtrudeMode() + if keepAspectRatio ~= self._lastKeepAspectRatio or + resizeFromCenter ~= self._lastResizeFromCenter then + if self._handles[self._draggingHandleId] then + self:_refreshDrag() + end + self._lastKeepAspectRatio = keepAspectRatio + self._lastResizeFromCenter = resizeFromCenter + return true + else + return false + end + end +end + +function ExtrudeHandle:keyDown(keyCode) + return self:_refreshDragIfNeeded() +end + +function ExtrudeHandle:keyUp(keyCode) + return self:_refreshDragIfNeeded() +end + +function ExtrudeHandle:hitTest(mouseRay, ignoreExtraThreshold) + local closestHandleId, closestHandleDistance = nil, math.huge + for handleId, handleProps in pairs(self._handles) do + local distance = ScaleHandleView.hitTest(handleProps, mouseRay) + if distance and distance < closestHandleDistance then + closestHandleDistance = distance + closestHandleId = handleId + end + end + if closestHandleId then + return closestHandleId, 0 + elseif ignoreExtraThreshold then + return nil, 0 + else + -- Second attempt to find a handle, now using closest distance to the ray + -- rather than closest hit distance to the camera. + for handleId, handleProps in pairs(self._handles) do + local distance = ScaleHandleView.distanceFromHandle(handleProps, mouseRay) + if distance < closestHandleDistance then + closestHandleDistance = distance + closestHandleId = handleId + end + end + + -- Only hit a handle if we are actually inside the closest handle's + -- extra hitTest radius. + if closestHandleDistance < 0 then + return closestHandleId, 0 + else + return nil, 0 + end + end +end + +function ExtrudeHandle:_getBoundingBoxColor() + if self._scalingLimitReachedUpper or + self._scalingLimitReachedLower or + self._resizeWasConstrained + then + return Colors.SizeLimitReached + end + return self._draggerContext:getSelectionBoxColor() +end + +function ExtrudeHandle:_getBoundingBoxThickness() + return self._draggerContext:getHoverLineThickness() +end + +function ExtrudeHandle:_shouldDrawBoundingBox() + return self._scalingLimitReachedLower or + self._scalingLimitReachedUpper or + self._resizeWasConstrained or + (self._props.ShowBoundingBox and #self._selectionWrapper:get() > 1) +end + +function ExtrudeHandle:render(hoveredHandleId) + local children = {} + if self._draggingHandleId and self._handles[self._draggingHandleId] then + local handleProps = self._handles[self._draggingHandleId] + children[self._draggingHandleId] = Roact.createElement(ScaleHandleView, { + HandleCFrame = handleProps.HandleCFrame, + Color = handleProps.Color, + Scale = handleProps.Scale, + }) + + for otherHandleId, otherHandleProps in pairs(self._handles) do + if otherHandleId ~= self._draggingHandleId then + children[otherHandleId] = Roact.createElement(ScaleHandleView, { + HandleCFrame = otherHandleProps.HandleCFrame, + Color = Colors.makeDimmed(otherHandleProps.Color), + Scale = otherHandleProps.Scale, + Thin = true, + }) + end + end + + children.ImplementationRendered = + self._implementation:render(self._lastDeltaSize, self._lastOffset) + else + for handleId, handleProps in pairs(self._handles) do + local color = handleProps.Color + local hovered = (handleId == hoveredHandleId) + if not hovered then + color = Colors.makeDimmed(handleProps.Color) + end + children[handleId] = Roact.createElement(ScaleHandleView, { + HandleCFrame = handleProps.HandleCFrame, + Color = color, + Scale = handleProps.Scale, + Hovered = hovered, + }) + end + end + + if getFFlagLimitScaling() then + -- Show selection bounding box when scaling multiple items, or when scaling limit has been reached + if self:_shouldDrawBoundingBox() then + children.SelectionBoundingBox = Roact.createElement(StandaloneSelectionBox, { + CFrame = self._boundingBox.CFrame, + Size = self._boundingBox.Size, + Color = self:_getBoundingBoxColor(), + LineThickness = self:_getBoundingBoxThickness(), + Container = self._draggerContext:getGuiParent(), + }) + end + else + -- Show selection bounding box when scaling multiple items. + if self._props.ShowBoundingBox and #self._selectionWrapper:get() > 1 then + children.SelectionBoundingBox = Roact.createElement(StandaloneSelectionBox, { + CFrame = self._boundingBox.CFrame, + Size = self._boundingBox.Size, + Color = self._draggerContext:getSelectionBoxColor(), + LineThickness = self._draggerContext:getHoverLineThickness(), + Container = self._draggerContext:getGuiParent(), + }) + end + end + + if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() and self._props.Summonable then + if self._summonBasisOffset then + children.SummonedPivot = Roact.createElement(DraggedPivot, { + DraggerContext = self._draggerContext, + CFrame = self._boundingBox.CFrame * self:_getBasisOffset(), + IsActive = self._draggerContext:shouldShowActiveInstanceHighlight() and (#self._selectionWrapper:get() == 1), + }) + end + end + + return Roact.createElement("Folder", {}, children) +end + +function ExtrudeHandle:mouseDown(mouseRay, handleId) + self._draggingHandleId = handleId + + if not self._handles[handleId] then + return + end + self._normalId = self._handles[handleId].NormalId + self._handleCFrame = self._handles[handleId].HandleCFrame + if getEngineFeatureModelPivotVisual() then + self:_rememberCurrentBoundsAsOriginal() + else + self._originalBoundingBoxCFrame = self._boundingBox.CFrame + self._originalBoundingBoxSize = self._boundingBox.Size + end + + if getFFlagLimitScaling() then + self:_updateExtrudeMode() + else + self._lastKeepAspectRatio, self._lastResizeFromCenter = self:_getExtrudeMode() + self._minimumSize = self._implementation:getMinimumSize( + self._selectionWrapper:get(), self._selectionInfo, self._normalId) + end + + local hasDistance, distance = self:_getDistanceAlongAxis(mouseRay) + if getFFlagFixDraggerMovingInWrongDirection() then + self._startDistance = hasDistance and distance or 0.0 + else + assert(hasDistance) + self._startDistance = distance + end + + -- When you change extrude modes mid drag, we need to separate the + -- resize you've done so far from the part you will do in the new mode. + -- The part you've done so far is the "committed" part. + self._committedDeltaSize = Vector3.new() + self._committedOffset = Vector3.new() + + self._lastDeltaSize = Vector3.new() + self._lastOffset = Vector3.new() + + self._implementation:beginScale( + self._selectionWrapper:get(), + self._selectionInfo, + self._normalId) +end + +local function computeDeltaSize(originalSize, delta, normalId, keepAspectRatio) + local sizeComponents = {originalSize.X, originalSize.Y, originalSize.Z} + local xyz = {0, 0, 0} + xyz[normalId] = delta + if keepAspectRatio then + local ratio = delta / sizeComponents[normalId] + xyz[nextNormalId(normalId)] = sizeComponents[nextNormalId(normalId)] * ratio + xyz[nextNormalId(nextNormalId(normalId))] = sizeComponents[nextNormalId(nextNormalId(normalId))] * ratio + end + return Vector3.new(unpack(xyz)) +end + +-- Clamp x between min and max while changing it in increments of step (when step is nonzero) +-- Assumes max - min > step >= 0 +-- One can write this code in a single formula, but since implementation of % is different in C++ and Lua, +-- this would hurt readability +local function clampWithStep(x, min, max, step) + if x < min then + if step > 0 then + return min + (step - (min - x) % step) + else + return min + end + elseif x > max then + if step > 0 then + return max - (step - (x - max) % step) + else + return max + end + end + return x +end + +local function computeDeltaSizeMultiaxis(originalSize, delta, axisId, axesToScale, gridStep, minSize, maxSize) + if gridStep < 0.01 then + gridStep = 0 + end + local originalSizeArray = Math.vectorToArray(originalSize) + local minDeltaSizeArray = Math.vectorToArray(minSize - originalSize) + local maxDeltaSizeArray = Math.vectorToArray(maxSize - originalSize) + local actualDelta = clampWithStep(delta, minDeltaSizeArray[axisId], maxDeltaSizeArray[axisId], gridStep) + local ratio = actualDelta / originalSizeArray[axisId] + local deltaSize = originalSize * Math.setToVector3(axesToScale) * ratio + return deltaSize, actualDelta +end + +local function maxComponent(vector: Vector3) + return math.max(math.abs(vector.X), math.abs(vector.Y), math.abs(vector.Z)) +end + +function ExtrudeHandle:mouseDrag(mouseRay) + --assert(self._draggingHandleId, "Missing dragging handle ID.") + if not self._draggingHandleId then + return + end + + if not self._handles[self._draggingHandleId] then + return + end + + -- The settings used for the drag may have changed. + -- Return value intentionally ignored, we're already in the process of + -- updating the drag, so we're doing the drag update anyways regardless + -- of whether a refresh was needed due to a settings change. + self:_refreshDragIfNeeded() + + local hasDistance, distance = self:_getDistanceAlongAxis(mouseRay) + if not hasDistance then + return + end + + local dragDistance = distance - self._startDistance + local delta = self._draggerContext:snapToGridSize(dragDistance) + local handleProps = self._handles[self._draggingHandleId] + local normalId = handleProps.NormalId + local axis = handleProps.Axis + + local localOffset + if self._lastResizeFromCenter then + delta = delta * 2 + localOffset = Vector3.new() + else + localOffset = axis * 0.5 * delta + end + + -- Add the movement thanks to an offset center + if getEngineFeatureModelPivotVisual() then + local sizeComponents = {self._originalBoundingBoxSize.X, self._originalBoundingBoxSize.Y, self._originalBoundingBoxSize.Z} + local ratio = delta / sizeComponents[normalId] + + if getFFlagSummonPivot() then + -- The condition here is relevant in the case where you summon the + -- pivot while resizing a single part. + if getFFlagLimitScaling() then + ratio *= Math.setToVector3(self._lastAxesToScale) + localOffset = localOffset - self._perpendicularMovement * ratio + else + if self._lastKeepAspectRatio then + localOffset = localOffset - self._perpendicularMovement * ratio + end + end + else + localOffset = localOffset - self._perpendicularMovement * ratio + end + end + + -- Determine the size change for the selection + local originalSize = self._originalBoundingBoxSize + local deltaSize, actualDelta + if getFFlagLimitScaling() then + local axesToScale = self._lastAxesToScale + local gridSize = self._draggerContext:getGridSize() + local minSize = self._minimumSize + local maxSize = self._maximumSize + deltaSize, actualDelta = computeDeltaSizeMultiaxis(originalSize, delta, normalId, axesToScale, gridSize, minSize, maxSize) + if delta ~= 0 then + localOffset = localOffset * (actualDelta / delta) + end + self._scalingLimitReachedUpper = delta - actualDelta > 0 + self._scalingLimitReachedLower = actualDelta - delta > 0 + else + deltaSize = computeDeltaSize(originalSize, delta, normalId, self._lastKeepAspectRatio) + local targetSize = originalSize + deltaSize + local modTargetSize = self._minimumSize:Max(targetSize) + if targetSize ~= modTargetSize then + if self._lastKeepAspectRatio then + -- Can't keep aspect ratio while applying a min size, bail out + -- TODO: Improve this + return + end + local newDeltaSize = modTargetSize - self._originalBoundingBoxSize + local denominator = maxComponent(deltaSize) + local fractionPossible = (denominator > 0) and + (maxComponent(newDeltaSize) / denominator) or 0 + localOffset = localOffset * fractionPossible + deltaSize = newDeltaSize + end + end + + local deltaSizeToApply = deltaSize + self._committedDeltaSize + local localOffsetToApply = localOffset + self._committedOffset + + -- Eliminate floating-point error for edits that don't have any visible impact + if deltaSizeToApply:FuzzyEq(Vector3.new()) then + deltaSizeToApply = Vector3.new() + end + if localOffsetToApply:FuzzyEq(Vector3.new()) then + localOffsetToApply = Vector3.new() + end + + self._lastDeltaSize, self._lastOffset = + self._implementation:updateScale(deltaSizeToApply, localOffsetToApply) + self._resizeWasConstrained = (deltaSizeToApply ~= self._lastDeltaSize) + self._boundingBox.CFrame = self._originalBoundingBoxCFrame * CFrame.new(self._lastOffset - self._committedOffset) + self._boundingBox.Size = originalSize + (self._lastDeltaSize - self._committedDeltaSize) + if getEngineFeatureModelPivotVisual() then + if getFFlagSummonPivot() and self._summonBasisOffset then + self._summonBasisOffset = + CFrame.new(self._originalBasisOffset / self._originalBoundingBoxSize * self._boundingBox.Size) + -- We can't stop summoning in the middle of a drag, so no need to + -- update _basisOffset in the case where we've summoned, it will not + -- be used. + else + self._basisOffset = + CFrame.new(self._originalBasisOffset / self._originalBoundingBoxSize * self._boundingBox.Size) + end + end +end + +function ExtrudeHandle:mouseUp(mouseRay) + if self._handles[self._draggingHandleId] then + self._implementation:endScale() + end + + self._draggingHandleId = nil + self._scalingLimitReachedUpper = false + self._scalingLimitReachedLower = false + self._resizeWasConstrained = false + + if getFFlagSummonPivot() and not self._tabKeyDown then + self:_endSummon() + end + self._schema.addUndoWaypoint(self._draggerContext, "Scale Selection") +end + +function ExtrudeHandle:_getDistanceAlongAxis(mouseRay) + -- Addresses MOD-621 + if getFFlagFixDraggerMovingInWrongDirection() then + local dragDirection = self._handleCFrame.LookVector + local dragFrame = self._originalBoundingBoxCFrame + if getEngineFeatureModelPivotVisual() then + dragFrame = dragFrame * CFrame.new(self._originalBasisOffset) + end + local dragStartPosition = dragFrame.Position + return computeDraggedDistance(dragStartPosition, dragDirection, mouseRay) + else + local axis = self._handleCFrame.LookVector + if getEngineFeatureModelPivotVisual() then + return Math.intersectRayRay( + (self._originalBoundingBoxCFrame * CFrame.new(self._originalBasisOffset)).Position, axis, + mouseRay.Origin, mouseRay.Direction.Unit) + else + return Math.intersectRayRay(self._originalBoundingBoxCFrame.Position, axis, mouseRay.Origin, mouseRay.Direction.Unit) + end + end +end + +function ExtrudeHandle:_updateHandles() + if self._selectionInfo:isEmpty() or self._boundingBox.Size:FuzzyEq(Vector3.new()) then + -- Can't scale something with a zero size. + self._handles = {} + else + for handleId, handleDefinition in pairs(ScaleHandleDefinitions) do + -- Offset the handle's base position by the size of the bounding + -- box on that handle's axis. + local offset = handleDefinition.Offset + if getEngineFeatureModelPivotVisual() then + local inverseHandleCFrame = offset:Inverse() + local localSize = inverseHandleCFrame:VectorToWorldSpace(self._boundingBox.Size) + local offsetDueToBoundingBox = 0.5 * math.abs(localSize.Z) + local offsetDueToBasisOffset + if getFFlagSummonPivot() then + offsetDueToBasisOffset = inverseHandleCFrame:VectorToWorldSpace(self:_getBasisOffset().Position) + else + offsetDueToBasisOffset = inverseHandleCFrame:VectorToWorldSpace(self._basisOffset.Position) + end + local handleBaseCFrame = + self._boundingBox.CFrame * + offset * + CFrame.new(offsetDueToBasisOffset.X, offsetDueToBasisOffset.Y, -offsetDueToBoundingBox) + local isRoad = false + local isIntersection = false + local isSinglePart = not self._lastKeepAspectRatio + if isSinglePart and self._selectionInfo:isRoad() then + if self._selectionInfo:isIntersection() then + isIntersection = true + else + isRoad = true + end + end + if (handleDefinition.HideWhenRoad and isRoad) or (handleDefinition.HideWhenIntersection and isIntersection) then + self._handles[handleId] = nil + else + self._handles[handleId] = { + Color = handleDefinition.Color, + Axis = offset.LookVector, + HandleCFrame = handleBaseCFrame, + NormalId = handleDefinition.NormalId, + Scale = self._draggerContext:getHandleScale(handleBaseCFrame.Position), + } + end + else + local localSize = offset:Inverse():VectorToWorldSpace(self._boundingBox.Size) + local boundingBoxOffset = 0.5 * math.abs(localSize.Z) + local handleBaseCFrame = self._boundingBox.CFrame * offset * CFrame.new(0, 0, -boundingBoxOffset) + + self._handles[handleId] = { + Color = handleDefinition.Color, + Axis = offset.LookVector, + HandleCFrame = handleBaseCFrame, + NormalId = handleDefinition.NormalId, + Scale = self._draggerContext:getHandleScale(handleBaseCFrame.Position), + } + end + end + end +end + +if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() then + function ExtrudeHandle:keyDown(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = true + if not self._draggingHandleId then + self:_summonHandles() + return true + end + end + return false + end + + function ExtrudeHandle:keyUp(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = false + if not self._draggingHandleId then + self:_endSummon() + end + return true + end + return false + end +end + +return ExtrudeHandle diff --git a/src/Handles/MoveHandles.lua b/src/Handles/MoveHandles.lua new file mode 100644 index 0000000..6843f4a --- /dev/null +++ b/src/Handles/MoveHandles.lua @@ -0,0 +1,540 @@ + +-- Libraries +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Roact = require(Plugin.Packages.Roact) + +local Math = require(DraggerFramework.Utility.Math) +local Colors = require(DraggerFramework.Utility.Colors) +local StandaloneSelectionBox = require(DraggerFramework.Components.StandaloneSelectionBox) + +local MoveHandleView = require(DraggerFramework.Components.MoveHandleView) +local SummonHandlesNote = require(DraggerFramework.Components.SummonHandlesNote) +local SummonHandlesHider = require(DraggerFramework.Components.SummonHandlesHider) +local DraggedPivot = require(DraggerFramework.Components.DraggedPivot) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) +local computeDraggedDistance = require(DraggerFramework.Utility.computeDraggedDistance) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +local getFFlagFixDraggerMovingInWrongDirection = require(DraggerFramework.Flags.getFFlagFixDraggerMovingInWrongDirection) + +local ALWAYS_ON_TOP = true + +local MoveHandles = {} +MoveHandles.__index = MoveHandles + +local MoveHandleDefinitions = { + MinusZ = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0)), + Color = Colors.Z_AXIS, + }, + PlusZ = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, -1, 0)), + Color = Colors.Z_AXIS, + }, + MinusY = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0)), + Color = Colors.Y_AXIS, + HideWhenTempPart = true, + }, + PlusY = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(-1, 0, 0)), + Color = Colors.Y_AXIS, + HideWhenTempPart = true, + }, + MinusX = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1)), + Color = Colors.X_AXIS, + }, + PlusX = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, -1)), + Color = Colors.X_AXIS, + }, +} + +function MoveHandles.new(draggerContext, props, implementation) + local self = {} + self._handles = {} + self._props = props or { + Summonable = true, + } + self._draggerContext = draggerContext + self._implementation = implementation + self._tabKeyDown = false + return setmetatable(self, MoveHandles) +end + +function MoveHandles:update(draggerToolModel, selectionInfo) + if not self._draggingHandleId then + if getFFlagSummonPivot() and not self._tabKeyDown then + self:_endSummon() + end + + -- Don't clobber these fields while we're dragging because we're + -- updating the bounding box in a smart way given how we're moving the + -- parts. + local cframe, offset, size = selectionInfo:getBoundingBox() + self._basisOffset = CFrame.new(-offset) -- negative since relative to bounding box + self._boundingBox = { + Size = size, + CFrame = cframe * CFrame.new(offset), + } + self._schema = draggerToolModel:getSchema() + self._selectionWrapper = draggerToolModel:getSelectionWrapper() + self._selectionInfo = selectionInfo + end + self:_updateHandles() +end + +-- Summon handles to the current mouse hover location +function MoveHandles:_summonHandles() + if not self._props.Summonable then + return false + end + + local mouseRay = self._draggerContext:getMouseRay() + local _, _, distance = self._schema.getMouseTarget(self._draggerContext, mouseRay, {}) + if distance then + local hitPoint = mouseRay.Origin + mouseRay.Direction.Unit * distance + self._summonBasisOffset = CFrame.new(self._boundingBox.CFrame:PointToObjectSpace(hitPoint)) + end +end + +function MoveHandles:_endSummon() + if self._summonBasisOffset then + self._summonBasisOffset = nil + end +end + +function MoveHandles:_getBasisOffset() + return self._summonBasisOffset or self._basisOffset +end + +function MoveHandles:shouldBiasTowardsObjects() + return false +end + +function MoveHandles:hitTest(mouseRay, ignoreExtraThreshold) + local closestHandleId, closestHandleDistance = nil, math.huge + for handleId, handleProps in pairs(self._handles) do + local distance = MoveHandleView.hitTest(handleProps, mouseRay) + if distance and distance < closestHandleDistance then + closestHandleDistance = distance + closestHandleId = handleId + end + end + return closestHandleId, closestHandleDistance, ALWAYS_ON_TOP +end + +function MoveHandles:render(hoveredHandleId) + local children = {} + + local EngineFeatureModelPivotVisual = getEngineFeatureModelPivotVisual() + + if self._draggingHandleId and self._handles[self._draggingHandleId] then + local handleProps = self._handles[self._draggingHandleId] + children[self._draggingHandleId] = Roact.createElement(MoveHandleView, { + Axis = handleProps.Axis, + AxisOffset = (not EngineFeatureModelPivotVisual) and handleProps.AxisOffset or nil, + Outset = handleProps.Outset, + Color = handleProps.Color, + Scale = handleProps.Scale, + AlwaysOnTop = ALWAYS_ON_TOP, + Hovered = false, + }) + for otherHandleId, otherHandleProps in pairs(self._handles) do + if otherHandleId ~= self._draggingHandleId then + children[otherHandleId] = Roact.createElement(MoveHandleView, { + Axis = otherHandleProps.Axis, + AxisOffset = (not EngineFeatureModelPivotVisual) and otherHandleProps.AxisOffset or nil, + Outset = handleProps.Outset, + Color = Colors.makeDimmed(otherHandleProps.Color), + Scale = otherHandleProps.Scale, + AlwaysOnTop = ALWAYS_ON_TOP, + Thin = true, + }) + end + end + + if getFFlagSummonPivot() then + children.ImplementationRendered = + self._implementation:render(self._lastGlobalTransformForRender) + else + children.ImplementationRendered = + self._implementation:render(self._boundingBox.CFrame * self:_getBasisOffset()) + end + else + for handleId, handleProps in pairs(self._handles) do + local color = handleProps.Color + local hovered = (handleId == hoveredHandleId) + if not hovered then + color = Colors.makeDimmed(color) + end + children[handleId] = Roact.createElement(MoveHandleView, { + Axis = handleProps.Axis, + AxisOffset = (not EngineFeatureModelPivotVisual) and handleProps.AxisOffset or nil, + Outset = handleProps.Outset, + Color = color, + Scale = handleProps.Scale, + AlwaysOnTop = ALWAYS_ON_TOP, + Hovered = hovered, + }) + end + end + + if self._props.ShowBoundingBox and #self._selectionWrapper:get() > 1 then + children.SelectionBoundingBox = Roact.createElement(StandaloneSelectionBox, { + CFrame = self._boundingBox.CFrame, + Size = self._boundingBox.Size, + Color = self._draggerContext:getSelectionBoxColor(), + LineThickness = self._draggerContext:getHoverLineThickness(), + Container = self._draggerContext:getGuiParent(), + }) + end + + if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() and self._props.Summonable then + if self._summonBasisOffset then + children.SummonedPivot = Roact.createElement(DraggedPivot, { + DraggerContext = self._draggerContext, + CFrame = self._boundingBox.CFrame * self:_getBasisOffset(), + IsActive = self._draggerContext:shouldShowActiveInstanceHighlight() and (#self._selectionWrapper:get() == 1), + }) + end + + if not self._draggingHandleId then + if self._summonBasisOffset then + -- If we are summoning the handles, record the time we're doing it for + children.SummonHandlesHider = Roact.createElement(SummonHandlesHider, { + DraggerContext = self._draggerContext, + }) + elseif not SummonHandlesHider.hasSeenEnough(self._draggerContext) then + local worldPosition = (self._boundingBox.CFrame * self._basisOffset).Position + local screenPosition, inView = self._draggerContext:worldToViewportPoint(worldPosition) + if screenPosition.Z > 0 then + children.SummonHandlesNote = Roact.createElement(SummonHandlesNote, { + Position = Vector2.new(screenPosition.X, screenPosition.Y), + InView = inView, + DraggerContext = self._draggerContext, + }) + end + end + end + end + + return Roact.createElement("Folder", {}, children) +end + +function MoveHandles:mouseDown(mouseRay, handleId) + self._draggingHandleId = handleId + self._draggingOriginalBoundingBoxCFrame = self._boundingBox.CFrame + + -- Calculate fraction of the way along the handle to "stick" the cursor to + if self._handles[handleId] then + self:_setupMoveAtCurrentBoundingBox(mouseRay) + + local handleProps = self._handles[handleId] + if getEngineFeatureModelPivotVisual() then + local handleOffset, handleLength = + MoveHandleView.getHandleDimensionForScale(handleProps.Scale, self._props.Outset) + self._draggingHandleFrac = (self._startDistance - handleOffset) / handleLength + else + local handleOffset, handleLength = + MoveHandleView.getHandleDimensionForScale(handleProps.Scale) + local offsetDueToBoundingBox = handleProps.AxisOffset + self._draggingHandleFrac = + (self._startDistance - handleOffset - offsetDueToBoundingBox) / handleLength + end + end + + self._implementation:beginDrag(self._selectionWrapper:get(), self._selectionInfo) +end + +function MoveHandles:_setupMoveAtCurrentBoundingBox(mouseRay) + local offset = MoveHandleDefinitions[self._draggingHandleId].Offset + local axis = (self._boundingBox.CFrame * offset).LookVector + self._axis = axis + + local hasDistance, distance = self:_getDistanceAlongAxis(mouseRay) + if getFFlagFixDraggerMovingInWrongDirection() then + self._startDistance = hasDistance and distance or 0.0 + else + -- In order to hitTest true in the first place it has to not be parallel + -- and thus have a distance here. + assert(hasDistance) + self._startDistance = distance + end + self._lastGlobalTransformForRender = CFrame.new() +end + +function MoveHandles:_setMidMoveBoundingBox(newBoundingBoxCFrame) + self._boundingBox.CFrame = newBoundingBoxCFrame +end + +--[[ + Returns the distance the mouse cursor was dragged along handle axis +]] +function MoveHandles:_getDistanceAlongAxis(mouseRay) + -- this flag fixes issue MOD-602 + if getFFlagFixDraggerMovingInWrongDirection() then + local draggedFrame = self._draggingOriginalBoundingBoxCFrame + if getEngineFeatureModelPivotVisual() then + draggedFrame = draggedFrame * self:_getBasisOffset() + end + local dragStartPosition = draggedFrame.Position + local dragDirection = self._axis.Unit + return computeDraggedDistance(dragStartPosition, dragDirection, mouseRay) + else + if getEngineFeatureModelPivotVisual() then + return Math.intersectRayRay( + (self._draggingOriginalBoundingBoxCFrame * self:_getBasisOffset()).Position, self._axis, + mouseRay.Origin, mouseRay.Direction.Unit) + else + return Math.intersectRayRay( + self._draggingOriginalBoundingBoxCFrame.Position, self._axis, + mouseRay.Origin, mouseRay.Direction.Unit) + end + end +end + + +--[[ + We want to keep the mouse cursor snapped to a point a constant fraction of + the way down the handle length over the whole duration of a move. This is + non-trivial, as the distance along the handle depends on the scale of the + handle, but the scale of the handle depends on how far it has been moved + relative to the camera. + + We must solve a non-linear equation satisfying the constraint: + fraction of distance along handle at new location + equals + fraction of distance along handle at start location + + Do this using a binary search over the potential solution space. +]] +function MoveHandles:_solveForAdjustedDistance(unadjustedDistance) + local EngineFeatureModelPivotVisual = getEngineFeatureModelPivotVisual() + + -- vvvv TODO mlangen: Remove with FFlagSummonPivot + local offsetDueToBoundingBox + local offsetInHandleSpace + if EngineFeatureModelPivotVisual then + offsetInHandleSpace = self._handles[self._draggingHandleId].OffsetInHandleSpace + else + offsetDueToBoundingBox = self._handles[self._draggingHandleId].AxisOffset + end + -- ^^^^ + + local handleRotation = MoveHandleDefinitions[self._draggingHandleId].Offset + + local function getScaleForDistance(distance) + local boundingBoxAtDistance = + self._draggingOriginalBoundingBoxCFrame + + self._axis * (distance - self._startDistance) + local baseCFrameAtDistance + if EngineFeatureModelPivotVisual and getFFlagSummonPivot() then + baseCFrameAtDistance = boundingBoxAtDistance * self:_getBasisOffset() * handleRotation + else + baseCFrameAtDistance = + EngineFeatureModelPivotVisual and + (boundingBoxAtDistance * handleRotation * offsetInHandleSpace) or + (boundingBoxAtDistance * handleRotation * CFrame.new(0, 0, -offsetDueToBoundingBox)) + end + return self._draggerContext:getHandleScale(baseCFrameAtDistance.Position) + end + + local function getHandleFracForDistance(distance) + local scale = getScaleForDistance(distance) + if EngineFeatureModelPivotVisual then + local handleOffset, handleLength = + MoveHandleView.getHandleDimensionForScale(scale, self._props.Outset) + + local movementAmount = distance - self._startDistance + local handleTailInAxis = movementAmount + handleOffset + + return (unadjustedDistance - handleTailInAxis) / handleLength + else + local handleOffset, handleLength = MoveHandleView.getHandleDimensionForScale(scale) + local intoDist = unadjustedDistance - distance + self._startDistance + return (intoDist - handleOffset - offsetDueToBoundingBox) / handleLength + end + end + + local function getHandleLengthForDistance(distance) + local _, handleLength + if EngineFeatureModelPivotVisual then + _, handleLength = MoveHandleView.getHandleDimensionForScale( + getScaleForDistance(distance), self._props.Outset) + else + _, handleLength = MoveHandleView.getHandleDimensionForScale(getScaleForDistance(distance)) + end + return handleLength + end + + -- Establish the bounds on the binary search for a good distance. Using + -- max(originalLength, newLength) is a bit of a hack. + -- abs(originalLength - newLength) is the more mathematically appropriate + -- expression for how much unadjustedDistance might be off by, but it's too + -- "sharp", and slightly misses including the solution we're looking for + -- sometimes. Using the larger interval with max works too, it's just doing + -- slightly more work than it should really have to. + local originalHandleLength = getHandleLengthForDistance(0) + local currentHandleLength = getHandleLengthForDistance(unadjustedDistance) + local handleSizeDifference = math.max(originalHandleLength, currentHandleLength) + local minPossibleDistance = unadjustedDistance - handleSizeDifference + local maxPossibleDistance = unadjustedDistance + handleSizeDifference + local fracAtMin = getHandleFracForDistance(minPossibleDistance) + local fracAtMax = getHandleFracForDistance(maxPossibleDistance) + + -- Do the binary search + local iterationCount = 0 + while math.abs(minPossibleDistance - maxPossibleDistance) > 0.0001 and iterationCount < 32 do + local mid = 0.5 * (minPossibleDistance + maxPossibleDistance) + local fracAtMid = getHandleFracForDistance(mid) + if (self._draggingHandleFrac - fracAtMid) * (fracAtMax - fracAtMin) > 0 then + minPossibleDistance = mid + fracAtMin = fracAtMid + else + maxPossibleDistance = mid + fracAtMax = fracAtMid + end + end + + return minPossibleDistance +end + +function MoveHandles:_getSnappedDelta(delta) + local snapPoints + if self._implementation.getSnapPoints then + snapPoints = self._implementation:getSnapPoints() + end + if snapPoints then + -- We deliberately don't use self:_getBasisOffset() here, because we want to snap + -- the actual basis point of the selection to the snap points, not the position the + -- handles are visually at. + local basePoint = (self._draggingOriginalBoundingBoxCFrame * self._basisOffset).Position + local axis = self._axis + local maxDistanceAlongAxis = -math.huge + local minDistanceAlongAxis = math.huge + local closenessToDelta = math.huge + local bestDistanceAlongAxis = math.huge + for _, point in ipairs(snapPoints) do + local dist = (point.Position - basePoint):Dot(axis) + maxDistanceAlongAxis = math.max(maxDistanceAlongAxis, dist) + minDistanceAlongAxis = math.min(minDistanceAlongAxis, dist) + + local absDist = math.abs(dist - delta) + if absDist < closenessToDelta then + closenessToDelta = absDist + bestDistanceAlongAxis = dist + end + end + if delta > maxDistanceAlongAxis or delta < minDistanceAlongAxis then + -- Point is outside of the bounds of the snap points, use the grid + -- snap instead if it is closer than the snap point. + local gridSnappedDelta = self._draggerContext:snapToGridSize(delta) + local closenessToGrid = math.abs(gridSnappedDelta - delta) + if closenessToDelta < closenessToGrid then + return bestDistanceAlongAxis + else + return gridSnappedDelta + end + else + -- Point is within the bounds of the snap points, use the distance + -- to the closest snap point. + return bestDistanceAlongAxis + end + else + return self._draggerContext:snapToGridSize(delta) + end +end + +function MoveHandles:mouseDrag(mouseRay) + local hasDistance, distance = self:_getDistanceAlongAxis(mouseRay) + if not hasDistance then + return + end + + if not self._handles[self._draggingHandleId] then + return + end + + local delta = self:_solveForAdjustedDistance(distance) - self._startDistance + local snappedDelta + if getEngineFeatureModelPivotVisual() then + snappedDelta = self:_getSnappedDelta(delta) + else + snappedDelta = self._draggerContext:snapToGridSize(delta) + end + + + -- Let the implementation figure out what global transform can actually be + -- applied (because there may be collisions / constraints involved) + local actualGlobalTransform = + self._implementation:updateDrag(CFrame.new(self._axis * snappedDelta)) + assert(actualGlobalTransform ~= nil, "Did not return a transform from updateDrag.") + + -- Set the new resulting CFrame + self:_setMidMoveBoundingBox( + actualGlobalTransform * self._draggingOriginalBoundingBoxCFrame) + self._lastGlobalTransformForRender = actualGlobalTransform +end + +function MoveHandles:mouseUp(mouseRay) + self._draggingHandleId = nil + local newSelectionInfoHint = self._implementation:endDrag() + self._schema.addUndoWaypoint(self._draggerContext, "Axis Move Selection") + if getFFlagSummonPivot() and not self._tabKeyDown then + self:_endSummon() + end + return newSelectionInfoHint +end + +function MoveHandles:_updateHandles() + if self._selectionInfo:isEmpty() then + self._handles = {} + else + for handleId, handleDef in pairs(MoveHandleDefinitions) do + local handleBaseCFrame = + self._boundingBox.CFrame * self:_getBasisOffset() * handleDef.Offset + if not handleDef.HideWhenTempPart or true then + self._handles[handleId] = { + Outset = self._props.Outset, + Axis = handleBaseCFrame, + Color = handleDef.Color, + Scale = self._draggerContext:getHandleScale(handleBaseCFrame.Position), + AlwaysOnTop = ALWAYS_ON_TOP, + } + else + self._handles[handleId] = nil + end + end + end +end + +if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() then + function MoveHandles:keyDown(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = true + if not self._draggingHandleId then + self:_summonHandles() + return true + end + end + return false + end + + function MoveHandles:keyUp(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = false + if not self._draggingHandleId then + self:_endSummon() + end + return true + end + return false + end +end + +return MoveHandles diff --git a/src/Handles/RotateHandles.lua b/src/Handles/RotateHandles.lua new file mode 100644 index 0000000..24fc104 --- /dev/null +++ b/src/Handles/RotateHandles.lua @@ -0,0 +1,469 @@ +local Workspace = game:GetService("Workspace") + +-- Libraries +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Roact = require(Plugin.Packages.Roact) + +-- Dragger Framework +local Colors = require(DraggerFramework.Utility.Colors) +local Math = require(DraggerFramework.Utility.Math) +local StandaloneSelectionBox = require(DraggerFramework.Components.StandaloneSelectionBox) +local roundRotation = require(DraggerFramework.Utility.roundRotation) +local snapRotationToPrimaryDirection = require(DraggerFramework.Utility.snapRotationToPrimaryDirection) + +local RotateHandleView = require(DraggerFramework.Components.RotateHandleView) +local SummonHandlesNote = require(DraggerFramework.Components.SummonHandlesNote) +local SummonHandlesHider = require(DraggerFramework.Components.SummonHandlesHider) +local DraggedPivot = require(DraggerFramework.Components.DraggedPivot) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +-- The minimum rotate increment to display snapping increments for (below this +-- increment there are so many points that they become visual noise) +local MIN_ROTATE_INCREMENT = 5.0 + +local RIGHT_ANGLE = math.pi / 2 +local RIGHT_ANGLE_EXACT_THRESHOLD = 0.001 + +local RotateHandles = {} +RotateHandles.__index = RotateHandles + +--[[ + Axis of rotation is the CFrame right vector. + RadiusOffset slightly bumps the arc radii so that we can control which one + shows up on top where they intersect. +]] +local RotateHandleDefinitions = { + XAxis = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1)), + Color = Colors.X_AXIS, + RadiusOffset = 0.00, + HideWhenTempPart = true, + }, + YAxis = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 1, 0), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0)), + Color = Colors.Y_AXIS, + RadiusOffset = 0.01, + }, + ZAxis = { + Offset = CFrame.fromMatrix(Vector3.new(), Vector3.new(0, 0, 1), Vector3.new(1, 0, 0), Vector3.new(0, 1, 0)), + Color = Colors.Z_AXIS, + RadiusOffset = 0.02, + HideWhenTempPart = true, + }, +} + +local function isRightAngle(angleDelta) + local snappedTo90 = math.floor((angleDelta / RIGHT_ANGLE) + 0.5) * RIGHT_ANGLE + return math.abs(snappedTo90 - angleDelta) < RIGHT_ANGLE_EXACT_THRESHOLD +end + +local function getRotationTransform(mainCFrame, axisVector, delta, rotateIncrement) + local localAxis = mainCFrame:VectorToObjectSpace(axisVector) + local rotationCFrame = CFrame.fromAxisAngle(localAxis, delta) + + -- Special case rotations in 90 degree increments as a permutation of + -- the identity matrix rather than numerically calculating an axis + -- rotation which would introduce floating point error. + if rotateIncrement > 0 and isRightAngle(delta) then + -- Since we know that this is already almost a right angle rotation + -- thanks to the isRightAngle check, we can find the pure + -- permutation rotation matrix simply by rounding the rotation + -- matrix elements to the nearest integer. + rotationCFrame = roundRotation(rotationCFrame) + end + + -- Convert the rotation to a global space transformation + return mainCFrame * rotationCFrame * mainCFrame:Inverse() +end + +--[[ + Find the angle around the rotation axis where the mouse ray intersects the + plane perpendicular to the rotation axis. +]] +local function rotationAngleFromRay(cframe, unitRay) + local t = Math.intersectRayPlane(unitRay.Origin, unitRay.Direction, cframe.Position, cframe.RightVector) + if t >= 0 then + local mouseWorld = unitRay.Origin + unitRay.Direction * t + local direction = (mouseWorld - cframe.Position).Unit + local rx = cframe.LookVector:Dot(direction) + local ry = cframe.UpVector:Dot(direction) + + -- Remap into [0, 2pi] for better snapping behavior with not + -- evenly divisible snapping angles. + local theta = math.atan2(ry, rx) + if theta < 0 then + return 2 * math.pi + theta + else + return theta + end + end + return nil +end + +local function snapToRotateIncrementIfNeeded(angle, rotateIncrement) + if rotateIncrement > 0 then + local angleIncrement = math.rad(rotateIncrement) + local snappedAngle = math.floor(angle / angleIncrement + 0.5) * angleIncrement + local deltaFromCompleteRotation = math.abs(angle - math.pi * 2) + local deltaFromSnapPoint = math.abs(angle - snappedAngle) + if deltaFromCompleteRotation < deltaFromSnapPoint then + -- For rotate increments which don't evenly divide the + -- circle, there won't be a snap point at 360 degrees, so + -- this if statement manually adds a special case for that + -- additional snap point. + return 0 + else + return snappedAngle + end + else + return angle + end +end + +function RotateHandles.new(draggerContext, props, implementation) + local self = {} + self._draggerContext = draggerContext + self._handles = {} + self._props = props or { + Summonable = true, + } + self._implementation = implementation + self._tabKeyDown = false + return setmetatable(self, RotateHandles) +end + +-- Summon handles to the current mouse hover location +function RotateHandles:_summonHandles() + if not self._props.Summonable then + return false + end + + local mouseRay = self._draggerContext:getMouseRay() + local hitSelectable, hitItem, distance = self._schema.getMouseTarget(self._draggerContext, mouseRay, {}) + if hitItem then + local hitPoint = mouseRay.Origin + mouseRay.Direction.Unit * distance + self._summonBasisOffset = CFrame.new(self._boundingBox.CFrame:PointToObjectSpace(hitPoint)) + + if self._implementation.findSummonSnap then + local snappedHitCFrame, isOnSurface = self._implementation:findSummonSnap(hitPoint, hitItem) + if snappedHitCFrame then + local snappedBasisOffset = self._boundingBox.CFrame:ToObjectSpace(snappedHitCFrame) + local toOldBasisOffset = snappedBasisOffset:Inverse() * self._summonBasisOffset + + self._summonBasisOffset = snappedBasisOffset * snapRotationToPrimaryDirection(toOldBasisOffset) + self._summonWasSnapped = true + self._summonWasSnappedToSurface = isOnSurface + end + end + end +end + +function RotateHandles:_endSummon() + if self._summonBasisOffset then + self._summonBasisOffset = nil + self._summonWasSnapped = false + self._summonWasSnappedToSurface = false + end +end + +function RotateHandles:_getBasisOffset() + return self._summonBasisOffset or self._basisOffset +end + +function RotateHandles:update(draggerToolModel, selectionInfo) + if not self._draggingHandleId then + if getFFlagSummonPivot() and not self._tabKeyDown then + self:_endSummon() + end + + local cframe, offset, size = selectionInfo:getBoundingBox() + self._boundingBox = { + Size = size, + CFrame = cframe * CFrame.new(offset), + } + self._basisOffset = CFrame.new(-offset) + self._selectionInfo = selectionInfo + self._selectionWrapper = draggerToolModel:getSelectionWrapper() + self._schema = draggerToolModel:getSchema() + if getEngineFeatureModelPivotVisual() then + if getFFlagSummonPivot() then + self._scale = self._draggerContext:getHandleScale((self._boundingBox.CFrame * self:_getBasisOffset()).Position) + else + self._scale = self._draggerContext:getHandleScale(cframe.Position) + end + else + self._scale = self._draggerContext:getHandleScale(self._boundingBox.CFrame.Position) + end + end + self:_updateHandles() +end + +function RotateHandles:shouldBiasTowardsObjects() + return false +end + +function RotateHandles:hitTest(mouseRay, ignoreExtraThreshold) + local closestHandleId, closestHandleDistance = nil, math.huge + for handleId, handleProps in pairs(self._handles) do + local distance = RotateHandleView.hitTest(handleProps, mouseRay) + if distance and distance < closestHandleDistance then + closestHandleDistance = distance + closestHandleId = handleId + end + end + + local alwaysOnTop = true + return closestHandleId, closestHandleDistance, alwaysOnTop +end + +function RotateHandles:render(hoveredHandleId) + local children = {} + + local increment = self._draggerContext:getRotateIncrement() + local tickAngle + if increment >= MIN_ROTATE_INCREMENT then + tickAngle = math.rad(increment) + end + + if self._draggingHandleId and self._handles[self._draggingHandleId] then + local handleProps = self._handles[self._draggingHandleId] + children[self._draggingHandleId] = Roact.createElement(RotateHandleView, { + HandleCFrame = handleProps.HandleCFrame, + Color = handleProps.Color, + StartAngle = self._startAngle - self._draggingLastGoodDelta, + EndAngle = self._startAngle, + Scale = self._scale, + Hovered = false, + RadiusOffset = handleProps.RadiusOffset, + TickAngle = tickAngle, + }) + + -- Show the other handles, but thinner + for handleId, otherHandleProps in pairs(self._handles) do + if handleId ~= self._draggingHandleId then + local offset = RotateHandleDefinitions[handleId].Offset + children[handleId] = Roact.createElement(RotateHandleView, { + HandleCFrame = self._boundingBox.CFrame * offset, + Color = Colors.makeDimmed(otherHandleProps.Color), + Scale = self._scale, + Thin = true, + RadiusOffset = handleProps.RadiusOffset, + }) + end + end + + if getFFlagSummonPivot() then + children.ImplementationRendered = + self._implementation:render(self._lastGlobalTransformForRender) + else + children.ImplementationRendered = + self._implementation:render(self._boundingBox.CFrame * self._basisOffset) + end + else + for handleId, handleProps in pairs(self._handles) do + local color = handleProps.Color + local hovered = (handleId == hoveredHandleId) + local tickAngleToUse + if hovered then + tickAngleToUse = tickAngle + else + color = Colors.makeDimmed(color) + end + children[handleId] = Roact.createElement(RotateHandleView, { + HandleCFrame = handleProps.HandleCFrame, + Color = color, + Scale = self._scale, + Hovered = hovered, + RadiusOffset = handleProps.RadiusOffset, + TickAngle = tickAngleToUse, + }) + end + end + + if self._props.ShowBoundingBox and #self._selectionWrapper:get() > 1 then + children.SelectionBoundingBox = Roact.createElement(StandaloneSelectionBox, { + CFrame = self._boundingBox.CFrame, + Size = self._boundingBox.Size, + Color = self._draggerContext:getSelectionBoxColor(), + LineThickness = self._draggerContext:getHoverLineThickness(), + Container = self._draggerContext:getGuiParent(), + }) + end + + if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() and self._props.Summonable then + if self._summonBasisOffset then + if self._summonWasSnapped then + children.SummonSnap = Roact.createElement("BoxHandleAdornment", { + Adornee = Workspace.Terrain, + Color3 = self._draggerContext:getGeometrySnapColor(), + CFrame = self._boundingBox.CFrame * self._summonBasisOffset, + Size = Vector3.new(0.5, 0.5, 0.5) * self._scale, + AlwaysOnTop = not self._summonWasSnappedToSurface, + Transparency = self._summonWasSnappedToSurface and 0.0 or 0.5, + ZIndex = 0, + }) + else + children.SummonedPivot = Roact.createElement(DraggedPivot, { + DraggerContext = self._draggerContext, + CFrame = self._boundingBox.CFrame * self:_getBasisOffset(), + IsActive = self._draggerContext:shouldShowActiveInstanceHighlight() and (#self._selectionWrapper:get() == 1), + }) + end + end + + if not self._draggingHandleId then + -- Show / hide the summon handles note + if self._summonBasisOffset then + children.SummonHandlesHider = Roact.createElement(SummonHandlesHider, { + DraggerContext = self._draggerContext, + }) + elseif not SummonHandlesHider.hasSeenEnough(self._draggerContext) then + local worldPosition = (self._boundingBox.CFrame * self._basisOffset).Position + local screenPosition, inView = self._draggerContext:worldToViewportPoint(worldPosition) + if screenPosition.Z > 0 then + children.SummonHandlesNote = Roact.createElement(SummonHandlesNote, { + Position = Vector2.new(screenPosition.X, screenPosition.Y), + InView = inView, + DraggerContext = self._draggerContext, + }) + end + end + end + end + + return Roact.createElement("Folder", {}, children) +end + +function RotateHandles:mouseDown(mouseRay, handleId) + -- Attempted to re-drag a handle which no longer exists + -- (happens if the selection changes in the middle of the drag in a way + -- which causes the previously dragged handle to no longer exist) + if not self._handles[handleId] then + return + end + + -- Check if we can find a starting angle + local handleCFrame + if getEngineFeatureModelPivotVisual() then + handleCFrame = self._handles[handleId].HandleCFrame + else + local offset = RotateHandleDefinitions[handleId].Offset + handleCFrame = self._boundingBox.CFrame * offset + end + local angle = rotationAngleFromRay(handleCFrame, mouseRay.Unit) + if not angle then + return + end + + -- We can start a drag as a result of this mouse down + self._draggingHandleId = handleId + self._handleCFrame = handleCFrame + self._lastGlobalTransformForRender = CFrame.new() + self._draggingLastGoodDelta = 0 + self._originalBoundingBoxCFrame = self._boundingBox.CFrame + self._startAngle = snapToRotateIncrementIfNeeded( + angle, self._draggerContext:getRotateIncrement()) + + self._implementation:beginDrag(self._selectionWrapper:get(), self._selectionInfo) +end + +function RotateHandles:mouseDrag(mouseRay) + -- We never started this drag in the first place + if not self._handles[self._draggingHandleId] then + return + end + + local angle = rotationAngleFromRay(self._handleCFrame, mouseRay.Unit) + if not angle then + return + end + local snappedAngle = + snapToRotateIncrementIfNeeded(angle, self._draggerContext:getRotateIncrement()) + + local snappedDelta = snappedAngle - self._startAngle + local candidateGlobalTransform = getRotationTransform( + getEngineFeatureModelPivotVisual() and self._handleCFrame or self._originalBoundingBoxCFrame, + self._handleCFrame.RightVector, + snappedDelta, + self._draggerContext:getRotateIncrement()) + + local appliedGlobalTransform = + self._implementation:updateDrag(candidateGlobalTransform) + + -- Adjust the bounding box + self._boundingBox.CFrame = appliedGlobalTransform * self._originalBoundingBoxCFrame + self._lastGlobalTransformForRender = appliedGlobalTransform + + -- Derive the applied rotation angle (we need to display this in the + -- user interface) + local rotatedAxis = appliedGlobalTransform:VectorToObjectSpace(self._handleCFrame.LookVector) + local ry = self._handleCFrame.UpVector:Dot(rotatedAxis) + local rx = self._handleCFrame.LookVector:Dot(rotatedAxis) + self._draggingLastGoodDelta = -math.atan2(ry, rx) +end + +function RotateHandles:mouseUp(mouseRay) + -- We never started this drag in the first place + if not self._draggingHandleId then + return + end + if getFFlagSummonPivot() and not self._tabKeyDown then + self:_endSummon() + end + + self._draggingHandleId = nil + local newSelectionInfoHint = self._implementation:endDrag() + self._schema.addUndoWaypoint(self._draggerContext, "Axis Rotate Selection") + return newSelectionInfoHint +end + +function RotateHandles:_updateHandles() + if self._selectionInfo:isEmpty() then + self._handles = {} + else + for handleId, handleDefinition in pairs(RotateHandleDefinitions) do + if not handleDefinition.HideWhenTempPart or true then + self._handles[handleId] = { + HandleCFrame = getEngineFeatureModelPivotVisual() and + (self._boundingBox.CFrame * self:_getBasisOffset() * handleDefinition.Offset) or + (self._boundingBox.CFrame * handleDefinition.Offset), + Color = handleDefinition.Color, + RadiusOffset = handleDefinition.RadiusOffset, + Scale = self._scale, + } + else + self._handles[handleId] = nil + end + end + end +end + +if getEngineFeatureModelPivotVisual() and getFFlagSummonPivot() then + function RotateHandles:keyDown(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = true + if not self._draggingHandleId then + self:_summonHandles() + return true + end + end + return false + end + + function RotateHandles:keyUp(keyCode) + if keyCode == Enum.KeyCode.Tab then + self._tabKeyDown = false + if not self._draggingHandleId then + self:_endSummon() + return true + end + end + return false + end +end + +return RotateHandles diff --git a/src/Implementation/DraggerContext_FixtureImpl.lua b/src/Implementation/DraggerContext_FixtureImpl.lua new file mode 100644 index 0000000..38b4d68 --- /dev/null +++ b/src/Implementation/DraggerContext_FixtureImpl.lua @@ -0,0 +1,359 @@ +--[[ + DraggerContext is a class which wraps all of the global state which the + dragger needs to access to operate. This is the fixture implementation of + the context, which allows the values which the globals will have to be + expicitly set, to be used in doing testing. +]] + +local StudioService = game:GetService("StudioService") +local ChangeHistoryService = game:GetService("ChangeHistoryService") + +local DraggerFramework = script.Parent.Parent + +local Colors = require(DraggerFramework.Utility.Colors) + +local MockAnalytics = require(DraggerFramework.Utility.MockAnalytics) + +local DraggerContext = {} +DraggerContext.__index = DraggerContext + +local RAYCAST_DIRECTION_SCALE = 10000 + +local VIEWPORT_SIZE = 1000 + +local MAX_UNDO_WAYPOINTS = 20 + +function DraggerContext.new(guiTarget, selection) + assert(selection ~= nil) + return setmetatable({ + _guiTarget = guiTarget, + _useLocalSpace = false, + _areCollisionsEnabled = true, + _areConstraintsEnabled = false, + _areConstraintDetailsShown = false, + _drawConstraintsOnTop = false, + _shouldJoinSurfaces = true, + _mouseLocation = Vector2.new(), + _mouseUnitRay = Ray.new(Vector3.new(), Vector3.new()), + _cameraCFrame = CFrame.new(), + _cameraSize = 10, + _mouseIcon = "", + _isSimulating = false, + _gridSize = 1, + _rotateIncrement = math.rad(30), + _selection = selection, + _undoWaypoints = {}, + _isAltDown = false, + _isCtrlDown = false, + _isShiftDown = false, + _settingValues = {}, + }, DraggerContext) +end + +-- What instance should the plugin's GUI objects get created under? +function DraggerContext:getGuiParent() + return self._guiTarget +end + +function DraggerContext:setHoverInstance(instance) + self._hoverInstance = instance +end + +function DraggerContext:expectHoverInstance(instance) + if self._hoverInstance ~= instance then + local expected = instance and instance:GetFullName() or "nil" + local got = self._hoverInstance and self._hoverInstance:GetFullName() or "nil" + error("Wrong hover instance,\n Expected: " .. expected .. "\n Got: " .. got) + end +end + +function DraggerContext:shouldUseLocalSpace() + return self._useLocalSpace +end + +function DraggerContext:setUseLocalSpace(value) + self._useLocalSpace = value +end + +function DraggerContext:areCollisionsEnabled() + return self._areCollisionsEnabled +end + +function DraggerContext:setCollisionsEnabled(value) + self._areCollisionsEnabled = value +end + +function DraggerContext:areConstraintsEnabled() + return self._areConstraintsEnabled +end + +function DraggerContext:setConstraintsEnabled(value) + self._areConstraintsEnabled = value +end + +function DraggerContext:areConstraintDetailsShown() + return self._areConstraintDetailsShown +end + +function DraggerContext:setConstraintDetailsShown(value) + self._areConstraintDetailsShown = value +end + +function DraggerContext:shouldDrawConstraintsOnTop() + return self._drawConstraintsOnTop +end + +function DraggerContext:setDrawConstraintsOnTop(value) + self._drawConstraintsOnTop = value +end + +function DraggerContext:shouldJoinSurfaces() + return self._shouldJoinSurfaces +end + +function DraggerContext:setJoinSurfaces(value) + assert(typeof(value) == "boolean") -- Don't try to pass the Enum + self._shouldJoinSurfaces = value +end + +function DraggerContext:shouldShowHover() + return true +end + +function DraggerContext:shouldAnimateHover() + return true +end + +function DraggerContext:shouldSelectScopeByDefault() + return true +end + +function DraggerContext:getHoverAnimationSpeedInSeconds() + return 0.5 +end + +function DraggerContext:getHoverBoxColor(isActive) + return Color3.new() +end + +function DraggerContext:getHoverLineThickness() + return 0.04 +end + +function DraggerContext:getSelectionBoxColor(isActive) + return Color3.new() +end + +function DraggerContext:getGeometrySnapColor() + return Color3.new() +end + +function DraggerContext:getCameraCFrame() + return self._cameraCFrame +end + +function DraggerContext:setCamera(cframe, size) + self._cameraCFrame = cframe + self._cameraSize = size or 10 +end + +function DraggerContext:getHandleScale(focusPoint) + return 1.0 +end + +function DraggerContext:getMouseUnitRay() + return self:viewportPointToRay(self._mouseLocation) +end + +function DraggerContext:getMouseRay() + local unitRay = self:getMouseUnitRay() + return Ray.new(unitRay.Origin, unitRay.Direction * RAYCAST_DIRECTION_SCALE) +end + +function DraggerContext:getMouseLocation() + return self._mouseLocation +end + +function DraggerContext:setMouseLocation(location) + self._mouseLocation = location +end + +function DraggerContext:viewportPointToRay(screenPoint) + local x = (screenPoint.X / VIEWPORT_SIZE - 0.5) * self._cameraSize + local y = (screenPoint.Y / VIEWPORT_SIZE - 0.5) * self._cameraSize + local at = self._cameraCFrame:PointToWorldSpace(Vector3.new(x, y, 0)) + return Ray.new(at, self._cameraCFrame.LookVector) +end + +function DraggerContext:worldToViewportPoint(worldPoint) + local point = self._cameraCFrame:Inverse() * worldPoint + local x = (point.X / self._cameraSize + 0.5) * VIEWPORT_SIZE + local y = (point.Y / self._cameraSize + 0.5) * VIEWPORT_SIZE + local onScreen = + (x >= 0 and x <= VIEWPORT_SIZE) and + (y >= 0 and y <= VIEWPORT_SIZE) and + point.Z < 0 + return Vector2.new(x, y), onScreen +end + +function DraggerContext:getViewportSize() + return Vector2.new(VIEWPORT_SIZE, VIEWPORT_SIZE) +end + +function DraggerContext:setMouseIcon(icon) + self._mouseIcon = icon +end + +function DraggerContext:expectMouseIcon(icon) + if self._mouseIcon ~= icon then + local expected = icon or "nil" + local got = self._mouseIcon or "nil" + error("Wrong mouse icon,\n Expected: " .. expected .. "\n Got: " .. got) + end +end + +function DraggerContext:getSelection() + return self._selection +end + +-- Are non-anchored parts in the world currently being physically simulated? +-- (i.e. are they moving around of thier own accord) +function DraggerContext:isSimulating() + return self._isSimulating +end + +function DraggerContext:setSimulating(value) + self._isSimulating = value +end + +function DraggerContext:isAltKeyDown() + return self._isAltDown +end + +function DraggerContext:isCtrlKeyDown() + return self._isCtrlDown +end + +function DraggerContext:isShiftKeyDown() + return self._isShiftDown +end + +function DraggerContext:shouldExtendSelection() + return self:isCtrlKeyDown() or self:isShiftKeyDown() +end + +function DraggerContext:setCtrlAltShift(ctrl, alt, shift) + self._isCtrlDown = ctrl + self._isAltDown = alt + self._isShiftDown = shift +end + +function DraggerContext:getGridSize() + return self._gridSize +end + +function DraggerContext:snapToGridSize(distance) + -- Use an exact check here because we're in control of things, we can set + -- exactly grid size = 0 when snapping should be disabled. + if self._gridSize > 0 then + return math.floor(distance / self._gridSize + 0.5) * self._gridSize + else + return distance + end +end + +function DraggerContext:getRotateIncrement() + return self._rotateIncrement +end + +function DraggerContext:setGridSize(value) + self._gridSize = math.max(value, 0.001) +end + +function DraggerContext:setRotateIncrement(value) + self._rotateIncrement = value +end + +function DraggerContext:getAnalytics() + return MockAnalytics +end + +function DraggerContext:gizmoRaycast(origin, direction, raycastParams) + return StudioService:GizmoRaycast(origin, direction, raycastParams) +end + +function DraggerContext:setInsertPoint(location) + self._insertPoint = location +end + +function DraggerContext:expectInsertPoint(location) + if self._insertPoint ~= location then + local expected = tostring(location) + local got = tostring(self._insertPoint) + error("Wrong insert point,\n Expected: " .. expected .. "\n Got: " .. got) + end +end + +function DraggerContext:shouldShowActiveInstanceHighlight() + return true +end + +function DraggerContext:shouldAlignDraggedObjects() + return true +end + +function DraggerContext:addUndoWaypoint(waypointIdentifier, waypointText) + if ChangeHistoryService then + ChangeHistoryService:SetWaypoint(waypointIdentifier) + end + table.insert(self._undoWaypoints, waypointIdentifier) + while #self._undoWaypoints > MAX_UNDO_WAYPOINTS do + table.remove(self._undoWaypoints, 1) + end +end + +function DraggerContext:expectMostRecentUndoWaypoint(waypointIdentifier) + local mostRecent = self._undoWaypoints[#self._undoWaypoints] + if mostRecent ~= waypointIdentifier then + error("Wrong last undo waypoint,\n Expected: " .. waypointIdentifier .. + "\n Got: " .. (mostRecent or "")) + end +end + +function DraggerContext:expectAndUndo(waypointIdentifier) + self:expectMostRecentUndoWaypoint(waypointIdentifier) + self._undoWaypoints[#self._undoWaypoints] = nil + if ChangeHistoryService then + ChangeHistoryService:Undo() + end +end + +function DraggerContext:getText(scope, key, args) + if args then + return ("%s.%s (%s)").format(scope, key, table.concat(args, ",")) + else + return ("%s.%s").format(scope, key) + end +end + +function DraggerContext:getThemeColor(item) + if item == Enum.StudioStyleGuideColor.MainBackground then + return Colors.WHITE + else + return Colors.BLACK + end +end + +function DraggerContext:getSetting(name) + return self._settingValues[name] +end + +function DraggerContext:setSetting(name, value) + self._settingValues[name] = value +end + +function DraggerContext:setPivotIndicator(state) + return false +end + +return DraggerContext diff --git a/src/Implementation/DraggerContext_PluginImpl.lua b/src/Implementation/DraggerContext_PluginImpl.lua new file mode 100644 index 0000000..760a074 --- /dev/null +++ b/src/Implementation/DraggerContext_PluginImpl.lua @@ -0,0 +1,307 @@ +--[[ + DraggerContext is a class which wraps all of the global state which the + dragger needs to access to operate. This is the plugin implementation of the + context, which pulls those globals from the studio session's services, to + be used by a plugin. +]] + +local DraggerFramework = script.Parent.Parent + +local Analytics = require(DraggerFramework.Utility.Analytics) +local setInsertPoint = require(DraggerFramework.Utility.setInsertPoint) + +local FallbackLocalizationTable = DraggerFramework.Resources.TranslationDevelopmentTable +local TranslatedLocalizationTable = DraggerFramework.Resources.TranslationReferenceTable + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +local DraggerContext = {} +DraggerContext.__index = DraggerContext + +local RAYCAST_DIRECTION_SCALE = 10000 +local HANDLE_SCALE_FACTOR = 0.05 +local FALLBACK_LOCALE = "en_US" + +function DraggerContext.new(plugin, editDataModel, userSettings, selection) + return setmetatable({ + _editDataModel = editDataModel, + _plugin = plugin, + _userSettings = userSettings, + _studioService = editDataModel:GetService("StudioService"), + _draggerService = getFFlagSummonPivot() and editDataModel:GetService("DraggerService") or nil, + _runService = editDataModel:GetService("RunService"), + _studioSettings = userSettings.Studio, + _workspace = editDataModel:GetService("Workspace"), + _userInputService = editDataModel:GetService("UserInputService"), + _changeHistoryService = editDataModel:GetService("ChangeHistoryService"), + _mouse = plugin:GetMouse(), + _selection = selection, + LocaleChangedSignal = editDataModel:GetService("StudioService"):GetPropertyChangedSignal("StudioLocaleId"), + _fallbackTranslators = {}, + _translators = {}, + }, DraggerContext) +end + +-- What instance should the plugin's GUI objects get created under? +function DraggerContext:getGuiParent() + return self._editDataModel:GetService("CoreGui") +end + +function DraggerContext:setHoverInstance(instance) + --self._studioService.HoverInstance = instance +end + +function DraggerContext:shouldUseLocalSpace() + return self._studioService.UseLocalSpace +end + +function DraggerContext:areCollisionsEnabled() + return self._plugin.CollisionEnabled +end + +function DraggerContext:areConstraintsEnabled() + return self._studioService.DraggerSolveConstraints +end + +function DraggerContext:areConstraintDetailsShown() + return self._studioService.ShowConstraintDetails +end + +function DraggerContext:shouldDrawConstraintsOnTop() + return self._studioService.DrawConstraintsOnTop +end + +function DraggerContext:shouldJoinSurfaces() + return self._plugin:GetJoinMode() ~= Enum.JointCreationMode.None +end + +function DraggerContext:shouldShowHover() + return self._studioSettings["Show Hover Over"] +end + +function DraggerContext:shouldAnimateHover() + return self._studioSettings["Animate Hover Over"] +end + +function DraggerContext:shouldSelectScopeByDefault() + return self._studioSettings["Physical Draggers Select Scope By Default"] +end + +function DraggerContext:getHoverAnimationSpeedInSeconds() + local speed = self._studioSettings["Hover Animate Speed"] + if speed == Enum.HoverAnimateSpeed.VerySlow then + return 2 + elseif speed == Enum.HoverAnimateSpeed.Slow then + return 1 + elseif speed == Enum.HoverAnimateSpeed.Medium then + return 0.5 + elseif speed == Enum.HoverAnimateSpeed.Fast then + return 0.25 + elseif speed == Enum.HoverAnimateSpeed.VeryFast then + return 0.1 + end + return 0 +end + +function DraggerContext:getHoverBoxColor(isActive) + if isActive then + return self._studioSettings["Active Hover Over Color"] + else + return self._studioSettings["Hover Over Color"] + end +end + +function DraggerContext:getHoverLineThickness() + if getFFlagSummonPivot() then + return self._draggerService.HoverThickness + else + return 0.04 + end +end + +function DraggerContext:getSelectionBoxColor(isActive) + if isActive then + return self._studioSettings["Active Color"] + else + return self._studioSettings["Select Color"] + end +end + +function DraggerContext:getGeometrySnapColor() + return self._draggerService.GeometrySnapColor +end + +function DraggerContext:getCameraCFrame() + return self._workspace.CurrentCamera.CFrame +end + +function DraggerContext:getMouseUnitRay() + return self._mouse.UnitRay +end + +function DraggerContext:getHandleScale(focusPoint) + local distance = (self:getCameraCFrame().Position - focusPoint).Magnitude + local angleFrac = math.sin(math.rad(self._workspace.CurrentCamera.FieldOfView)) + return angleFrac * distance * HANDLE_SCALE_FACTOR +end + +function DraggerContext:getMouseRay() + local unitRay = self:getMouseUnitRay() + return Ray.new(unitRay.Origin, unitRay.Direction * RAYCAST_DIRECTION_SCALE) +end + +function DraggerContext:getMouseLocation() + return self._userInputService:GetMouseLocation() +end + +function DraggerContext:viewportPointToRay(mouseLocation) + return self._workspace.CurrentCamera:ViewportPointToRay(mouseLocation.X, mouseLocation.Y) +end + +function DraggerContext:worldToViewportPoint(worldPoint) + return self._workspace.CurrentCamera:WorldToViewportPoint(worldPoint) +end + +function DraggerContext:getViewportSize() + return self._workspace.CurrentCamera.ViewportSize +end + +function DraggerContext:setMouseIcon(icon) + self._mouse.Icon = icon +end + +function DraggerContext:getSelection() + return self._selection +end + +-- Are non-anchored parts in the world currently being physically simulated? +-- (i.e. are they moving around of thier own accord) +function DraggerContext:isSimulating() + return self._runService:IsRunning() +end + +function DraggerContext:isAltKeyDown() + return self._userInputService:IsKeyDown(Enum.KeyCode.LeftAlt) or + self._userInputService:IsKeyDown(Enum.KeyCode.RightAlt) +end + +function DraggerContext:isCtrlKeyDown() + return self._userInputService:IsKeyDown(Enum.KeyCode.LeftControl) or + self._userInputService:IsKeyDown(Enum.KeyCode.RightControl) +end + +function DraggerContext:isShiftKeyDown() + return self._userInputService:IsKeyDown(Enum.KeyCode.LeftShift) or + self._userInputService:IsKeyDown(Enum.KeyCode.RightShift) +end + +function DraggerContext:shouldExtendSelection() + return self:isShiftKeyDown() +end + +function DraggerContext:getGridSize() + return self._studioService.GridSize +end + +-- Wrapped in a function because of the awkward situation where there's no +-- explicit way to determine whether grid snapping is on or off right now, and +-- the implementation of this may change once there is. +-- Currently DISABLED_GRID_SIZE is what StudioService returns when grid snapping +-- is disabled, so detect based on that. +local DISABLED_GRID_SIZE = 0.01 +function DraggerContext:snapToGridSize(distance) + local gridSize = self._studioService.GridSize + if math.abs(gridSize - DISABLED_GRID_SIZE) < 0.001 then + return distance + else + return math.floor(distance / gridSize + 0.5) * gridSize + end +end + +function DraggerContext:getRotateIncrement() + return self._studioService.RotateIncrement +end + +function DraggerContext:getAnalytics() + return Analytics +end + +function DraggerContext:gizmoRaycast(origin, direction, raycastParams) + return self._studioService:GizmoRaycast(origin, direction, raycastParams) +end + +function DraggerContext:setInsertPoint(location) + setInsertPoint(location) +end + +function DraggerContext:shouldShowActiveInstanceHighlight() + return false --self._studioService.ShowActiveInstanceHighlight +end + +function DraggerContext:shouldAlignDraggedObjects() + return true --self._studioService.AlignDraggedObjects +end + +function DraggerContext:addUndoWaypoint(waypointIdentifier, waypointText) + -- Nothing to do with waypoint text currently, but we will need to do + -- something with localizing undo waypoints eventually. + self._changeHistoryService:SetWaypoint(waypointIdentifier) +end + +-- TODO mlangen: Share this code with DevFramework somehow. We don't want to +-- include the entirety of DevFramework just to translate a couple of strings, +-- but we do really want to share just the localization part of DevFramework. +-- For now, this code does mostly the same thing as DevFramework's Localization +-- class. +function DraggerContext:getText(scope, key, args) + key = ("Studio.DraggerFramework.%s.%s"):format(scope, key) + local locale = self._studioService.StudioLocaleId + if locale == FALLBACK_LOCALE then + local fallbackTranslator = self._fallbackTranslators[locale] + if not fallbackTranslator then + fallbackTranslator = FallbackLocalizationTable:GetTranslator(FALLBACK_LOCALE) + self._fallbackTranslators[locale] = fallbackTranslator + end + return fallbackTranslator:FormatByKey(key, args) + else + local referenceTranslator = self._translators[locale] + if not referenceTranslator then + referenceTranslator = TranslatedLocalizationTable:GetTranslator(locale) + self._translators[locale] = referenceTranslator + end + local success, result = pcall(function() + return referenceTranslator:FormatByKey(key, args) + end) + if success then + return result + else + local fallbackTranslator = self._fallbackTranslators[locale] + if not fallbackTranslator then + fallbackTranslator = FallbackLocalizationTable:GetTranslator(FALLBACK_LOCALE) + self._fallbackTranslators[locale] = fallbackTranslator + end + return fallbackTranslator:FormatByKey(key, args) + end + end +end + +-- Takes a StudioStyleGuideColor enum +function DraggerContext:getThemeColor(item) + return self._studioSettings.Theme:GetColor(item) +end + +function DraggerContext:getSetting(name) + return self._plugin:GetSetting(name) +end + +function DraggerContext:setSetting(name, value) + self._plugin:SetSetting(name, value) +end + +function DraggerContext:setPivotIndicator(state) + local oldValue = self._draggerService.ShowPivotIndicator + self._draggerService.ShowPivotIndicator = state + return oldValue +end + +return DraggerContext \ No newline at end of file diff --git a/src/Implementation/DraggerStateType.lua b/src/Implementation/DraggerStateType.lua new file mode 100644 index 0000000..92bdc40 --- /dev/null +++ b/src/Implementation/DraggerStateType.lua @@ -0,0 +1,19 @@ + +local StateType = {} +setmetatable(StateType, { + __index = function(self, index) + error("Attempt to get invalid StateType `"..tostring(index).."`") + end, +}) + +StateType.Ready = "Ready" +-- Clicked a part, but haven't started dragging: +StateType.PendingDraggingParts = "PendingDraggingParts" +-- No-op clicked the selection, when releasing the mouse, try to select next: +StateType.PendingSelectNext = "PendingSelectNext" +StateType.DraggingHandle = "DraggingHandle" +StateType.DraggingParts = "DraggingParts" +StateType.DragSelecting = "DragSelecting" +StateType.DraggingFaceInstance = "DraggingFaceInstance" + +return StateType \ No newline at end of file diff --git a/src/Implementation/DraggerStates/DragSelecting.lua b/src/Implementation/DraggerStates/DragSelecting.lua new file mode 100644 index 0000000..f92a783 --- /dev/null +++ b/src/Implementation/DraggerStates/DragSelecting.lua @@ -0,0 +1,88 @@ +local DraggerFramework = script.Parent.Parent.Parent +local Packages = DraggerFramework.Parent + +local Roact = require(Packages.Roact) +local DragSelectionView = require(DraggerFramework.Components.DragSelectionView) +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local DragSelector = require(DraggerFramework.Utility.DragSelector) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local DragSelecting = {} +DragSelecting.__index = DragSelecting + +function DragSelecting.new(draggerToolModel) + local self = setmetatable({ + _dragSelector = DragSelector.new( + draggerToolModel:getSelectionWrapper(), + draggerToolModel:getSchema().beginBoxSelect, + draggerToolModel:getSchema().endBoxSelect), + _draggerToolModel = draggerToolModel, + }, DragSelecting) + self:_init() + return self +end + +function DragSelecting:enter() + self._mouseStartLocation = + self._draggerToolModel._draggerContext:getMouseLocation() +end + +function DragSelecting:leave() + +end + +function DragSelecting:_init() + self._draggerToolModel._sessionAnalytics.dragSelects = self._draggerToolModel._sessionAnalytics.dragSelects + 1 + self._hasMovedMouse = false +end + +function DragSelecting:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getArrow()) + + local startLocation = + self._hasMovedMouse and + self._dragSelector:getStartLocation() or + self._draggerToolModel._draggerContext:getMouseLocation() + return Roact.createElement(DragSelectionView, { + DragStartLocation = startLocation, + DragEndLocation = self._draggerToolModel._draggerContext:getMouseLocation(), + }) +end + +function DragSelecting:processSelectionChanged() + -- Don't do anything. We don't want to unnecessarily fight other sources + -- over selection changes. +end + +function DragSelecting:processMouseDown() + error("Mouse should already be down while drag selecting.") +end + +function DragSelecting:processViewChanged() + if not self._hasMovedMouse then + self._dragSelector:beginDrag( + self._draggerToolModel._draggerContext, self._mouseStartLocation) + self._hasMovedMouse = true + end + self._dragSelector:updateDrag(self._draggerToolModel._draggerContext) +end + +function DragSelecting:processMouseUp() + if self._hasMovedMouse then + self._dragSelector:commitDrag(self._draggerToolModel._draggerContext) + self._draggerToolModel:_updateSelectionInfo() + self._hasMovedMouse = false + end + self._draggerToolModel:_analyticsSendBoxSelect() + self._draggerToolModel:transitionToState(DraggerStateType.Ready) +end + +function DragSelecting:processKeyDown(keyCode) + -- Nothing to do +end + +function DragSelecting:processKeyUp(keyCode) + -- Nothing to do. +end + +return DragSelecting \ No newline at end of file diff --git a/src/Implementation/DraggerStates/DraggingFaceInstance.lua b/src/Implementation/DraggerStates/DraggingFaceInstance.lua new file mode 100644 index 0000000..f910b04 --- /dev/null +++ b/src/Implementation/DraggerStates/DraggingFaceInstance.lua @@ -0,0 +1,81 @@ +--[[ + When dragging over a Part, DraggingFaceInstance parents the instance onto + the part and sets the instance's "Face" property to the closest Surface. +]] +local DraggerFramework = script.Parent.Parent.Parent + +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local DragHelper = require(DraggerFramework.Utility.DragHelper) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local SURFACE_TO_FACE = { + ["TopSurface"] = "Top", + ["BottomSurface"] = "Bottom", + ["LeftSurface"] = "Left", + ["RightSurface"] = "Right", + ["FrontSurface"] = "Front", + ["BackSurface"] = "Back", +} + +local DraggingFaceInstance = {} +DraggingFaceInstance.__index = DraggingFaceInstance + +function DraggingFaceInstance.new(draggerToolModel, connectionToBreak) + local self = setmetatable({ + _draggerToolModel = draggerToolModel, + _connectionToBreak = connectionToBreak, + }, DraggingFaceInstance) + return self +end + +function DraggingFaceInstance:enter() +end + +function DraggingFaceInstance:leave() + self._draggerToolModel._draggerSchema.addUndoWaypoint(self._draggerToolModel._draggerContext, "Drag Face Instance") + + if self._connectionToBreak then + self._connectionToBreak:Disconnect() + end +end + +function DraggingFaceInstance:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) +end + +function DraggingFaceInstance:processSelectionChanged() + self:_endDrag() +end + +function DraggingFaceInstance:processMouseDown() +end + +function DraggingFaceInstance:processViewChanged() + local part, surface = DragHelper.getPartAndSurface(self._draggerToolModel._draggerContext:getMouseRay()) + local configurableFaces = self._draggerToolModel._selectionInfo.instancesWithConfigurableFace + + if configurableFaces then + for _, instance in pairs(configurableFaces) do + if part and surface then + instance.Parent = part + instance.Face = SURFACE_TO_FACE[surface] + end + end + end +end + +function DraggingFaceInstance:processMouseUp() + self:_endDrag() +end + +function DraggingFaceInstance:processKeyDown(keyCode) +end + +function DraggingFaceInstance:processKeyUp(keyCode) +end + +function DraggingFaceInstance:_endDrag() + self._draggerToolModel:transitionToState(DraggerStateType.Ready) +end + +return DraggingFaceInstance \ No newline at end of file diff --git a/src/Implementation/DraggerStates/DraggingHandle.lua b/src/Implementation/DraggerStates/DraggingHandle.lua new file mode 100644 index 0000000..0163d68 --- /dev/null +++ b/src/Implementation/DraggerStates/DraggingHandle.lua @@ -0,0 +1,138 @@ +local DraggerFramework = script.Parent.Parent.Parent +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +local DraggingHandle = {} +DraggingHandle.__index = DraggingHandle + +function DraggingHandle.new(draggerToolModel, draggingHandles, draggingHandleId) + local self = setmetatable({ + _draggerToolModel = draggerToolModel, + }, DraggingHandle) + self:_init(draggingHandles, draggingHandleId) + return self +end + +function DraggingHandle:enter() +end + +function DraggingHandle:leave() +end + +function DraggingHandle:_init(draggingHandles, draggingHandleId) + assert(draggingHandleId, "Missing draggingHandleId") + + self._draggerToolModel._sessionAnalytics.handleDrags = self._draggerToolModel._sessionAnalytics.handleDrags + 1 + self._draggerToolModel._boundsChangedTracker:uninstall() + draggingHandles:mouseDown(self._draggerToolModel._draggerContext:getMouseRay(), draggingHandleId) + self._draggingHandleId = draggingHandleId + self._draggingHandles = draggingHandles +end + +function DraggingHandle:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) + + return self._draggingHandles:render(self._draggingHandleId) +end + +function DraggingHandle:processSelectionChanged() + if self._alreadyEndingDrag then + return + end + -- Re-init the drag if the selection changes. + local invokedBySelectionChange = true + self:_endHandleDrag(invokedBySelectionChange) + self:_init(self._draggingHandles, self._draggingHandleId) +end + +function DraggingHandle:processMouseDown() + error("Mouse should already be down while dragging handle.") +end + +function DraggingHandle:processViewChanged() + self._draggingHandles:mouseDrag( + self._draggerToolModel._draggerContext:getMouseRay()) +end + +function DraggingHandle:processMouseUp() + local invokedBySelectionChange = false + self:_endHandleDrag(invokedBySelectionChange) + self._draggerToolModel:transitionToState(DraggerStateType.Ready) +end + +function DraggingHandle:processKeyDown(keyCode) + if getFFlagSummonPivot() then + for _, handles in pairs(self._draggerToolModel:getHandlesList()) do + if handles.keyDown then + if handles:keyDown(keyCode) then + self:processViewChanged() + self._draggerToolModel:_scheduleRender() + end + end + end + else + if self._draggingHandles.keyDown then + if self._draggingHandles:keyDown(keyCode) then + -- Update the drag + self:processViewChanged() + if getEngineFeatureModelPivotVisual() then + self._draggerToolModel:_scheduleRender() + end + end + end + end +end + +function DraggingHandle:processKeyUp(keyCode) + if getFFlagSummonPivot() then + for _, handles in pairs(self._draggerToolModel:getHandlesList()) do + if handles.keyUp then + if handles:keyUp(keyCode) then + self:processViewChanged() + self._draggerToolModel:_scheduleRender() + end + end + end + else + if self._draggingHandles.keyUp then + if self._draggingHandles:keyUp(keyCode) then + -- Update the drag + self:processViewChanged() + if getEngineFeatureModelPivotVisual() then + self._draggerToolModel:_scheduleRender() + end + end + end + end +end + +function DraggingHandle:_endHandleDrag(invokedBySelectionChange: boolean) + self._alreadyEndingDrag = true + -- Commit the results of using the tool + + local newSelectionInfoHint = self._draggingHandles:mouseUp( + self._draggerToolModel._draggerContext:getMouseRay()) + if invokedBySelectionChange then + -- If the drag was ended by a selection change, then our computed + -- selection info hint will be stale, because it applies to the last + -- selection, rather than the new selection. So we don't use it. + self._draggerToolModel:_updateSelectionInfo(nil) + else + -- Use the Implementation's modification to the SelectionInfo hint + self._draggerToolModel:_updateSelectionInfo(newSelectionInfoHint) + end + + self._draggerToolModel._boundsChangedTracker:install() + + self._draggerToolModel:getSchema().setActivePoint( + self._draggerToolModel._draggerContext, + self._draggerToolModel._selectionInfo) + + self._draggerToolModel:_analyticsSendHandleDragged(self._draggingHandleId) + self._alreadyEndingDrag = false +end + +return DraggingHandle \ No newline at end of file diff --git a/src/Implementation/DraggerStates/DraggingParts.lua b/src/Implementation/DraggerStates/DraggingParts.lua new file mode 100644 index 0000000..7c27f2d --- /dev/null +++ b/src/Implementation/DraggerStates/DraggingParts.lua @@ -0,0 +1,118 @@ +local Workspace = game:GetService("Workspace") +local ChangeHistoryService = game:GetService("ChangeHistoryService") + +local DraggerFramework = script.Parent.Parent.Parent + +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local DragHelper = require(DraggerFramework.Utility.DragHelper) +local PartMover = require(DraggerFramework.Utility.PartMover) +local AttachmentMover = require(DraggerFramework.Utility.AttachmentMover) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local DraggingParts = {} +DraggingParts.__index = DraggingParts + +function DraggingParts.new(draggerToolModel, dragInfo) + local t = tick() + draggerToolModel._boundsChangedTracker:uninstall() + local self = setmetatable({ + _draggerToolModel = draggerToolModel, + _freeformDragger = draggerToolModel:getSchema().FreeformDragger.new( + draggerToolModel._draggerContext, draggerToolModel, dragInfo) + }, DraggingParts) + local timeToStartDrag = tick() - t + draggerToolModel:_analyticsRecordFreeformDragBegin(timeToStartDrag) + return self +end + +function DraggingParts:enter() + self:_updateFreeformSelectionDrag() +end + +function DraggingParts:leave() +end + +function DraggingParts:_initIgnoreList(parts) + local filter = table.create(#parts + 1) + for i, part in ipairs(parts) do + filter[i] = part + end + table.insert(filter, self._partMover:getIgnorePart()) + self._raycastFilter = filter +end + +function DraggingParts:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) + + return self._freeformDragger:render() +end + +function DraggingParts:processSelectionChanged() + if self._alreadyEndingDrag then + return + end + -- If something unexpectedly changes the selection out from underneath us, + -- bail out of the drag. + self:_endFreeformSelectionDrag() +end + +function DraggingParts:processMouseDown() + error("Mouse should already be down while dragging parts.") +end + +function DraggingParts:processViewChanged() + self:_updateFreeformSelectionDrag() +end + +function DraggingParts:processMouseUp() + self:_endFreeformSelectionDrag() +end + +function DraggingParts:processKeyDown(keyCode) + if keyCode == Enum.KeyCode.R then + self._draggerToolModel._sessionAnalytics.dragRotates = self._draggerToolModel._sessionAnalytics.dragRotates + 1 + self:_tiltRotateFreeformSelectionDrag(Vector3.new(0, 1, 0)) + elseif keyCode == Enum.KeyCode.T then + self._draggerToolModel._sessionAnalytics.dragTilts = self._draggerToolModel._sessionAnalytics.dragTilts + 1 + self:_tiltRotateFreeformSelectionDrag(Vector3.new(1, 0, 0)) + end +end + +function DraggingParts:processKeyUp(keyCode) +end + +function DraggingParts:_tiltRotateFreeformSelectionDrag(axis) + self._freeformDragger:rotate(axis) + + self:_updateFreeformSelectionDrag() + self._draggerToolModel:_scheduleRender() +end + +function DraggingParts:_updateFreeformSelectionDrag() + self._freeformDragger:update() +end + +--[[ + Refresh selection info to reflect the new CFrames of the dragged parts + and return to the Ready state. +]] +function DraggingParts:_endFreeformSelectionDrag() + self._alreadyEndingDrag = true + local newSelectionInfoHint = self._freeformDragger:destroy() + + self._draggerToolModel._boundsChangedTracker:install() + + self._draggerToolModel:_updateSelectionInfo(newSelectionInfoHint) + + self._draggerToolModel:transitionToState(DraggerStateType.Ready) + + self._draggerToolModel:getSchema().addUndoWaypoint( + self._draggerToolModel._draggerContext, + "End Freeform Drag") + self._draggerToolModel:getSchema().setActivePoint( + self._draggerToolModel._draggerContext, + self._draggerToolModel._selectionInfo) + self._alreadyEndingDrag = false +end + +return DraggingParts \ No newline at end of file diff --git a/src/Implementation/DraggerStates/PendingDraggingParts.lua b/src/Implementation/DraggerStates/PendingDraggingParts.lua new file mode 100644 index 0000000..7c27d33 --- /dev/null +++ b/src/Implementation/DraggerStates/PendingDraggingParts.lua @@ -0,0 +1,68 @@ +local DraggerFramework = script.Parent.Parent.Parent +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local FREEFORM_DRAG_THRESHOLD = 4 + +local PendingDraggingParts = {} +PendingDraggingParts.__index = PendingDraggingParts + +function PendingDraggingParts.new(draggerToolModel, isDoubleClick, dragInfo) + return setmetatable({ + _isDoubleClick = isDoubleClick, + _dragStartLocation = draggerToolModel._draggerContext:getMouseLocation(), + _dragInfo = dragInfo, + _draggerToolModel = draggerToolModel + }, PendingDraggingParts) +end + +function PendingDraggingParts:enter() + +end + +function PendingDraggingParts:leave() + +end + +function PendingDraggingParts:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getClosedHand()) +end + +function PendingDraggingParts:processSelectionChanged() + -- Don't clear the state back to Ready in this case. In Run mode the + -- selection should change while we're sitting in Pending state, and + -- that's okay, because we already recorded how to drag the selection + -- relative to the mouse on down. +end + +function PendingDraggingParts:processMouseDown() + error("Mouse should already be down while pending part drag.") +end + +function PendingDraggingParts:processViewChanged() + local location = self._draggerToolModel._draggerContext:getMouseLocation() + local screenMovement = location - self._dragStartLocation + + if screenMovement.Magnitude > FREEFORM_DRAG_THRESHOLD then + self._draggerToolModel:transitionToState(DraggerStateType.DraggingParts, self._dragInfo) + end +end + +function PendingDraggingParts:processMouseUp() + -- If the mouse didn't move enough to start a drag, try to select next + -- selectables instead. + self._draggerToolModel:selectNextSelectables( + self._dragInfo, self._isDoubleClick) + + self._draggerToolModel:transitionToState(DraggerStateType.Ready) +end + +function PendingDraggingParts:processKeyDown(keyCode) + -- Nothing to do. +end + +function PendingDraggingParts:processKeyUp(keyCode) + -- Nothing to do. +end + +return PendingDraggingParts \ No newline at end of file diff --git a/src/Implementation/DraggerStates/PendingSelectNext.lua b/src/Implementation/DraggerStates/PendingSelectNext.lua new file mode 100644 index 0000000..5c96c97 --- /dev/null +++ b/src/Implementation/DraggerStates/PendingSelectNext.lua @@ -0,0 +1,63 @@ +--[[ + When clicking on the selection in a way that doesn't change the selection on + mouse down, attempt to select the next selectables instead when the mouse is + released. The reason that we do this on mouse up is for uniformity with the + begin freeform drag behavior. +]] +local DraggerFramework = script.Parent.Parent.Parent + +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local PendingSelectNext = {} +PendingSelectNext.__index = PendingSelectNext + +function PendingSelectNext.new(draggerToolModel, isDoubleClick, dragInfo) + local self = setmetatable({ + _draggerToolModel = draggerToolModel, + _dragInfo = dragInfo, + _initialMouseLocation = draggerToolModel._draggerContext:getMouseLocation(), + _wasDoubleClick = isDoubleClick, + }, PendingSelectNext) + return self +end + +function PendingSelectNext:enter() +end + +function PendingSelectNext:leave() +end + +function PendingSelectNext:render() + self._draggerToolModel:setMouseCursor(StandardCursor.getOpenHand()) +end + +function PendingSelectNext:processSelectionChanged() + self:_transitionBack() +end + +function PendingSelectNext:processMouseDown() +end + +function PendingSelectNext:processViewChanged() +end + +function PendingSelectNext:processMouseUp() + if self._initialMouseLocation == self._draggerToolModel._draggerContext:getMouseLocation() then + -- Clicked nothing without moving + self._draggerToolModel:selectNextSelectables(self._dragInfo, self._wasDoubleClick) + end + self:_transitionBack() +end + +function PendingSelectNext:processKeyDown(keyCode) +end + +function PendingSelectNext:processKeyUp(keyCode) +end + +function PendingSelectNext:_transitionBack() + self._draggerToolModel:transitionToState(DraggerStateType.Ready) +end + +return PendingSelectNext \ No newline at end of file diff --git a/src/Implementation/DraggerStates/Ready.lua b/src/Implementation/DraggerStates/Ready.lua new file mode 100644 index 0000000..66045ef --- /dev/null +++ b/src/Implementation/DraggerStates/Ready.lua @@ -0,0 +1,294 @@ +local DraggerFramework = script.Parent.Parent.Parent +local Packages = DraggerFramework.Parent + +local Roact = require(Packages.Roact) +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local AnimatedHoverBox = require(DraggerFramework.Components.AnimatedHoverBox) +local LocalSpaceIndicator = require(DraggerFramework.Components.LocalSpaceIndicator) +local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) +local getGeometry = require(DraggerFramework.Utility.getGeometry) +local getFaceInstance = require(DraggerFramework.Utility.getFaceInstance) +local HoverTracker = require(DraggerFramework.Implementation.HoverTracker) +local StandardCursor = require(DraggerFramework.Utility.StandardCursor) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +local getFFlagFlippedScopeSelect = require(DraggerFramework.Flags.getFFlagFlippedScopeSelect) + +local getFFlagUseGetBoundingBox = require(DraggerFramework.Flags.getFFlagUseGetBoundingBox) + +local Ready = {} +Ready.__index = Ready + +function Ready.new(draggerToolModel) + return setmetatable({ + _draggerToolModel = draggerToolModel + }, Ready) +end + +function Ready:enter() + local function onHoverExternallyChanged() + self._draggerToolModel:_processViewChanged() + end + self._hoverTracker = + HoverTracker.new( + self._draggerToolModel:getSchema(), + self._draggerToolModel:getHandlesList(), + onHoverExternallyChanged) + self:_updateHoverTracker() +end + +function Ready:leave() + self._hoverTracker:clearHover(self._draggerToolModel._draggerContext) +end + +function Ready:render() + local elements = {} + + local draggerContext = self._draggerToolModel._draggerContext + + local hoverSelectable = self._hoverTracker:getHoverSelectable() + if draggerContext:shouldShowHover() and hoverSelectable then + -- Calls to the schema to know what kind of component to render the + -- selection box for the hovered object with. + local component = self._draggerToolModel:getSchema().getSelectionBoxComponent( + draggerContext, hoverSelectable) + if component then + local animatePeriod + if draggerContext:shouldAnimateHover() then + animatePeriod = draggerContext:getHoverAnimationSpeedInSeconds() + end + + local isActive = false + if draggerContext:shouldShowActiveInstanceHighlight() then + local activeInstance = + self._draggerToolModel:getSelectionWrapper():getActiveSelectable() + isActive = (hoverSelectable == activeInstance) + end + + elements.HoverBox = Roact.createElement(AnimatedHoverBox, { + -- Configurable component to render the selection box + SelectionBoxComponent = component, + HoverTarget = hoverSelectable, + SelectColor = draggerContext:getSelectionBoxColor(isActive), + LineThickness = draggerContext:getHoverLineThickness(), + HoverColor = draggerContext:getHoverBoxColor(isActive), + AnimatePeriod = animatePeriod, + }) + end + end + + if hoverSelectable or self._hoverTracker:getHoverHandleId() then + self._draggerToolModel:setMouseCursor(StandardCursor.getOpenHand()) + else + self._draggerToolModel:setMouseCursor(StandardCursor.getArrow()) + end + + if self._draggerToolModel:shouldShowLocalSpaceIndicator() then + local selectionInfo = self._draggerToolModel._selectionInfo + if not selectionInfo:isEmpty() and draggerContext:shouldUseLocalSpace() then + local cframe, offset, size + if getFFlagUseGetBoundingBox() then + cframe, offset, size = selectionInfo:getBoundingBox() + else + cframe, offset, size = selectionInfo:getLocalBoundingBox() + end + + elements.LocalSpaceIndicator = Roact.createElement(LocalSpaceIndicator, { + CFrame = cframe * CFrame.new(offset), + Size = size, + TextColor3 = draggerContext:getSelectionBoxColor(), + DraggerContext = draggerContext, + }) + end + end + + local hoverHandles, hoverHandleId = self._hoverTracker:getHoverHandleId() + for i, handles in pairs(self._draggerToolModel:getHandlesList()) do + elements["ImplementationUI" .. i] = + handles:render(hoverHandles == handles and hoverHandleId or nil) + end + + return Roact.createFragment(elements) +end + +function Ready:processSelectionChanged() + -- We expect selection changes while in the ready state + -- when the developer selects objects in the explorer window. + self:_updateHoverTracker() +end + +--[[ + Find the clicked part or constraint system gizmo by raycasting with the + current mouse location and decide what action to take: + + * If the clicked instance is added to or was already in the selection, begin + (maybe) freeform dragging the selected parts. + * If no selectable instance was clicked, begin drag selecting. + * When an Attachment is clicked without a selection modifier key pressed, + begin (maybe) freeform dragging that Attachment. + * When a Constraint is clicked, select it but don't do any form of drag. +]] +function Ready:processMouseDown(isDoubleClick) + -- We have to do an update here for the edge case where the 3D view just + -- became selected thanks to the mouse down event, so we haven't received + -- a view change event yet. + self:_updateHoverTracker() + + local hoverHandles, hoverHandleId = self._hoverTracker:getHoverHandleId() + if hoverHandleId then + self._draggerToolModel:transitionToState(DraggerStateType.DraggingHandle, + hoverHandles, hoverHandleId) + else + self:_clickInWorld(isDoubleClick) + end +end + +function Ready:processViewChanged() + self:_updateHoverTracker() +end + +function Ready:processMouseUp() + -- Nothing to do. This case can ocurr when the user clicks on a constraint. +end + +function Ready:_scopeSelectChanged() + if self._hoverTracker:getHoverItem() ~= nil then + self:_updateHoverTracker() + self._draggerToolModel:_scheduleRender() + end +end + +function Ready:processKeyDown(keyCode) + if getFFlagFlippedScopeSelect() then + if keyCode == Enum.KeyCode.LeftAlt or keyCode == Enum.KeyCode.RightAlt then + self:_scopeSelectChanged() + end + end + + if getFFlagSummonPivot() then + for _, handles in pairs(self._draggerToolModel:getHandlesList()) do + if handles.keyDown then + if handles:keyDown(keyCode) then + self:processViewChanged() + self._draggerToolModel:_scheduleRender() + end + end + end + end +end + +function Ready:processKeyUp(keyCode) + if getFFlagFlippedScopeSelect() then + if keyCode == Enum.KeyCode.LeftAlt or keyCode == Enum.KeyCode.RightAlt then + self:_scopeSelectChanged() + end + end + + if getFFlagSummonPivot() then + for _, handles in pairs(self._draggerToolModel:getHandlesList()) do + if handles.keyUp then + if handles:keyUp(keyCode) then + self:processViewChanged() + self._draggerToolModel:_scheduleRender() + end + end + end + end +end + +function Ready:_updateHoverTracker() + self._hoverTracker:update( + self._draggerToolModel._draggerContext, + self._draggerToolModel:getSelectionWrapper():get(), + self._draggerToolModel._selectionInfo) +end + +local function contains(list, targetItem) + for _, item in ipairs(list) do + if item == targetItem then + return true + end + end + return false +end + +--[[ + Called when the user clicking in the 3d space (not on a handle) +]] +function Ready:_clickInWorld(isDoubleClick) + local draggerContext = self._draggerToolModel._draggerContext + local clickedItem, position = self._hoverTracker:getHoverItem() + local clickedSelectable = self._hoverTracker:getHoverSelectable() + local oldSelection = self._draggerToolModel:getSelectionWrapper():get() + local selectionDidContainSelectable = contains(oldSelection, clickedSelectable) + local shouldExtendSelection = draggerContext:shouldExtendSelection() + if isDoubleClick and selectionDidContainSelectable then + -- Special case. Double clicking with extend selection would be a + -- no-op, thanks to adding something to the selection and then + -- immediately removing it again. + -- Prevent this. This also allows us to handle double clicks with + -- shouldExtendSelection activated as a getNextSelectable invocation. + shouldExtendSelection = false + end + local isExclusiveSelectable = + (clickedSelectable ~= nil) and + self._draggerToolModel:getSchema().isExclusiveSelectable( + draggerContext, clickedSelectable, clickedItem) + local selectionDidChange, newSelection, hint = + SelectionHelper.updateSelection( + clickedSelectable, oldSelection, + isExclusiveSelectable, + shouldExtendSelection) + if selectionDidChange then + self._draggerToolModel:getSelectionWrapper():set(newSelection, hint) + + -- Process selection changed only gets called automatically when studio + -- changes the selection, since we just changed the selection manually + -- we need to invoke it here. + self._draggerToolModel:_processSelectionChanged() + + -- If we have objects to transform, then change the insert point to + -- the selection's center. This makes it easier to paste and insert + -- objects at the position of a target object. + self._draggerToolModel:getSchema().setActivePoint( + self._draggerToolModel._draggerContext, + self._draggerToolModel._selectionInfo) + end + + self._draggerToolModel:_analyticsSendClick(clickedItem, selectionDidChange) + + local selectionEvent = { + DoubleClicked = isDoubleClick, + ClickedSelectable = clickedSelectable, + ClickedItem = clickedItem, + ClickedPosition = position, + SelectionDidContainSelectable = selectionDidContainSelectable, + SelectionNowContainsSelectable = contains(newSelection, clickedSelectable), + } + local nextState, extraData = + self._draggerToolModel:getSchema().dispatchWorldClick( + self._draggerToolModel._draggerContext, + self._draggerToolModel, + selectionEvent) + + if nextState == "Ready" then + if clickedSelectable and (not selectionDidChange or isDoubleClick) then + self._draggerToolModel:transitionToState( + DraggerStateType.PendingSelectNext, isDoubleClick, selectionEvent) + else + -- Nothing to do, stay in ready state + end + elseif nextState == "DragSelecting" then + if self._draggerToolModel:doesAllowDragSelect() then + self._draggerToolModel:transitionToState(DraggerStateType.DragSelecting) + end + elseif nextState == "FreeformSelectionDrag" then + self._draggerToolModel:transitionToState( + DraggerStateType.PendingDraggingParts, isDoubleClick, extraData) + else + error("Bad state returned from dispatchWorldClick: `" .. tostring(nextState) .. "`") + end +end + +return Ready \ No newline at end of file diff --git a/src/Implementation/DraggerToolModel.lua b/src/Implementation/DraggerToolModel.lua new file mode 100644 index 0000000..e8f58e2 --- /dev/null +++ b/src/Implementation/DraggerToolModel.lua @@ -0,0 +1,582 @@ + +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) + +local SelectionWrapper = require(DraggerFramework.Utility.SelectionWrapper) +local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) +local classifyPivot = require(DraggerFramework.Utility.classifyPivot) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) +local getFFlagDraggerFrameworkFixes = require(DraggerFramework.Flags.getFFlagDraggerFrameworkFixes) +local getFFlagBoxSelectNoPivot = require(DraggerFramework.Flags.getFFlagBoxSelectNoPivot) + +local getFFlagMoreLuaDraggerFixes = require(DraggerFramework.Flags.getFFlagMoreLuaDraggerFixes) +local getFFlagIgnoreSpuriousViewChange = require(DraggerFramework.Flags.getFFlagIgnoreSpuriousViewChange) + +local DraggerToolModel = {} +DraggerToolModel.__index = DraggerToolModel + +-- States +local DraggerStateType = require(DraggerFramework.Implementation.DraggerStateType) +local DraggerStates = DraggerFramework.Implementation.DraggerStates +local DraggerState = { + [DraggerStateType.Ready] = require(DraggerStates.Ready), + [DraggerStateType.DraggingFaceInstance] = require(DraggerStates.DraggingFaceInstance), + [DraggerStateType.PendingDraggingParts] = require(DraggerStates.PendingDraggingParts), + [DraggerStateType.PendingSelectNext] = require(DraggerStates.PendingSelectNext), + [DraggerStateType.DraggingHandle] = require(DraggerStates.DraggingHandle), + [DraggerStateType.DraggingParts] = require(DraggerStates.DraggingParts), + [DraggerStateType.DragSelecting] = require(DraggerStates.DragSelecting), +} + +-- We can't respect the OS setting, no way to tell whether a click in the 3d +-- viewport is a double click :( +local DOUBLE_CLICK_TIME = 0.5 + +-- Note: ShowSelectionDot remove because we don't use it anymore +-- Note: UseCollisionsTransparency is part of the Schema now +local DEFAULT_DRAGGER_SETTINGS = { + AllowDragSelect = true, + AllowFreeformDrag = true, + ShowLocalSpaceIndicator = false, + WasAutoSelected = false, + HandlesList = {}, + ShowPivotIndicator = false, +} +local REQUIRED_DRAGGER_SETTINGS = { + AnalyticsName = true, +} + +function DraggerToolModel.new(draggerContext, draggerSchema, draggerSettings, + requestRenderCallback, markViewDirtyCallback, markSelectionDirtyCallback) + -- Check validity of passed set of props + for prop, _ in pairs(draggerSettings) do + if DEFAULT_DRAGGER_SETTINGS[prop] == nil and REQUIRED_DRAGGER_SETTINGS[prop] == nil then + error("Unexpected DraggerToolModel prop `" .. prop .. "`") + end + end + -- Build props for this dragger + local draggerSettingsOrDefault = {} + + for requiredProp, _ in pairs(REQUIRED_DRAGGER_SETTINGS) do + local settingValue = draggerSettings[requiredProp] + if settingValue == nil then + error("Required prop `" .. requiredProp .. "` missing from DraggerToolModel props") + else + draggerSettingsOrDefault[requiredProp] = settingValue + end + end + for prop, default in pairs(DEFAULT_DRAGGER_SETTINGS) do + if draggerSettings[prop] ~= nil then + draggerSettingsOrDefault[prop] = draggerSettings[prop] + else + draggerSettingsOrDefault[prop] = default + end + end + + return setmetatable({ + _lastMouseClickTime = 0, + _lastMouseClickLocation = Vector2.new(-1, -1), + _handlesList = draggerSettingsOrDefault.HandlesList, + _draggerContext = draggerContext, + _draggerSchema = draggerSchema, + _modelProps = draggerSettingsOrDefault, + _requestRenderCallback = requestRenderCallback, + _markViewDirtyCallback = markViewDirtyCallback, + _markSelectionDirtyCallback = markSelectionDirtyCallback, + _selectionWrapper = nil, + }, DraggerToolModel) +end + +--[[ + Called by the DraggerTool main states to set the mouse cursor. + + To not interfere with other parts of studio which set the mouse cursor, + we have to only set the cursor when we think it should change. This is the + abstraction layer that guarantees this. +]] +function DraggerToolModel:setMouseCursor(cursor) + if self._mouseCursor ~= cursor then + self._mouseCursor = cursor + self._draggerContext:setMouseIcon(cursor) + end +end + +--[[ + Called by the main DraggerToolModel code, and the code in individual + DraggerTool main states in order to transition to a new state. + + The variable arguments are passed as arguments to the constructor of the + new state object which will be constructed and transitioned to. +]] +function DraggerToolModel:transitionToState(draggerStateType, ...) + assert(DraggerState[draggerStateType], "Missing state type: "..tostring(draggerStateType)) + self._stateObject:leave() + self._mainState = draggerStateType + self._stateObject = DraggerState[draggerStateType].new(self, ...) + self._stateObject:enter() + if getFFlagBoxSelectNoPivot() then + self:_updatePivotIndicatorVisibility() + end + self:_scheduleRender() +end + +function DraggerToolModel:render() + if getFFlagDraggerFrameworkFixes() then + self:_updateHandles() + end + + return Roact.createElement(Roact.Portal, { + target = self._draggerContext:getGuiParent(), + }, { + DraggerUI = Roact.createElement("Folder", {}, self._stateObject:render()), + }) +end + +-- Called every frame on render step +function DraggerToolModel:update() + if self._draggerContext:isSimulating() then + -- Must do a view update every frame in run mode to catch stuff + -- moving under our mouse. + self._markViewDirtyCallback() + + -- If there's a physically simulated part in the selection then we + -- have to update the whole selection every frame. + local isDragging = + self._mainState == DraggerStateType.DraggingHandle or + self._mainState == DraggerStateType.DraggingParts + if not isDragging then + if self._selectionInfo:isDynamic() then + self._markSelectionDirtyCallback() + end + end + end +end + +function DraggerToolModel:getSelectionWrapper() + return self._selectionWrapper +end + +function DraggerToolModel:getAnalyticsName() + return self._modelProps.AnalyticsName +end + +-- Note: This needs to be exposed now because the Schema analytics care about it +function DraggerToolModel:wasAutoSelected() + return self._modelProps.WasAutoSelected +end + +function DraggerToolModel:getSchema() + return self._draggerSchema +end + +function DraggerToolModel:getHandlesList() + return self._handlesList +end + +function DraggerToolModel:doesAllowDragSelect() + return self._modelProps.AllowDragSelect +end + +function DraggerToolModel:doesAllowFreeformDrag() + return self._modelProps.AllowFreeformDrag +end + +function DraggerToolModel:shouldShowLocalSpaceIndicator() + return self._modelProps.ShowLocalSpaceIndicator +end + +function DraggerToolModel:shouldShowSelectionDot() + return self._modelProps.ShowSelectionDot +end + +function DraggerToolModel:shouldUseCollisionTransparency() + return self._modelProps.UseCollisionsTransparency +end + +function DraggerToolModel:shouldAlignDraggedObjects() + return self._draggerContext:shouldAlignDraggedObjects() +end + +function DraggerToolModel:selectNextSelectables(dragInfo, isDoubleClick) + local oldSelection = self._selectionWrapper:get() + local nextSelectables = self._draggerSchema.getNextSelectables( + self._draggerContext, + oldSelection, + dragInfo, + isDoubleClick) + if nextSelectables then + local shouldXorSelection = false + local shouldExtendSelection = self._draggerContext:shouldExtendSelection() + local newSelection = + SelectionHelper.updateSelectionWithMultipleSelectables( + nextSelectables, oldSelection, shouldXorSelection, shouldExtendSelection) + self._selectionWrapper:set(newSelection) + self:_updateSelectionInfo() + end +end + +--[[ +Tries to boil what the pivot for the current selection is set to down to a +single descriptive analytics value as a string. Possible return values: + - None: There is no selection + - Default: The pivot is at the center of the bounds + - Inside: The pivot is within (but not exactly on) the bounds + - Surface: The pivot is exactly on the surface of the bounds + - Outside: The pivot is outside of the bounds + - Far: The pivot is very far outside the bounds (more than one size away) +]] +function DraggerToolModel:classifySelectionPivot() + if not self._selectionInfo then + return "None" + end + + local cframe, offset, size = self._selectionInfo:getBoundingBox() + return classifyPivot(cframe, offset, size) +end + +function DraggerToolModel:_processSelected() + if getFFlagMoreLuaDraggerFixes() then + self._debugInProcessSelected = true + end + + self._mainState = DraggerStateType.Ready + self._stateObject = DraggerState[DraggerStateType.Ready].new(self) + + if getFFlagSummonPivot() and self._modelProps.ShowPivotIndicator then + self._oldShowPivot = self._draggerContext:setPivotIndicator(true) + end + + self._mouseCursor = "" + self._draggerContext:setMouseIcon("") + + -- We defer handling part bounds changes to the render step, as the + -- changes that are happening to the selection may be happening to many + -- objects in the selection. Without deferring we could end up with + -- N^2 behavior if the whole selection is being updated (N part bounds + -- changes x each bounds change requires looking at all N parts in + -- the selection to calculate the new bounds) + self._boundsChangedTracker = + self._draggerSchema.BoundsChangedTracker.new( + self._draggerContext, + function(item) + self._markSelectionDirtyCallback() + end) + self._boundsChangedTracker:install() + + self._selectionWrapper = SelectionWrapper.new(self._draggerContext:getSelection()) + self._selectionChangedConnection = + self._selectionWrapper.onSelectionExternallyChanged:Connect(function() + self:_processSelectionChanged() + end) + + self:_updateSelectionInfo() + + self._stateObject:enter() + + self:_analyticsSessionBegin() + + if getFFlagMoreLuaDraggerFixes() then + self._debugInProcessSelected = false + end +end + +function DraggerToolModel:_processDeselected() + if getFFlagMoreLuaDraggerFixes() then + self._debugInProcessDeselected = true + end + + if self._isMouseDown then + self:_processMouseUp() + end + + if getFFlagSummonPivot() and self._modelProps.ShowPivotIndicator then + self._draggerContext:setPivotIndicator(self._oldShowPivot) + end + + -- Need to explicitly leave the last state now for correct behavior + self._stateObject:leave() + self._stateObject = nil + + self._selectionWrapper:destroy() + self._selectionWrapper = nil + + self._boundsChangedTracker:uninstall() + + self._selectionChangedConnection:Disconnect() + self._selectionChangedConnection = nil + + self:_analyticsSendSession() + + if getFFlagMoreLuaDraggerFixes() then + self._debugInProcessDeselected = false + end +end + +function DraggerToolModel:_processSelectionChanged() + self:_updateSelectionInfo() + self._stateObject:processSelectionChanged() + if getFFlagDraggerFrameworkFixes() then + self:_scheduleRender() + end +end + +function DraggerToolModel:_processKeyDown(keyCode) + self._stateObject:processKeyDown(keyCode) +end + +function DraggerToolModel:_processKeyUp(keyCode) + self._stateObject:processKeyUp(keyCode) +end + +function DraggerToolModel:_processMouseDown() + if self._isMouseDown then + -- Not ideal code. There are just too many situations where the engine + -- passes us disbalanced mouseup / mousedown events for us to reliably + -- handle all of them, so as an escape hatch, handle a mouse up if we + -- get a mouse down without having gotten the preceeding mouse up. + self:_processMouseUp() + end + local isDoubleClick + local thisClickLocation = self._draggerContext:getMouseLocation() + if (os.clock() - self._lastMouseClickTime) < DOUBLE_CLICK_TIME and + thisClickLocation == self._lastMouseClickLocation then + isDoubleClick = true + self._lastMouseClickTime = 0 -- Suppress double-double clicks + else + isDoubleClick = false + self._lastMouseClickTime = os.clock() + end + self._lastMouseClickLocation = thisClickLocation + self._isMouseDown = true + self._stateObject:processMouseDown(isDoubleClick) +end + +function DraggerToolModel:_processMouseUp() + if not self._isMouseDown then + -- There are various circumstances where the mouse can be down without + -- us having started an associated drag. The engine has a habit of + -- sending mismatched mouse up/down events in various rare edge cases. + return + end + self._isMouseDown = false + self._stateObject:processMouseUp() +end + +--[[ + Called when the camera or mouse position changes, i.e., the world position + currently under the mouse cursor has changed. +]] + +-- Use these to differentiate stack traces in Backtrace analytics +local function debugErrorInBothSelectAndDeselect() + error("Missing stateObject (Selecting and Deselecting)") +end +local function debugErrorInSelect() + error("Missing stateObject (Selecting)") +end +local function debugErrorInDeselect() + error("Missing stateObject (Deselecting)") +end +local function debugErrorInNeither() + error("Missing stateObject (Neither)") +end + +function DraggerToolModel:_processViewChanged() + if getFFlagIgnoreSpuriousViewChange() and not self._stateObject then + return + end + if getFFlagMoreLuaDraggerFixes() and not self._stateObject then + if self._debugInProcessSelected and self._debugInProcessDeselected then + debugErrorInBothSelectAndDeselect() + elseif self._debugInProcessSelected then + debugErrorInSelect() + elseif self._debugInProcessDeselected then + debugErrorInDeselect() + else + debugErrorInNeither() + end + end + self._stateObject:processViewChanged() + + -- Derived world state may have changed as a result of the view update, so + -- we need to manually trigger a re-render here. + self:_scheduleRender() +end + +function DraggerToolModel:_updateHandles() + for _, handle in pairs(self._handlesList) do + if handle.update then + handle:update(self, self._selectionInfo) + end + end +end + +function DraggerToolModel:_updateSelectionInfo(newSelectionInfoHint) + if newSelectionInfoHint then + self._selectionInfo = newSelectionInfoHint + else + self._selectionInfo = self._draggerSchema.SelectionInfo.new( + self._draggerContext, self._selectionWrapper:get()) + end + self._boundsChangedTracker:setSelection(self._selectionInfo) + + if getFFlagDraggerFrameworkFixes() then + self:_updateHandles() + else + self:_scheduleRender() + end + + if getFFlagSummonPivot() then + self:_updatePivotIndicatorVisibility() + end +end + +function DraggerToolModel:_updatePivotIndicatorVisibility() + if self._modelProps.ShowPivotIndicator then + if (getFFlagBoxSelectNoPivot() and self._mainState == DraggerStateType.DragSelecting) or #self._selectionWrapper:get() > 1 then + self._draggerContext:setPivotIndicator(self._oldShowPivot) + else + self._draggerContext:setPivotIndicator(true) + end + end +end + +function DraggerToolModel:_processToolboxInitiatedFreeformSelectionDrag() + -- We didn't get an associated mouse down, so we have to set the mouse + -- down tracking variable here. + self._isMouseDown = true + + self:transitionToState(DraggerStateType.DraggingParts, { + mouseLocation = self._draggerContext:getMouseLocation(), + basisPoint = Vector3.new(), -- Just drag from the center of the object + clickPoint = Vector3.new(), + }) +end + +function DraggerToolModel:_processToolboxInitiatedFaceDrag(instances) + -- We didn't get an associated mouse down, so we have to set the mouse + -- down tracking variable here. + self._isMouseDown = true + + local connectionToBreak + if instances[1]:IsA("VideoFrame") then + local videoFrameContainer = Instance.new("SurfaceGui") + videoFrameContainer.Enabled = true + videoFrameContainer.Parent = Workspace + instances[1].Parent = videoFrameContainer + + -- When we the user drags out of the 3D view, the face instance that was + -- passed to us will be removed. When that happens we need to also + -- remove the container we made for it here. + connectionToBreak = videoFrameContainer.ChildRemoved:Connect(function(instance) + videoFrameContainer:Destroy() + end) + + self._selectionWrapper:set({ videoFrameContainer }) + self:_updateSelectionInfo() + end + + self:transitionToState(DraggerStateType.DraggingFaceInstance, connectionToBreak) +end + +function DraggerToolModel:_scheduleRender() + if not getFFlagDraggerFrameworkFixes() then + self:_updateHandles() + end + + self._requestRenderCallback() +end + +function DraggerToolModel:_analyticsSessionBegin() + self._selectedAtTime = tick() + self._sessionAnalytics = { + freeformDrags = 0, + handleDrags = 0, + clickSelects = 0, + dragSelects = 0, + dragTilts = 0, + dragRotates = 0, + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = self._modelProps.WasAutoSelected, + } + self._draggerContext:getAnalytics():sendEvent("toolSelected", { + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = self._modelProps.WasAutoSelected, + }) + if self._modelProps.WasAutoSelected then + self._draggerContext:getAnalytics():reportCounter("studioLuaDefaultDraggerSelected") + else + self._draggerContext:getAnalytics():reportCounter("studioLua" .. self._modelProps.AnalyticsName .. "DraggerSelected") + end +end + +function DraggerToolModel:_analyticsSendSession() + local totalTime = tick() - self._selectedAtTime + self._sessionAnalytics.duration = totalTime + self._draggerContext:getAnalytics():sendEvent("toolSession", self._sessionAnalytics) +end + +function DraggerToolModel:_analyticsSendClick(clickedInstance, didAlterSelection) + self._draggerContext:getAnalytics():sendEvent("clickedObject", { + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = self._modelProps.WasAutoSelected, + altPressed = self._draggerContext:isAltKeyDown(), + ctrlPressed = self._draggerContext:isCtrlKeyDown(), + shiftPressed = self._draggerContext:isShiftKeyDown(), + clickedAttachment = clickedInstance and clickedInstance:IsA("Attachment"), + clickedConstraint = clickedInstance and clickedInstance:IsA("Constraint"), + clickedWeldConstraint = clickedInstance and clickedInstance:IsA("WeldConstraint"), + clickedNoCollisionConstraint = clickedInstance and clickedInstance:IsA("NoCollisionConstraint"), + didAlterSelection = didAlterSelection, + }) + if didAlterSelection then + self._sessionAnalytics.clickSelects = self._sessionAnalytics.clickSelects + 1 + end +end + +function DraggerToolModel:_analyticsRecordFreeformDragBegin(timeToStartDrag) + self._sessionAnalytics.freeformDrags = self._sessionAnalytics.freeformDrags + 1 + local dragStartTimeName = + "studioLuaDragger" .. self._modelProps.AnalyticsName .. "DragTime" + self._draggerContext:getAnalytics():reportStats(dragStartTimeName, timeToStartDrag) +end + +function DraggerToolModel:_analyticsSendHandleDragged(handleId) + self._draggerContext:getAnalytics():sendEvent("handleDragged", { + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = false, -- For consistency with other events + gridSize = self._draggerContext:getGridSize(), + rotateIncrement = self._draggerContext:getRotateIncrement(), + useLocalSpace = self._draggerContext:shouldUseLocalSpace(), + joinSurfaces = self._draggerContext:shouldJoinSurfaces(), + useConstraints = self._draggerContext:areConstraintsEnabled(), + haveCollisions = self._draggerContext:areCollisionsEnabled(), + pivotType = self:classifySelectionPivot(), + handleId = handleId, + }) +end + +function DraggerToolModel:_analyticsSendBoxSelect() + self._draggerContext:getAnalytics():sendEvent("boxSelected", { + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = self._modelProps.WasAutoSelected, + objectCount = #self._selectionWrapper:get(), -- This line changed + altPressed = self._draggerContext:isAltKeyDown(), + ctrlPressed = self._draggerContext:isCtrlKeyDown(), + shiftPressed = self._draggerContext:isShiftKeyDown(), + }) +end + +function DraggerToolModel:_analyticsSendFaceInstanceSelected(className) + self._draggerContext:getAnalytics():sendEvent("faceInstanceSelected", { + toolName = self._modelProps.AnalyticsName, + wasAutoSelected = self._modelProps.WasAutoSelected, + className = className, + }) +end + +return DraggerToolModel diff --git a/src/Implementation/HoverTracker.lua b/src/Implementation/HoverTracker.lua new file mode 100644 index 0000000..bf3c969 --- /dev/null +++ b/src/Implementation/HoverTracker.lua @@ -0,0 +1,144 @@ + +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) + +--[[ + Ignored handle hits: When the ToolImplementation's shouldBiasTowardsObject + function returns true, then this function will decide whether to ignore + handle clicks where the user clicked outside of the handle's visual, but + still within the handle's invisible hitbox. +]] +local function isIgnoredHandleHit(handles, mouseRay, selectionInfo, hitItem) + if not handles:shouldBiasTowardsObjects() then + -- Only potentially ignores if we're biased towards parts + return false + end + + if not hitItem or not selectionInfo:doesContainItem(hitItem) then + -- Only bias towards parts when clicking on something in the selection + return false + end + + -- Ignore the hit if when ignoring the extra threshold the handle is no + -- longer hit. + local ignoreExtraThreshold = true + return handles:hitTest(mouseRay, ignoreExtraThreshold) == nil +end + +local HoverTracker = {} +HoverTracker.__index = HoverTracker + +function HoverTracker.new(draggerSchema, handlesList, onHoverExternallyChangedFunction) + assert(type(handlesList) == "table") + assert(type(draggerSchema) == "table") + return setmetatable({ + _draggerSchema = draggerSchema, + _handlesList = handlesList, + _hoverHandleId = nil, + _hoverItem = nil, + _onHoverChanged = onHoverExternallyChangedFunction, + }, HoverTracker) +end + +local function isCloser(distance, isOnTop, currentDistance, currentIsOnTop) + if currentIsOnTop then -- Neat logic + return isOnTop and distance < currentDistance + else + return isOnTop or distance < currentDistance + end +end + +function HoverTracker:update(draggerContext, currentSelection, selectionInfo) + assert(currentSelection ~= nil) + local oldHoverSelectable = self._hoverSelectable + + -- Hover parts in the workspace + local mouseRay = draggerContext:getMouseRay() + local hitSelectable, hitItem, distanceToHover = + self._draggerSchema.getMouseTarget(draggerContext, mouseRay, currentSelection) + self._hoverItem = hitItem + self._hoverSelectable = hitSelectable + self._hoverHandleId = nil + if hitSelectable ~= nil then + self._hoverDistance = distanceToHover + self._hoverPosition = mouseRay.Origin + mouseRay.Direction.Unit * distanceToHover + else + distanceToHover = math.huge + self._hoverDistance = math.huge + self._hoverPosition = nil + end + + self._hoverHandles = nil + local currentIsOnTop = false + for _, handles in pairs(self._handlesList) do + -- Possibly hover a handle instead if we have a handle closer than the part + -- and the hit wasn't ignored by bias towards hovering parts. + local hoverHandleId, hoverHandleDistance, isOnTop = handles:hitTest(mouseRay, false) + if hoverHandleId then + if isCloser(hoverHandleDistance, isOnTop, distanceToHover, currentIsOnTop) and + not isIgnoredHandleHit(handles, mouseRay, selectionInfo, hitItem) then + self._hoverHandles = handles + self._hoverHandleId = hoverHandleId + self._hoverDistance = hoverHandleDistance + self._hoverPosition = nil + distanceToHover = hoverHandleDistance + currentIsOnTop = isOnTop + end + end + end + + if self._hoverHandles then + self._draggerSchema.setHover(draggerContext, nil, nil) + else + self._draggerSchema.setHover(draggerContext, self._hoverSelectable, self._hoverItem) + end + + if self._onHoverChanged and self._hoverSelectable ~= oldHoverSelectable then + self:_freeHoverEscapeDetector() + if self._hoverSelectable then + self._hoverEscapeDetector = + self._draggerSchema.HoverEscapeDetector.new( + draggerContext, self._hoverSelectable, self._onHoverChanged) + end + end +end + +function HoverTracker:_freeHoverEscapeDetector() + if self._hoverEscapeDetector then + self._hoverEscapeDetector:destroy() + self._hoverEscapeDetector = nil + end +end + +function HoverTracker:clearHover(draggerContext) + self:_freeHoverEscapeDetector() + self._hoverItem = nil + self._hoverSelectable = nil + self._hoverPosition = nil + self._hoverHandles = nil + self._hoverHandleId = nil + self._hoverDistance = nil + self._draggerSchema.setHover(draggerContext, nil, nil) +end + +--[[ + Returns: The Id of the hovered handle, the distance to that handle +]] +function HoverTracker:getHoverHandleId() + return self._hoverHandles, self._hoverHandleId, self._hoverDistance +end + +--[[ + Returns: The hovered instance, and the world position of the hit on it +]] +function HoverTracker:getHoverItem() + return self._hoverItem, self._hoverPosition +end + +function HoverTracker:getHoverSelectable() + return self._hoverSelectable +end + +return HoverTracker \ No newline at end of file diff --git a/src/Resources/TranslationDevelopmentTable.csv b/src/Resources/TranslationDevelopmentTable.csv new file mode 100644 index 0000000..6c6d1bb --- /dev/null +++ b/src/Resources/TranslationDevelopmentTable.csv @@ -0,0 +1,3 @@ +Key,Context,Example,Source,en-us +Studio.DraggerFramework.SummonPivot.SummonText,,,,Summon Handles +Studio.DraggerFramework.SummonPivot.TabText,,,,Hold Tab \ No newline at end of file diff --git a/src/Resources/TranslationReferenceTable.csv b/src/Resources/TranslationReferenceTable.csv new file mode 100644 index 0000000..6fd8cc4 --- /dev/null +++ b/src/Resources/TranslationReferenceTable.csv @@ -0,0 +1,3 @@ +Key,Context,Example,Source,en-us,es-es,ja-jp,ko-kr,pt-br,zh-cjv,zh-cn,zh-tw +Studio.DraggerFramework.SummonPivot.TabText,,,Hold Tab,Hold Tab,Mantener Pestaña,タブの長押し,탭 길게 누르기,Segurar aba,按住 Tab,按住 Tab,儲藏標籤 +Studio.DraggerFramework.SummonPivot.SummonText,,,Summon Handles,Summon Handles,Invocar controladores,ハンドル召喚,핸들 소환,Invocar alças,召唤拉杆,召唤拉杆,召喚手把 \ No newline at end of file diff --git a/src/Utility/Analytics.lua b/src/Utility/Analytics.lua new file mode 100644 index 0000000..1415684 --- /dev/null +++ b/src/Utility/Analytics.lua @@ -0,0 +1,46 @@ + +local RbxAnalyticsService = game:GetService("RbxAnalyticsService") +local StudioService = game:GetService("StudioService") + +local DraggerFramework = script.Parent.Parent + +local Analytics = {} + +-- If this is a fork of the dragger code, mock out the RbxAnalyticsService +if not pcall(function() local _ = RbxAnalyticsService.Name end) then + RbxAnalyticsService = {} + function RbxAnalyticsService:SendEventDeferred() end + function RbxAnalyticsService:ReportCounter() end + function RbxAnalyticsService:GetSessionId() end + function RbxAnalyticsService:GetClientId() end + function RbxAnalyticsService:ReportStats() end +end + +-- If this is a fork of the dragger code being used ingame, mock out StudioService +if not StudioService then + StudioService = {} + function StudioService:GetUserId() end +end + +function Analytics:sendEvent(eventName, argMap) + local totalArgMap = { + studioSid = RbxAnalyticsService:GetSessionId(), + clientId = RbxAnalyticsService:GetClientId(), + placeId = game.PlaceId, + userId = StudioService:GetUserId(), + } + for k, v in pairs(argMap) do + totalArgMap[k] = v + end + RbxAnalyticsService:SendEventDeferred("studio", "Modeling", eventName, totalArgMap) +end + +function Analytics:reportCounter(counterName, count) + RbxAnalyticsService:ReportCounter(counterName, count or 1) +end + +function Analytics:reportStats(statName, value) + RbxAnalyticsService:ReportStats(statName, value) +end + +return Analytics \ No newline at end of file diff --git a/src/Utility/AttachmentMover.lua b/src/Utility/AttachmentMover.lua new file mode 100644 index 0000000..1e1034d --- /dev/null +++ b/src/Utility/AttachmentMover.lua @@ -0,0 +1,44 @@ +local RunService = game:GetService("RunService") + +local DraggerFramework = script.Parent.Parent + +local AttachmentMover = {} +AttachmentMover.__index = AttachmentMover + +function AttachmentMover.new() + return setmetatable({}, AttachmentMover) +end + +function AttachmentMover:setDragged(attachments) + self._originalWorldCFrames = {} + local isRunning = RunService:IsRunning() + self._partsToUnanchor = {} + for _, attachment in ipairs(attachments) do + self._originalWorldCFrames[attachment] = attachment.WorldCFrame + if isRunning then + local part = attachment:FindFirstAncestorWhichIsA("BasePart") + if part and not part:IsGrounded() then + self._partsToUnanchor[part] = true + end + end + end + for part, _ in pairs(self._partsToUnanchor) do + part.Anchored = true + end +end + +function AttachmentMover:transformTo(transform) + for attachment, originalWorldCFrame in pairs(self._originalWorldCFrames) do + attachment.WorldCFrame = transform * originalWorldCFrame + end +end + +function AttachmentMover:commit() + self._originalWorldCFrames = nil + for part, _ in pairs(self._partsToUnanchor) do + part.Anchored = false + end + self._partsToUnanchor = nil +end + +return AttachmentMover \ No newline at end of file diff --git a/src/Utility/BoundingBox.lua b/src/Utility/BoundingBox.lua new file mode 100644 index 0000000..f163cb4 --- /dev/null +++ b/src/Utility/BoundingBox.lua @@ -0,0 +1,230 @@ +local Workspace = game:GetService("Workspace") + +local function getBoundingBoxInternal(cframe, size, inverseBasis) + local localCFrame = inverseBasis and (inverseBasis * cframe) or cframe + local sx, sy, sz = size.X, size.Y, size.Z + + local _, _, _, + t00, t01, t02, + t10, t11, t12, + t20, t21, t22 = localCFrame:GetComponents() + local hw = 0.5 * (math.abs(sx * t00) + math.abs(sy * t01) + math.abs(sz * t02)) + local hh = 0.5 * (math.abs(sx * t10) + math.abs(sy * t11) + math.abs(sz * t12)) + local hd = 0.5 * (math.abs(sx * t20) + math.abs(sy * t21) + math.abs(sz * t22)) + local x, y, z = localCFrame.X, localCFrame.Y, localCFrame.Z + + local xmin = x - hw + local xmax = x + hw + local ymin = y - hh + local ymax = y + hh + local zmin = z - hd + local zmax = z + hd + + return xmin, xmax, ymin, ymax, zmin, zmax +end + +local BoundingBox = {} + +--[[ + Returns the bounding box that contains the specified objects. + + The bounding box is computed in the given coordinate space, or world space if + no local basis is provided. + + Params: + table objects: An array of BaseParts, Models, and Attachments. + CFrame basisCFrame: Optional local basis. + + Returns: + Tuple (Vector3 offset, Vector3 size). +]] +function BoundingBox.fromObjects(objects, basisCFrame) + local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil + local xmin, xmax = math.huge, -math.huge + local ymin, ymax = math.huge, -math.huge + local zmin, zmax = math.huge, -math.huge + + local terrain = Workspace.Terrain + + for _, object in ipairs(objects) do + local isModel = object:IsA("Model") + if isModel or (object:IsA("BasePart") and object ~= terrain) then + local cframe, size + if isModel then + cframe, size = object:GetBoundingBox() + else + cframe = object.CFrame + size = object.Size + end + + local xmin1, xmax1, ymin1, ymax1, zmin1, zmax1 = getBoundingBoxInternal( + cframe, size, inverseBasis) + + xmin = math.min(xmin, xmin1) + xmax = math.max(xmax, xmax1) + ymin = math.min(ymin, ymin1) + ymax = math.max(ymax, ymax1) + zmin = math.min(zmin, zmin1) + zmax = math.max(zmax, zmax1) + elseif object:IsA("Attachment") then + local localPosition = basisCFrame:PointToObjectSpace(object.WorldPosition) + local x, y, z = localPosition.X, localPosition.Y, localPosition.Z + xmin = math.min(xmin, x) + xmax = math.max(xmax, x) + ymin = math.min(ymin, y) + ymax = math.max(ymax, y) + zmin = math.min(zmin, z) + zmax = math.max(zmax, z) + end + end + + local offset = Vector3.new( + 0.5 * (xmin + xmax), + 0.5 * (ymin + ymax), + 0.5 * (zmin + zmax) + ) + local size = Vector3.new( + xmax - xmin, + ymax - ymin, + zmax - zmin + ) + + return offset, size +end + +--[[ + Returns the bounding box that contains the specified objects, as well as the + bounding boxes for all BaseParts and Models. + + Bounding boxes are computed in the given coordinate space, or world space if + no local basis is provided. + + Params: + table objects: An array of BaseParts, Models, and Attachments. + CFrame basisCFrame: Optional local basis. + + Returns: + Tuple (Vector3 offset, Vector3 size, table boundingBoxes). +]] +function BoundingBox.fromObjectsComputeAll(objects, basisCFrame) + local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil + local xmin, xmax = math.huge, -math.huge + local ymin, ymax = math.huge, -math.huge + local zmin, zmax = math.huge, -math.huge + + local terrain = Workspace.Terrain + + local boundingBoxes = {} + + for _, object in ipairs(objects) do + local isModel = object:IsA("Model") + if isModel or object:IsA("BasePart") and object ~= terrain then + local cframe, size + if isModel then + cframe, size = object:GetBoundingBox() + else + cframe = object.CFrame + size = object.Size + end + + local xmin1, xmax1, ymin1, ymax1, zmin1, zmax1 = getBoundingBoxInternal( + cframe, size, inverseBasis) + + xmin = math.min(xmin, xmin1) + xmax = math.max(xmax, xmax1) + ymin = math.min(ymin, ymin1) + ymax = math.max(ymax, ymax1) + zmin = math.min(zmin, zmin1) + zmax = math.max(zmax, zmax1) + + boundingBoxes[object] = { + offset = Vector3.new( + 0.5 * (xmin1 + xmax1), + 0.5 * (ymin1 + ymax1), + 0.5 * (zmin1 + zmax1) + ), + size = Vector3.new( + xmax1 - xmin1, + ymax1 - ymin1, + zmax1 - zmin1 + ) + } + elseif object:IsA("Attachment") then + local localPosition = basisCFrame:PointToObjectSpace(object.WorldPosition) + local x, y, z = localPosition.X, localPosition.Y, localPosition.Z + xmin = math.min(xmin, x) + xmax = math.max(xmax, x) + ymin = math.min(ymin, y) + ymax = math.max(ymax, y) + zmin = math.min(zmin, z) + zmax = math.max(zmax, z) + end + end + + local offset = Vector3.new( + 0.5 * (xmin + xmax), + 0.5 * (ymin + ymax), + 0.5 * (zmin + zmax) + ) + local size = Vector3.new( + xmax - xmin, + ymax - ymin, + zmax - zmin + ) + + return offset, size, boundingBoxes +end + +--[[ + Calculate an oriented bounding box for the passed in parts and attachments, + in the supplied basis. +]] +function BoundingBox.fromPartsAndAttachments(parts, attachments, basisCFrame) + local inverseBasis = basisCFrame and basisCFrame:Inverse() or nil + local xmin, xmax = math.huge, -math.huge + local ymin, ymax = math.huge, -math.huge + local zmin, zmax = math.huge, -math.huge + + local terrain = Workspace.Terrain + + for _, part in ipairs(parts) do + if part ~= terrain then + local xmin1, xmax1, + ymin1, ymax1, + zmin1, zmax1 = getBoundingBoxInternal(part.CFrame, part.Size, inverseBasis) + + xmin = math.min(xmin, xmin1) + xmax = math.max(xmax, xmax1) + ymin = math.min(ymin, ymin1) + ymax = math.max(ymax, ymax1) + zmin = math.min(zmin, zmin1) + zmax = math.max(zmax, zmax1) + end + end + + for _, attachment in ipairs(attachments) do + local localPosition = basisCFrame:PointToObjectSpace(attachment.WorldPosition) + local x, y, z = localPosition.X, localPosition.Y, localPosition.Z + xmin = math.min(xmin, x) + xmax = math.max(xmax, x) + ymin = math.min(ymin, y) + ymax = math.max(ymax, y) + zmin = math.min(zmin, z) + zmax = math.max(zmax, z) + end + + local offset = Vector3.new( + 0.5 * (xmin + xmax), + 0.5 * (ymin + ymax), + 0.5 * (zmin + zmax) + ) + local size = Vector3.new( + xmax - xmin, + ymax - ymin, + zmax - zmin + ) + + return offset, size +end + +return BoundingBox diff --git a/src/Utility/Colors.lua b/src/Utility/Colors.lua new file mode 100644 index 0000000..342b9cc --- /dev/null +++ b/src/Utility/Colors.lua @@ -0,0 +1,21 @@ +local Colors = {} + +Colors.WHITE = Color3.new(1, 1, 1) +Colors.BLACK = Color3.new(0, 0, 0) +Colors.GRAY = Color3.new(0.7, 0.7, 0.7) + +Colors.X_AXIS = Color3.new(1, 0, 0) +Colors.Y_AXIS = Color3.new(0, 1, 0) +Colors.Z_AXIS = Color3.new(0, 0, 1) + +Colors.WeldJoint = Color3.new(1, 1, 1) +Colors.RotatingJoint = Color3.new(0, 0, 1) +Colors.InvalidJoint = Color3.new(1, 0, 0) + +Colors.SizeLimitReached = Color3.new(1, 1, 0) + +function Colors.makeDimmed(color) + return color:Lerp(Colors.BLACK, 0.3) +end + +return Colors diff --git a/src/Utility/DragHelper.lua b/src/Utility/DragHelper.lua new file mode 100644 index 0000000..39da436 --- /dev/null +++ b/src/Utility/DragHelper.lua @@ -0,0 +1,491 @@ +local Workspace = game:GetService("Workspace") +local Selection = game:GetService("Selection") +local CollectionService = game:GetService("CollectionService") + +local DraggerFramework = script.Parent.Parent +local Plugin = DraggerFramework.Parent.Parent +local Math = require(DraggerFramework.Utility.Math) +local getGeometry = require(DraggerFramework.Utility.getGeometry) +local roundRotation = require(DraggerFramework.Utility.roundRotation) +local snapRotationToPrimaryDirection = require(DraggerFramework.Utility.snapRotationToPrimaryDirection) + +local getFFlagSummonPivot = require(DraggerFramework.Flags.getFFlagSummonPivot) + +local PrimaryDirections = { + Vector3.new(1, 0, 0), + Vector3.new(-1, 0, 0), + Vector3.new(0, 1, 0), + Vector3.new(0, -1, 0), + Vector3.new(0, 0, 1), + Vector3.new(0, 0, -1) +} + +local DragTargetType = { + Terrain = "Terrain", + Polygon = "Polygon", + Round = "Round", + Nothing = "Nothing", +} + +--[[ + If true, then "Rotate" will mean: + Rotate around whatever axis of the drag target is closest to the + camera's current up direction. + If false, then "Rotate" always mean: + Rotate around the normal of the surface we're dragging onto. +]] +local ROTATE_DEPENDS_ON_CAMERA = false + +local DragHelper = {} + +local VOXEL_RESOLUTION = 4 +local function roundToTerrainGrid(value) + return VOXEL_RESOLUTION * math.floor(value / VOXEL_RESOLUTION + 0.5) +end + +local function findClosestBasis(normal) + local mostPerpendicularNormal1 + local smallestDot = math.huge + for _, primaryDirection in ipairs(PrimaryDirections) do + local dot = math.abs(primaryDirection:Dot(normal)) + if dot < smallestDot then + smallestDot = dot + mostPerpendicularNormal1 = primaryDirection + end + end + + local mostPerpendicularNormal2 = mostPerpendicularNormal1:Cross(normal).Unit + local closestNormal = -mostPerpendicularNormal1:Cross(mostPerpendicularNormal2) + + return closestNormal, mostPerpendicularNormal1, mostPerpendicularNormal2 +end + +-- Remove with FFlag::SummonPivot +local function largestComponent(vector) + return math.max(math.abs(vector.X), math.abs(vector.Y), math.abs(vector.Z)) +end + +-- Remove with FFlag::SummonPivot +function DragHelper.snapVectorToPrimaryDirection(direction) + local largestDot = -math.huge + local closestDirection + for _, target in ipairs(PrimaryDirections) do + local dot = direction:Dot(target) + if dot > largestDot then + largestDot = dot + closestDirection = target + end + end + return closestDirection +end + +-- Remove with FFlag::SummonPivot +function DragHelper.snapRotationToPrimaryDirection(cframe) + assert(not getFFlagSummonPivot()) + local right = cframe.RightVector + local top = cframe.UpVector + local front = -cframe.LookVector + local largestRight = largestComponent(right) + local largestTop = largestComponent(top) + local largestFront = largestComponent(front) + if largestRight > largestTop and largestRight > largestFront then + -- Most aligned axis is X, the right, preserve that + right = DragHelper.snapVectorToPrimaryDirection(right) + if largestTop > largestFront then + top = DragHelper.snapVectorToPrimaryDirection(top) + else + local front = DragHelper.snapVectorToPrimaryDirection(front) + top = front:Cross(right).Unit + end + elseif largestTop > largestFront then + -- Most aligned axis is Y, the top, preserve that + top = DragHelper.snapVectorToPrimaryDirection(top) + if largestRight > largestFront then + right = DragHelper.snapVectorToPrimaryDirection(right) + else + local front = DragHelper.snapVectorToPrimaryDirection(front) + right = top:Cross(front).Unit + end + else + -- Most aligned axis is Z, the front, preserve that + local front = DragHelper.snapVectorToPrimaryDirection(front) + if largestRight > largestTop then + right = DragHelper.snapVectorToPrimaryDirection(right) + top = front:Cross(right).Unit + else + top = DragHelper.snapVectorToPrimaryDirection(top) + right = top:Cross(front).Unit + end + end + return CFrame.fromMatrix(Vector3.new(), right, top) +end + +function DragHelper.getSizeInSpace(sizeInGlobalSpace, localSpace) + local _, _, _, + t00, t01, t02, + t10, t11, t12, + t20, t21, t22 = localSpace:GetComponents() + local sx, sy, sz = sizeInGlobalSpace.X, sizeInGlobalSpace.Y, sizeInGlobalSpace.Z + local w = (math.abs(sx * t00) + math.abs(sy * t01) + math.abs(sz * t02)) + local h = (math.abs(sx * t10) + math.abs(sy * t11) + math.abs(sz * t12)) + local d = (math.abs(sx * t20) + math.abs(sy * t21) + math.abs(sz * t22)) + return Vector3.new(w, h, d) +end + +function DragHelper.getClosestFace(part, mouseWorld) + local geom = getGeometry(part, mouseWorld) + local closestFace + local closestDist = math.huge + for _, face in ipairs(geom.faces) do + local dist = math.abs((mouseWorld - face.point):Dot(face.normal)) + if dist < closestDist then + closestFace = face + closestDist = dist + end + end + return closestFace, geom +end + +function DragHelper.getPartAndSurface(mouseRay) + local part, mouseWorld = Workspace:FindPartOnRay(mouseRay) + + local closestFace, _ + if part then + if part:IsA("Terrain") then + -- Terrain doesn't have Primary Axis based surfaces to return + return part, nil + end + + closestFace, _ = DragHelper.getClosestFace(part, mouseWorld) + end + + if closestFace then + return part, closestFace.surface + else + return part, nil + end +end + +local function isHorizontal(vector: Vector3) + return math.abs(vector.Y) < 0.01 +end + +function DragHelper.getSurfaceMatrix(mouseRay, selection, lastSurfaceMatrix, draggingUnionParts) + local ignoreList = {} + for k, v in ipairs(selection) do ignoreList[k] = v end + local part, mouseWorld, normal = Workspace:FindPartOnRayWithIgnoreList(mouseRay, ignoreList) + + if part and part:IsA("Terrain") then + -- First, find the closest aligned global axis normal, and the two other + -- axes mutually orthogonal to it. + local closestNormal, mostPerpendicularNormal1, mostPerpendicularNormal2 + = findClosestBasis(normal) + + -- Now we want to grid-align mouseWorld by snapping it to the + -- grid size of the terrain on the non-normal axes. + local alongNormal1 = mouseWorld:Dot(mostPerpendicularNormal1) + local alongNormal2 = mouseWorld:Dot(mostPerpendicularNormal2) + local snappedMouseWorldBase = + mostPerpendicularNormal1 * roundToTerrainGrid(alongNormal1) + + mostPerpendicularNormal2 * roundToTerrainGrid(alongNormal2) + + -- Since we grid-aligned the position on two of the axis, we have to + -- bring the position back into the surface plane on the other axis. + -- Do that by solving the following equation: + -- (snappedMouseWorldBase + closestNormal * adjustmentIntoPlane):Dot(normal) = mouseWorld:Dot(normal) + local adjustmentIntoPlane = + (mouseWorld:Dot(normal) - snappedMouseWorldBase:Dot(normal)) / closestNormal:Dot(normal) + local snappedMouseWorld = snappedMouseWorldBase + closestNormal * adjustmentIntoPlane + + return CFrame.fromMatrix(snappedMouseWorld, + normal:Cross(mostPerpendicularNormal1).Unit, normal), + mouseWorld, DragTargetType.Terrain + elseif part then + -- Find the normal and secondary axis (the direction the studs / UV + -- coords are oriented in) of the surface that we're dragging onto. + -- Also find the closest "basis" point on the face to the mouse, + local closestFace, geom = DragHelper.getClosestFace(part, mouseWorld) + + local normal = closestFace.normal + + local secondary; + local closestEdgeDist = math.huge + for _, edge in ipairs(geom.edges) do + if (edge.a - closestFace.point):Dot(normal) < 0.001 and + (edge.b - closestFace.point):Dot(normal) < 0.001 + then + -- Both ends of the edge are part of the selected face, + -- consider it + local distAlongEdge = (mouseWorld - edge.a):Dot(edge.direction) + local pointOnEdge = edge.a + edge.direction * distAlongEdge + local distToEdge = (mouseWorld - pointOnEdge).Magnitude + if distToEdge < closestEdgeDist then + closestEdgeDist = distToEdge + secondary = edge.direction + end + end + end + local targetBasisPoint + local closestBasisDist = math.huge + for _, vert in ipairs(closestFace.vertices) do + local dist = (vert - mouseWorld).Magnitude + if dist < closestBasisDist then + closestBasisDist = dist + targetBasisPoint = vert + end + end + + local dragTargetType = DragTargetType.Polygon + + -- TODO: Deal with round things better (this is the case that gets hit + -- with round things like cylinders and spheres) + if not secondary then + dragTargetType = DragTargetType.Round + secondary = normal:Cross(Vector3.new(1, 1, 1)).Unit + end + if not targetBasisPoint then + targetBasisPoint = mouseWorld + end + + -- Find the total transform from the target point on the dragged to + -- surface to our original CFrame + return CFrame.fromMatrix(targetBasisPoint, -secondary:Cross(normal), normal), mouseWorld, dragTargetType + elseif lastSurfaceMatrix then + -- Use the last target mat, and the intersection of the mouse mouseRay with + -- that plane that target mat defined as the drag point. + local t = Math.intersectRayPlane( + mouseRay.Origin, mouseRay.Direction, + lastSurfaceMatrix.Position, lastSurfaceMatrix.UpVector) + mouseWorld = mouseRay.Origin + mouseRay.Direction * t + return lastSurfaceMatrix, mouseWorld, DragTargetType.Nothing + else + -- No previous target or current target, can't drag + return nil + end +end + +--[[ + Update the tiltRotate by rotating 90 degrees around an axis. Axis is the + axis in camera space over which the rotation should happen. +]] +function DragHelper.updateTiltRotate(cameraCFrame, mouseRay, selection, mainCFrame, lastTargetMat, tiltRotate, axis, + alignRotation) + -- Find targetMatrix and dragInTargetSpace in the same way as + -- in DragHelper.getDragTarget, see that function for further explanation. + local targetMatrix, _, dragTargetType, unionPart, draggingOntoWallsSpecialCase = + DragHelper.getSurfaceMatrix(mouseRay, selection, lastTargetMat) + if not targetMatrix then + return tiltRotate + end + + local dragInTargetSpace = targetMatrix:Inverse() * mainCFrame + if alignRotation then + if getFFlagSummonPivot() then + dragInTargetSpace = snapRotationToPrimaryDirection(dragInTargetSpace) + else + dragInTargetSpace = DragHelper.snapRotationToPrimaryDirection(dragInTargetSpace) + end + else + dragInTargetSpace = dragInTargetSpace - dragInTargetSpace.Position + end + + -- Current global rotation when dragging is given by: + -- (targetMatrix * dragInTargetSpace * tiltRotate) + -- + -- So we need to find the local axis of: + -- (targetMatrix * dragInTargetSpace) + -- + -- Which is the closest to Camera.RightVector. Then we can construct + -- the additional `rotation` to add to tiltRotate around that axis: + -- (targetMatrix * dragInTargetSpace * (rotation * tiltRotate)) + local baseMatrix = targetMatrix * dragInTargetSpace + local targetDirection + if not ROTATE_DEPENDS_ON_CAMERA and axis == Vector3.new(0, 1, 0) then + -- This will end up rotating around the normal of the target surface + if draggingOntoWallsSpecialCase then + targetDirection = Vector3.new(0, 1, 0) + else + targetDirection = targetMatrix.UpVector + end + else + targetDirection = cameraCFrame:VectorToWorldSpace(axis) + end + + -- Greater dot product = smaller angle, so the closest direction is + -- the one with the greatest dot product. + local closestAxis + local closestDelta = -math.huge + for _, direction in ipairs(PrimaryDirections) do + local delta = baseMatrix:VectorToWorldSpace(direction):Dot(targetDirection) + if delta > closestDelta then + closestAxis = direction + closestDelta = delta + end + end + + -- If we somehow had NaNs in our CFrame then closestAxis may still be nil + closestAxis = closestAxis or Vector3.new(0, 1, 0) + + -- Could be written without the need for rounding by permuting the + -- components of closestAxis, but this is more understandable. + local rotation = roundRotation(CFrame.fromAxisAngle(closestAxis, math.pi / 2)) + return rotation * tiltRotate, dragTargetType +end + +local function getBroadFaceDirection(instance: Instance): Vector3? + if instance:IsA("BasePart") then + if instance.Size.Z < instance.Size.X then + return instance.CFrame.LookVector + else + return instance.CFrame.XVector + end + elseif instance:IsA("Model") then + local cf, size = instance:GetBoundingBox() + if size.Z < size.X then + return cf.LookVector + else + return cf.XVector + end + else + return nil + end +end + +local function parallel(a: Vector3, b: Vector3): boolean + local dot = a:Dot(b) + return dot > 0.99 or dot < -0.99 +end + +local function perpendicular(a: Vector3, b: Vector3): boolean + return math.abs(a:Dot(b)) < 0.01 +end + +function DragHelper.getDragTarget(mouseRay, snapFunction, dragInMainSpace, selection, mainCFrame, basisPoint, + boundingBoxSize, boundingBoxOffset, tiltRotate, lastTargetMat, alignRotation, draggingUnionParts, localBoundingBoxSize) + if not dragInMainSpace then + return + end + + local targetMatrix, mouseWorld, dragTargetType, unionPart, draggingOntoWallsSpecialCase, wallThickness = + DragHelper.getSurfaceMatrix(mouseRay, selection, lastTargetMat, draggingUnionParts) + if not targetMatrix then + return + end + + local dragInTargetSpace = targetMatrix:Inverse() * mainCFrame + if alignRotation then + -- Now we want to "snap" the rotation of this transformation to 90 degree + -- increments, such that the dragInTargetSpace is only some combination of + -- the primary direction vectors. + if getFFlagSummonPivot() then + dragInTargetSpace = snapRotationToPrimaryDirection(dragInTargetSpace) + else + dragInTargetSpace = DragHelper.snapRotationToPrimaryDirection(dragInTargetSpace) + end + else + -- Just reduce dragInTargetSpace to a rotation + dragInTargetSpace = dragInTargetSpace - dragInTargetSpace.Position + end + + if draggingOntoWallsSpecialCase then + local worldCFrame = (targetMatrix * dragInTargetSpace) + local isSideways = localBoundingBoxSize.Z > localBoundingBoxSize.X + local worldDirection = isSideways and worldCFrame.XVector or worldCFrame.LookVector + if perpendicular(targetMatrix.YVector, worldDirection) then + dragInTargetSpace *= CFrame.fromEulerAnglesXYZ(0, math.pi/2, 0) + end + local dragThickness = math.min(localBoundingBoxSize.X, localBoundingBoxSize.Z) + targetMatrix *= CFrame.new(0, -0.5 * (dragThickness - wallThickness), 0) + end + + -- Now we want to "snap" the basisPoint to be on-Grid in the main space + -- the basisPoint is already in the main space, so we can just snap it to + -- grid and see what offset it moved by. We will need to use a bounding box + -- modified by that offset for the purposes of bumping, and also shift the + -- parts by that much when we finally apply them. That is equivalent to + -- applying the offset as a final factor in the transform this function + -- returns. + local offsetX = snapFunction(basisPoint.X) - basisPoint.X + local offsetY = snapFunction(basisPoint.Y) - basisPoint.Y + local offsetZ = snapFunction(basisPoint.Z) - basisPoint.Z + + local contentOffset = Vector3.new(offsetX, offsetY, offsetZ) + local contentOffsetCF = CFrame.new(contentOffset) + local snappedBoundingBoxOffset = boundingBoxOffset + contentOffset + + -- Compute the size in the space we're dragging into. If alignRotation + -- is enabled, it could just be computed as a permutation of the size + -- components. However, when it is an arbitrary rotational offset thanks + -- to alignRotation being disabled, a larger computation is needed. + local sizeInTargetSpace = DragHelper.getSizeInSpace(boundingBoxSize, dragInTargetSpace * tiltRotate) + + -- Figure out how much we have to "bump up" the selection to have its + -- bounding box sit on top of the plane we're dragging onto. + local offsetInTargetSpace = (dragInTargetSpace * tiltRotate):VectorToWorldSpace(snappedBoundingBoxOffset) + local normalBumpNeeded = (0.5 * sizeInTargetSpace.Y) - offsetInTargetSpace.Y + local normalBumpCF = CFrame.new(0, normalBumpNeeded, 0) + + -- Now we have to figure out the offset of the point we started the drag + -- with from the mainCFrame, and apply that same offset from the point we + -- dragged to on the new plane, to get a total offset which we should apply + -- the increment snapping to in the target space. + local mouseInMainSpace = dragInMainSpace + local mouseInMainSpaceCF = CFrame.new(mouseInMainSpace) + + -- New mouse position is defined by: + -- targetMatrix * snapAdjust * normalBump * dragInTargetSpace * tiltRotate * mouseInMainSpace * contentOffset = + -- mouseWorld + -- So we want to isolate snapAdjust to snap it's X and Z components + local mouseWorldCF = (targetMatrix - targetMatrix.Position) * dragInTargetSpace * tiltRotate + mouseWorld + local snapAdjust = + targetMatrix:Inverse() * + mouseWorldCF * + (normalBumpCF * dragInTargetSpace * tiltRotate * mouseInMainSpaceCF * contentOffsetCF):inverse() + + -- Now that the snapping space is isolated we can apply the snap + local snapAdjustCF = CFrame.new(snapFunction(snapAdjust.X), 0, snapFunction(snapAdjust.Z)) + + -- Get the final CFrame to move the parts to. + local rotatedBase = + targetMatrix * snapAdjustCF * normalBumpCF * + dragInTargetSpace * tiltRotate * contentOffsetCF + + -- Note: Snap point is the visual point that was snapped-to in world space + -- if we want to display that at some point. + local snapPoint = (targetMatrix * snapAdjustCF).Position + + return { + mainCFrame = rotatedBase, + snapPoint = snapPoint, + targetMatrix = targetMatrix, + dragTargetType = dragTargetType, + unionPart = unionPart, + } +end + +-- All values should be in world coordinates +function DragHelper.getCameraPlaneDragTarget(mouseRay, cameraLookVector, clickPoint) + if not clickPoint then + return nil + end + + local unitMouseRay = mouseRay.Unit + local cameraPlaneNormal = -1 * cameraLookVector.Unit + + local t = Math.intersectRayPlane(unitMouseRay.Origin, unitMouseRay.Direction, clickPoint, cameraPlaneNormal) + + if t >= 0 then + local targetPosition = unitMouseRay.Origin + (t * unitMouseRay.Direction) + local positionDelta = targetPosition - clickPoint + return { + mainCFrame = CFrame.new(positionDelta), + snapPoint = nil, -- not applicable to in-camera plane drag + targetMatrix = nil, -- not applicable to in-camera plane drag + dragTargetType = DragTargetType.Nothing, + } + end + + return nil +end + +return DragHelper diff --git a/src/Utility/DragSelector.lua b/src/Utility/DragSelector.lua new file mode 100644 index 0000000..23e3d48 --- /dev/null +++ b/src/Utility/DragSelector.lua @@ -0,0 +1,160 @@ +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent + +local SelectionHelper = require(DraggerFramework.Utility.SelectionHelper) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) + +-- Minimum distance (pixels) required for a drag to select parts. +local DRAG_SELECTION_THRESHOLD = 3 + +local DragSelector = {} +DragSelector.__index = DragSelector + +function DragSelector.new(selectionWrapper, beginBoxSelect, endBoxSelect) + assert(selectionWrapper ~= nil) + assert(beginBoxSelect ~= nil) + assert(endBoxSelect ~= nil) + local self = { + _isDragging = false, + _selectionBeforeDrag = {}, + _dragStartLocation = nil, + _dragCandidates = {}, + _selectionWrapper = selectionWrapper, + _beginBoxSelect = beginBoxSelect, + _endBoxSelect = endBoxSelect, + _insertionOrder = {}, + _insertionOrderNext = 1, + } + + return setmetatable(self, DragSelector) +end + +-- Create a frustum described by the selection start and end locations and current camera. +local function getSelectionFrustum(draggerContext, startLocation, endLocation) + local rect = Rect.new(startLocation, endLocation) + + local topLeft = draggerContext:viewportPointToRay(Vector2.new(rect.Min.X, rect.Min.Y)) + local topRight = draggerContext:viewportPointToRay(Vector2.new(rect.Max.X, rect.Min.Y)) + local bottomRight = draggerContext:viewportPointToRay(Vector2.new(rect.Max.X, rect.Max.Y)) + local bottomLeft = draggerContext:viewportPointToRay(Vector2.new(rect.Min.X, rect.Max.Y)) + + if topRight.Direction:FuzzyEq(topLeft.Direction) then + -- Ortho view + local top = (topRight.Origin - topLeft.Origin):Cross(topRight.Direction) + local right = (bottomRight.Origin - topRight.Origin):Cross(bottomRight.Direction) + local bottom = (bottomLeft.Origin - bottomRight.Origin):Cross(bottomLeft.Direction) + local left = (topLeft.Origin - bottomLeft.Origin):Cross(topLeft.Direction) + + return { + {origin = topLeft.Origin, normal = top}, + {origin = topRight.Origin, normal = right}, + {origin = bottomRight.Origin, normal = bottom}, + {origin = bottomLeft.Origin, normal = left} + } + else + -- Perspective view + local left = bottomLeft.Direction:Cross(topLeft.Direction) + local top = topLeft.Direction:Cross(topRight.Direction) + local right = topRight.Direction:Cross(bottomRight.Direction) + local bottom = bottomRight.Direction:Cross(bottomLeft.Direction) + + return { + {origin = topLeft.Origin, normal = top}, + {origin = topRight.Origin, normal = right}, + {origin = bottomRight.Origin, normal = bottom}, + {origin = bottomLeft.Origin, normal = left} + } + end +end + +function DragSelector:getStartLocation() + return self._dragStartLocation +end + +-- Get list of drag candidates from all selectable parts in the workspace. +-- startLocation can override the location the drag is treated as having +-- started at. +function DragSelector:beginDrag(draggerContext, startLocation) + assert(not self._isDragging, "Cannot begin drag when already dragging.") + self._isDragging = true + + self._dragCandidates = self._beginBoxSelect(draggerContext) + self._selectionBeforeDrag = self._selectionWrapper:get() + self._dragStartLocation = startLocation or draggerContext:getMouseLocation() +end + +--[[ + Test selectable parts against the frustum defined by the drag start location + and passed in location. Parts within the frustum are added or removed from + the selection, based on the held modified keys. +]] +function DragSelector:updateDrag(draggerContext) + assert(self._isDragging, "Cannot update drag when no drag in progress.") + + local shouldXorSelection = draggerContext:shouldExtendSelection() + local shouldDrillSelection = draggerContext:isAltKeyDown() + local location = draggerContext:getMouseLocation() + + local screenMovement = location - self._dragStartLocation + if screenMovement.Magnitude < DRAG_SELECTION_THRESHOLD then + return + end + + local planes = getSelectionFrustum(draggerContext, + self._dragStartLocation, location) + if not planes then + return + end + + local newSelection = {} + local didChangeSelection = false + local insertionOrder = self._insertionOrder + for _, candidate in ipairs(self._dragCandidates) do + local inside = true + for _, plane in ipairs(planes) do + local dot = (candidate.Center - plane.origin):Dot(plane.normal) + if dot < 0 then + inside = false + break + end + end + if inside ~= candidate.Selected then + candidate.Selected = inside + didChangeSelection = true + if getEngineFeatureModelPivotVisual() and inside then + insertionOrder[candidate.Selectable] = self._insertionOrderNext + self._insertionOrderNext += 1 + end + end + if inside then + table.insert(newSelection, candidate.Selectable) + end + end + + if didChangeSelection then + if getEngineFeatureModelPivotVisual() then + table.sort(newSelection, function(a, b) + return (insertionOrder[a] or 0) < (insertionOrder[b] or 0) + end) + end + + newSelection = SelectionHelper.updateSelectionWithMultipleSelectables( + newSelection, self._selectionBeforeDrag, + shouldXorSelection) + self._selectionWrapper:set(newSelection) + end +end + +function DragSelector:commitDrag(draggerContext) + self:updateDrag(draggerContext) + + self._endBoxSelect(draggerContext) + + self._selectionBeforeDrag = {} + self._dragStartLocation = nil + self._isDragging = false +end + +return DragSelector diff --git a/src/Utility/JointMaker.lua b/src/Utility/JointMaker.lua new file mode 100644 index 0000000..4fbccb2 --- /dev/null +++ b/src/Utility/JointMaker.lua @@ -0,0 +1,242 @@ + +local RunService = game:GetService("RunService") + +local DraggerFramework = script.Parent.Parent +local getGeometry = require(DraggerFramework.Utility.getGeometry) +local JointPairs = require(DraggerFramework.Utility.JointPairs) +local JointUtil = require(DraggerFramework.Utility.JointUtil) + +local getFFlagPreserveMotor6D = require(DraggerFramework.Flags.getFFlagPreserveMotor6D) + +local JointMaker = {} +JointMaker.__index = JointMaker + +function JointMaker.new(isSimulating) + return setmetatable({ + _isSimulating = isSimulating, + }, JointMaker) +end + +local function getConstraintLength(joint) + local a = joint.Attachment0.WorldPosition + local b = joint.Attachment1.WorldPosition + return (b - a).Magnitude +end + +--[[ + Set the parts to compute joints for, precomputing as much info as possible +]] +function JointMaker:pickUpParts(parts) + local partSet = {} + for _, part in ipairs(parts) do + partSet[part] = true + end + self._partSet = partSet + self._parts = parts + self._rootPartSet = {} -- Intentionally empty, only needed for IK moves + + local FFlagPreserveMotor6D = getFFlagPreserveMotor6D() + + local weldConstraintsToReenableSet = {} + local motor6dsToAdjustAndReenableSet = {} + local jointsToDestroy = {} + local alreadyConnectedToSets = {} + local initiallyTouchingSets = {} + local internalJointSet = {} + local springsToFixupSet = {} + local lengthConstraintsToFixupSet = {} + for _, part in ipairs(parts) do + alreadyConnectedToSets[part] = {} + for _, joint in ipairs(part:GetJoints()) do + if joint:IsA("Constraint") then + local other = JointUtil.getConstraintCounterpart(joint, part) + if other then + alreadyConnectedToSets[part][other] = true + + if joint:IsA("RopeConstraint") or + joint:IsA("RodConstraint") then + lengthConstraintsToFixupSet[joint] = { + Span = getConstraintLength(joint), + Length = joint.Length, + } + elseif joint:IsA("SpringConstraint") then + springsToFixupSet[joint] = { + Span = getConstraintLength(joint), + FreeLength = joint.FreeLength, + } + end + end + elseif joint:IsA("JointInstance") then + local other = JointUtil.getJointInstanceCounterpart(joint, part) + if partSet[other] then + internalJointSet[joint] = joint.Part1 + else + if FFlagPreserveMotor6D and joint:IsA("Motor6D") then + joint.Enabled = false + motor6dsToAdjustAndReenableSet[joint] = part.CFrame + alreadyConnectedToSets[part][other] = true + else + table.insert(jointsToDestroy, joint) + end + end + elseif joint:IsA("WeldConstraint") then + local other = JointUtil.getWeldConstraintCounterpart(joint, part) + joint.Enabled = false + alreadyConnectedToSets[part][other] = true + weldConstraintsToReenableSet[joint] = true + elseif joint:IsA("NoCollisionConstraint") then + local other = JointUtil.getNoCollisionConstraintCounterpart(joint, part) + alreadyConnectedToSets[part][other] = true + end + end + + initiallyTouchingSets[part] = {} + for _, otherPart in ipairs(part:GetTouchingParts()) do + initiallyTouchingSets[part][otherPart] = true + end + end + self._lengthConstraintsToFixupSet = lengthConstraintsToFixupSet + self._springsToFixupSet = springsToFixupSet + self._internalJointSet = internalJointSet + self._initiallyTouchingSets = initiallyTouchingSets + self._jointsToDestroy = jointsToDestroy + self._weldConstraintsToReenableSet = weldConstraintsToReenableSet + if FFlagPreserveMotor6D then + self._motor6dsToAdjustAndReenableSet = motor6dsToAdjustAndReenableSet + end + self._alreadyConnectedToSets = alreadyConnectedToSets + self._geometryCache = {} +end + +function JointMaker:anchorParts() + local toUnanchorSet = {} + for _, part in ipairs(self._parts) do + if not part.Anchored then + part.Anchored = true + toUnanchorSet[part] = true + end + end + self._toUnanchorSet = toUnanchorSet +end + +function JointMaker:restoreAnchored() + if self._toUnanchorSet then + for part, _ in pairs(self._toUnanchorSet) do + part.Anchored = false + end + self._toUnanchorSet = nil + end +end + +--[[ + Break existing joints to others +]] +function JointMaker:breakJointsToOutsiders() + for _, joint in ipairs(self._jointsToDestroy) do + joint.Parent = nil + end + self._jointsToDestroy = {} +end + +--[[ + Break joints between parts in the part list +]] +function JointMaker:disconnectInternalJoints() + for joint, _ in pairs(self._internalJointSet) do + joint.Part1 = nil + end +end + +--[[ + Reconnect the internal joints between parts with a scale +]] +function JointMaker:reconnectInternalJointsWithScale(scale) + for joint, part1 in pairs(self._internalJointSet) do + joint.C0 = joint.C0 + joint.C0.Position * (scale - 1) + joint.C1 = joint.C1 + joint.C1.Position * (scale - 1) + joint.Part1 = part1 + end +end + +--[[ + Compute the candidate joint pairs for the parts at their current location. +]] +function JointMaker:computeJointPairs() + local jointPairs = JointPairs.new(self._parts, self._partSet, self._rootPartSet, + CFrame.new(), + self._alreadyConnectedToSets, function(part) + return self:_getGeometry(part) + end) + + if self._isSimulating then + self._geometryCache = {} + end + + return jointPairs +end + +function JointMaker:isColliding(includeInitiallyTouching) + for _, part in ipairs(self._parts) do + for _, otherPart in ipairs(part:GetTouchingParts()) do + if not self._partSet[otherPart] then + if includeInitiallyTouching or not self._initiallyTouchingSets[part][otherPart] then + return true + end + end + end + end + return false +end + +function JointMaker:fixupConstraintLengths() + for constraint, data in pairs(self._lengthConstraintsToFixupSet) do + local scaledBy = getConstraintLength(constraint) / data.Span + constraint.Length = data.Length * scaledBy + end + for constraint, data in pairs(self._springsToFixupSet) do + local scaledBy = getConstraintLength(constraint) / data.Span + constraint.FreeLength = data.FreeLength * scaledBy + end +end + +function JointMaker:putDownParts() + for weld, _ in pairs(self._weldConstraintsToReenableSet) do + weld.Enabled = true + end + if getFFlagPreserveMotor6D() then + for motor6d, originalCFrame in pairs(self._motor6dsToAdjustAndReenableSet) do + if self._partSet[motor6d.Part0] then + -- Modify C0 + local part0 = motor6d.Part0 + motor6d.C0 = part0.CFrame:Inverse() * originalCFrame * motor6d.C0 + else + -- Modify C1 + local part1 = motor6d.Part1 + motor6d.C1 = part1.CFrame:Inverse() * originalCFrame * motor6d.C1 + end + motor6d.Enabled = true + end + self._motor6dsToAdjustAndReenableSet = nil + end + self._weldConstraintsToReenableSet = nil + self._alreadyConnectedToSets = nil + self._geometryCache = nil + self._parts = {} + self._partSet = {} +end + +function JointMaker:_getGeometry(part) + if self._partSet[part] then + -- Scaling, so our geometry might change every step + return getGeometry(part) + else + local geometry = self._geometryCache[part] + if not geometry then + geometry = getGeometry(part) + self._geometryCache[part] = geometry + end + return geometry + end +end + +return JointMaker \ No newline at end of file diff --git a/src/Utility/JointPairs.lua b/src/Utility/JointPairs.lua new file mode 100644 index 0000000..23d208e --- /dev/null +++ b/src/Utility/JointPairs.lua @@ -0,0 +1,435 @@ +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local Packages = DraggerFramework.Parent +local Roact = require(Packages.Roact) +local Colors = require(DraggerFramework.Utility.Colors) +local Math = require(DraggerFramework.Utility.Math) + +local JointPairs = {} +JointPairs.__index = JointPairs + +local JointTypeToColor = { + Rotate = Colors.RotatingJoint, + RotateV = Colors.RotatingJoint, + RotateP = Colors.RotatingJoint, + Weld = Colors.WeldJoint, + None = Colors.InvalidJoint, +} + +--[[ + How far away from a part to look for other parts to join to. This is purely + an optimization setting, as long as it is larger than JOINT_TOLERANCE it + won't change the PartMover behavior. +]] +local FUZZY_TOLERANCE = 0.1 + +--[[ + How close together the parallel faces of two parts have to be for a joint + to be made between them. +]] +local JOINT_TOLERANCE = 0.05 + +--[[ + How close the dot product of two face normals has to be for a joint to be + made between them. +]] +local JOINT_ANGLE_TOLERANCE = 0.001 + +local function isVertexInFace(vert, face, normal) + for i = 1, #face do + local e1, e2; + if i == 1 then + e1 = face[#face] + e2 = face[1] + else + e1 = face[i - 1] + e2 = face[i] + end + local edge = e2 - e1 + local to = vert - e1 + -- TODO: Fix this. For very large parts this will lead to erroneous + -- misses because we're comparing to an angle tolerance rather than + -- a distance tolerance by having a tolerance here + if edge:Cross(to):Dot(normal) < 0.01 then + return false + end + end + return true +end + +local function faceHasVertsInFace(face, containingFace, normal) + for _, vert in ipairs(face) do + if isVertexInFace(vert, containingFace, normal) then + return true + end + end + return false +end + +local function edgeIntersectsEdge(a1, a2, b1, b2, bnormal) + local arun, brun = (a2 - a1), (b2 - b1) + local alen, blen = arun.Magnitude, brun.Magnitude + local edgeDot = arun:Dot(brun) / alen / blen + local athres = 0.01 / alen + local bthres = 0.01 / blen + + --[[ + More common case, edges coincident over a non-zero length section. + We also have a consistent winding direction on our geometry, so if the + edges are going in the same direction, they are from an edge that is + touching but not thanks to a touching face. For example: + [] + [] Those two blocks touch at one edge, but not at any faces. + ]] + if math.abs(math.abs(edgeDot) - 1) < 0.0001 then + --parallel case + if edgeDot > 0 then + -- They are going in the same direction, they can't be + -- an edge pair to join on + return false + else + -- They are going in opposite directions, this might be an exactly + -- coincident edge. + + -- First check if they are cooincident + local inB = (a1 - b1):Dot(brun) / blen / blen + local dist = (a1 - (b1 + inB * brun)).Magnitude + if dist > 0.001 then + return false + end + + -- Now we need to see if they overlap + local inB2 = (a2 - b1):Dot(brun) / blen / blen + local interval = math.clamp(inB, 0, 1) - math.clamp(inB2, 0, 1) + return interval > 0.001 -- They are coincident and share an interval + end + end + + --[[ + Less common case, edges that cross one and other. Imagine two long thin + parts arranged in an X shape. They should be joined, and that joint comes + from this case, where their edges intersect at the middle of the X. + ]] + local intersects, s = Math.intersectRayRay(a1, arun, b1, brun) + if not intersects or s < athres or s > 1 - athres then + return false + end + local intersects2, t = Math.intersectRayRay(b1, brun, a1, arun) + assert(intersects2) -- Must be true if intersects was true + return t >= bthres and t <= 1 - bthres +end + +local function edgesIntersectsEdges(face, otherFace, otherNormal) + for i = 1, #face do + local a1, a2; + if i == 1 then + a1, a2 = face[#face], face[1] + else + a1, a2 = face[i - 1], face[i] + end + for j = 1, #otherFace do + local b1, b2; + if j == 1 then + b1, b2 = otherFace[#otherFace], otherFace[1] + else + b1, b2 = otherFace[j - 1], otherFace[j] + end + if edgeIntersectsEdge(a1, a2, b1, b2, otherNormal) then + return true + end + end + end + return false +end + +local function canMakeJointBetweenFaces(part, face, otherPart, otherFace, otherNormal) + -- Can join if there are any touching faces. This can be divided into: + -- 1. Obviously touching if verts of one face are contained in the other face + -- 2. If edges of one face intersect the other, for example, when two long + -- parts are arranged in an X shape. + return faceHasVertsInFace(face.vertices, otherFace.vertices, otherNormal) or + faceHasVertsInFace(otherFace.vertices, face.vertices, -otherNormal) or + edgesIntersectsEdges(face.vertices, otherFace.vertices, otherNormal) +end + +local function getFaceCenter(face) + if not face.center then + local total = Vector3.new() + for _, vert in ipairs(face.vertices) do + total = total + vert + end + face.center = total / #face.vertices + end + return face.center +end + +local function buildJoint(part0, face0, part1, jointType) + -- Determine the C0 and C1 for the joint. Center it around the center of + -- the relevant face, since that's where the surface axis gizmo appears. + local centerCFrame = + CFrame.fromMatrix( + getFaceCenter(face0), + face0.direction, face0.normal:Cross(face0.direction)) + return { + ClassName = jointType, + Part0 = part0, + C0 = part0.CFrame:Inverse() * centerCFrame, + Part1 = part1, + C1 = part1.CFrame:Inverse() * centerCFrame, + } +end + +local function buildInvalidJoint() + return { + ClassName = "None", + } +end + +--[[ + Called once we already know that there is a joint between two faces, but + don't know what _kind_ of joint it is yet or which part will be the + part0 and which will be the part1. + + `part` is the part being moved, and its face surface type takes priority + as far as determining the type of joint. +]] +local function buildAppropriateJoint(part, otherPart, shape, otherShape, face, otherFace) + local isFaceAcceptable = + (shape == "Mesh") or + (shape == "Cylinder" and + (face.surface == "RightSurface" or face.surface == "LeftSurface")) + + local isOtherFaceAcceptable = + (otherShape == "Mesh") or + (otherShape == "Cylinder" and + (otherFace.surface == "RightSurface" or otherFace.surface == "LeftSurface")) + + if isFaceAcceptable and isOtherFaceAcceptable then + return buildJoint(part, face, otherPart, "Weld") + else + -- Only planar meshes and the flat ends of cylinders are allowed to + -- form surface welds. + return nil + end +end + +--[[ + Check if a joint is possible between two specific parts and return the joint + if there is a possible joint. + Updates facesToHighlightSet with both the possible joint if there is one, + and with an invalid joint if there's touching faces that can't be joined. +]] +local function tryToCreateJointPair(transform, part, otherPart, facesToHighlightSet, + getGeometryFunction, isAHumanoidModelFunction) + local partGeometry = getGeometryFunction(part) + local otherGeometry = getGeometryFunction(otherPart) + + -- Transform faces of the + local tempTransformedFaces = {} + + -- Easy case first, just handle mesh-mesh for now + for _, partFace in ipairs(partGeometry.faces) do + for _, otherFace in ipairs(otherGeometry.faces) do + local transformedNormal = transform:VectorToWorldSpace(partFace.normal) + local transformedDirection = transform:VectorToWorldSpace(partFace.direction) + local dot = transformedNormal:Dot(otherFace.normal) + if dot < -(1 - JOINT_ANGLE_TOLERANCE) then + local newPoint = transform:PointToWorldSpace(partFace.point) + local dist = (newPoint - otherFace.point):Dot(otherFace.normal) + if math.abs(dist) < JOINT_TOLERANCE then + local tempTransformedFace = tempTransformedFaces[partFace] + if not tempTransformedFace then + local transformedFaceVerts = {} + for _, vertex in ipairs(partFace.vertices) do + table.insert(transformedFaceVerts, transform * vertex) + end + tempTransformedFace = { + id = partFace.id, + vertices = transformedFaceVerts, + normal = transformedNormal, + direction = transformedDirection, + surface = partFace.surface, + -- Note: There are more fields, we're only copying + -- over the modified ones we need. + } + tempTransformedFaces[partFace] = tempTransformedFace + end + local canJoin = canMakeJointBetweenFaces( + part, tempTransformedFace, + otherPart, otherFace, + otherFace.normal) + if canJoin then + -- The faces are aligned and close to cooincident + local joint = buildAppropriateJoint( + part, otherPart, + partGeometry.shape, otherGeometry.shape, + tempTransformedFace, otherFace) + if joint and not isAHumanoidModelFunction(part.Parent) and + not isAHumanoidModelFunction(otherPart.Parent) then + -- This if exists because of the extra weird case of + -- surface hinges/motors blocking joints on the rest + -- of the surface that they're on. + -- Also block joints between parts when either part + -- is inside of a humanoid. + facesToHighlightSet[tempTransformedFace] = joint + facesToHighlightSet[otherFace] = joint + return true, joint + else + -- If the joint doesn't exist, we draw an invalid + -- joint pair (red) between the parts. + joint = buildInvalidJoint() + facesToHighlightSet[tempTransformedFace] = joint + facesToHighlightSet[otherFace] = joint + return false + end + end + end + end + end + end + return false +end + +--[[ + Compute the joint pairs between a collection of parts and the rest of the + world. Return a JointPairs object storing information about the computed + joints for further use. + + partList - A list of the parts + partSet -- The same parts, organized as a set rather than a list. + rootPartSet -- A set of the root parts of the parts in the partList. + globalTransform -- + A transform to apply to the partSet, including any getGeometry results + returned for them. + alreadyConnectedToSets -- + alreadyConnectedToSets[part][otherPart] is true if part and other are + already connected with a joint. + getGeometryFunction -- + A function that takes a part and returns the getGeometry() for it. +]] +function JointPairs.new(partList, partSet, rootPartSet, globalTransform, alreadyConnectedToSets, getGeometryFunction) + local self = setmetatable({}, JointPairs) + + local isAHumanoidModel = {} + local function isAHumanoidModelFunction(object) + if not object then + return false + end + local status = isAHumanoidModel[object] + if not status then + status = + (object:FindFirstChildWhichIsA("Humanoid") ~= nil) or + isAHumanoidModelFunction(object.Parent) + isAHumanoidModel[object] = status + end + return status + end + + local terrain = Workspace.Terrain + local facesToHighlightSet = {} + local jointPairs = {} + + for _, part in ipairs(partList) do + local radius = part.Size.Magnitude / 2 + local radiusVector = + Vector3.new(radius + FUZZY_TOLERANCE, radius + FUZZY_TOLERANCE, radius + FUZZY_TOLERANCE) + local nearbyParts = Workspace:FindPartsInRegion3WithIgnoreList( + Region3.new(part.Position - radiusVector, part.Position + radiusVector), + {}, --partList could go here but costs too much to repeatedly reflect + 10000) + + -- Terrain joint case. If we are touching the terrain at all then + -- create a joint pair with it, regardless of surface geometry. + for _, touchingPart in ipairs(part:GetTouchingParts()) do + if touchingPart == terrain then + if not alreadyConnectedToSets[part][terrain] then + table.insert(jointPairs, { + ClassName = "Weld", + C0 = CFrame.new(), + C1 = part.CFrame, + Part0 = part, + Part1 = terrain, + }) + end + break + end + end + + for _, otherPart in ipairs(nearbyParts) do + if not partSet[otherPart] and not rootPartSet[otherPart:GetRootPart()] then + if otherPart ~= terrain then + local otherRadius = otherPart.Size.Magnitude / 2 + if (otherPart.Position - part.Position).Magnitude <= (radius + otherRadius) + JOINT_TOLERANCE then + -- This is a very uncommon condition (happens only for + -- constraints joining a part in the selection and + -- one not in the selection and only when those two parts + -- are touching) which is why I put it innermost. + if not alreadyConnectedToSets[part][otherPart] then + local success, joint = tryToCreateJointPair(globalTransform, + part, otherPart, + facesToHighlightSet, + getGeometryFunction, isAHumanoidModelFunction) + if success then + table.insert(jointPairs, joint) + end + end + end + end + end + end + end + + self._jointPairs = jointPairs + self._facesToHighlightSet = facesToHighlightSet + + return self +end + +--[[ + Return a Roact component visually displaying the joint pairs to be created. +]] +function JointPairs:renderJoints(scale) + local faceViews = {} + for face, joint in pairs(self._facesToHighlightSet) do + local edgeViews = {} + for i = 1, #face.vertices do + local v0, v1; + if i == 1 then + v0, v1 = face.vertices[#face.vertices], face.vertices[1] + else + v0, v1 = face.vertices[i - 1], face.vertices[i] + end + local edgeLength = (v0 - v1).Magnitude + edgeViews[i] = Roact.createElement("CylinderHandleAdornment", { + CFrame = CFrame.new(v0, v1) * CFrame.new(0, 0, -edgeLength / 2) * CFrame.Angles(0, 0, math.pi / 2), + Color3 = JointTypeToColor[joint.ClassName], + Radius = 0.05 + 0.05 * scale, + Height = edgeLength, + Adornee = Workspace.Terrain + }) + end + + -- Using the face table's hash as key here. There's no other good way to + -- uniquely identify the face, because they key is really the part that + -- the face is being shown for, but we can't use a part as a key here. + faceViews[tostring(face)] = Roact.createElement("Folder", {}, edgeViews) + end + return Roact.createFragment(faceViews) +end + +--[[ + Create the joint pairs and parent them to the Workspace. +]] +function JointPairs:createJoints() + for _, joint in ipairs(self._jointPairs) do + local jointInstance = Instance.new(joint.ClassName) + jointInstance.Part0 = joint.Part0 + jointInstance.C0 = joint.C0 + jointInstance.Part1 = joint.Part1 + jointInstance.C1 = joint.C1 + jointInstance.Parent = joint.Part0 + end +end + +return JointPairs \ No newline at end of file diff --git a/src/Utility/JointUtil.lua b/src/Utility/JointUtil.lua new file mode 100644 index 0000000..004b96b --- /dev/null +++ b/src/Utility/JointUtil.lua @@ -0,0 +1,49 @@ +local JointUtil = {} + +function JointUtil.getConstraintCounterpart(constraint, part) + -- Ugly micro-optimized code because this function gets hit in hot paths + -- The micro-optimized version saves 3-4ms on some actions over the + -- unoptimized variant. + local attachment0 = constraint.Attachment0 + if attachment0 then + local attachment0Parent = attachment0.Parent + if attachment0Parent == part then + local attachment1 = constraint.Attachment1 + return attachment1 and attachment1.Parent + else + return attachment0Parent + end + else + local attachment1 = constraint.Attachment1 + return attachment1 and attachment1.Parent + end +end + +function JointUtil.getJointInstanceCounterpart(joint, part) + local part0 = joint.Part0 + if part0 == part then + return joint.Part1 + else + return part0 + end +end + +function JointUtil.getWeldConstraintCounterpart(weldConstraint, part) + local part0 = weldConstraint.Part0 + if part0 == part then + return weldConstraint.Part1 + else + return part0 + end +end + +function JointUtil.getNoCollisionConstraintCounterpart(noCollisionConstraint, part) + local part0 = noCollisionConstraint.Part0 + if part0 == part then + return noCollisionConstraint.Part1 + else + return part0 + end +end + +return JointUtil \ No newline at end of file diff --git a/src/Utility/Math.lua b/src/Utility/Math.lua new file mode 100644 index 0000000..737bd86 --- /dev/null +++ b/src/Utility/Math.lua @@ -0,0 +1,148 @@ +local Math = {} + +-- Insersect Ray(a + t*b) with plane (origin: o, normal: n), return t of the interesection +function Math.intersectRayPlane(a, b, o, n) + return (o - a):Dot(n) / b:Dot(n) +end + +-- Intersect Ray(a + t*b) with plane (origin: o, normal :n), and return the intersection as Vector3 +function Math.intersectRayPlanePoint(a, b, o, n) + local t = Math.intersectRayPlane(a, b, o, n) + return a + t * b; +end + +--[[ + The return value `t` is a number such that `r1o + t * r1d` is the point of + closest approach on the first ray between the two rays specified by the + arguments. +]] +function Math.intersectRayRay(r1o, r1d, r2o, r2d) + local n = + (r2o - r1o):Dot(r1d) * r2d:Dot(r2d) + + (r1o - r2o):Dot(r2d) * r1d:Dot(r2d) + local d = + r1d:Dot(r1d) * r2d:Dot(r2d) - + r1d:Dot(r2d) * r1d:Dot(r2d) + if d == 0 then + return false + else + return true, n / d + end +end + +--[[ + Returns the point of closest approach on the first ray between the two rays + specified by the arguments. +]] +function Math.intersectRayRayPoint(r1o, r1d, r2o, r2d) + local r1du = r1d.Unit + local r2du = r2d.Unit + local b, t = Math.intersectRayRay(r1o, r1du, r2o, r2du) + return r1o + t * r1du +end + +--[[ + Intersect a ray (origin + t * direction) + with + A cylinder located at the origin with its axis aligned in the +x direction + having a radius and a height + Return t +]] +function Math.intersectRayCylinder(origin, direction, radius, height) + local p0 = origin + + local a = direction.Y * direction.Y + direction.Z * direction.Z + local b = direction.Y * p0.Y + direction.Z * p0.Z + local c = p0.Y * p0.Y + p0.Z * p0.Z - radius * radius + + local delta = b * b - a * c; + if delta < 0 then + return false + end + + local t1 = (-b - math.sqrt(delta)) / a + local x1 = p0.X + t1 * direction.X + if math.abs(x1) <= 0.5 * height then + return true, t1 + end + + local t2 = (-b + math.sqrt(delta)) / a + local x2 = p0.X + t2 * direction.X + if math.abs(x2) <= 0.5 * height then + return true, t2 + end + + return false +end + +--[[ + Returns the closest point of intersection along a ray with a sphere. + + For line-sphere intersection, there are three possible results: no intersection, + one point intersection (tangent), and two point intersection. Since a ray has an + origin and direction, we only need to consider the smaller (and positive) of the + two intersections. +]] +function Math.intersectRaySphere(rayOrigin, rayDirection, sphereOrigin, radius) + local oc = rayOrigin - sphereOrigin + local a = rayDirection:Dot(rayDirection) + local b = 2 * oc:Dot(rayDirection) + local c = oc:Dot(oc) - radius * radius + local discriminant = b * b - 4 * a * c + if discriminant >= 0 then + local numerator = -b - math.sqrt(discriminant) + if numerator > 0 then + return true, numerator / 2 * a + end + end + return false +end + +function Math.regionFromParts(parts) + local minX, minY, minZ = math.huge, math.huge, math.huge + local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge + for _, part in ipairs(parts) do + local min, max + if part:IsA("BasePart") then + min = part.CFrame.Position - part.Size + max = part.CFrame.Position + part.Size + elseif part:IsA("Model") then + local orientation, size = part:GetBoundingBox() + min = orientation - size + max = orientation + size + end + if min ~= nil and max ~= nil then + minX = math.min(minX, min.X) + minY = math.min(minY, min.Y) + minZ = math.min(minZ, min.Z) + maxX = math.max(maxX, max.X) + maxY = math.max(maxY, max.Y) + maxZ = math.max(maxZ, max.Z) + end + end + return Region3.new(Vector3.new(minX, minY, minZ), Vector3.new(maxX, maxY, maxZ)) +end + + +-- Convert a subset of {X: bool, Y: bool, Z: bool} to a vector with corresponding axes set to 1 +function Math.setToVector3(set) + 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) + or Vector3.new(0, 0, 0) +end + +-- Convert vector into array so that its components could be indexed +function Math.vectorToArray(vec) + return {vec.X, vec.Y, vec.Z} +end + +-- Largest component of a vector +function Math.maxComponent(vec) + return math.max(vec.X, vec.Y, vec.Z) +end + +-- Smallest component of a vector +function Math.minComponent(vec) + return math.min(vec.X, vec.Y, vec.Z) +end + +return Math diff --git a/src/Utility/MockAnalytics.lua b/src/Utility/MockAnalytics.lua new file mode 100644 index 0000000..a5b7043 --- /dev/null +++ b/src/Utility/MockAnalytics.lua @@ -0,0 +1,16 @@ +--[[ +No-op Analytics replacement which the unit tests direct logging to. +]] + +local Analytics = {} + +function Analytics:sendEvent(eventName, argMap) +end + +function Analytics:reportCounter(counterName, count) +end + +function Analytics:reportStats(statName, value) +end + +return Analytics \ No newline at end of file diff --git a/src/Utility/PartMover.lua b/src/Utility/PartMover.lua new file mode 100644 index 0000000..2559944 --- /dev/null +++ b/src/Utility/PartMover.lua @@ -0,0 +1,578 @@ +--[[ + Shared utility class which transforms parts efficiently as a group, and + manages joining / unjoining them from the world. +]] +local Workspace = game:GetService("Workspace") +local RunService = game:GetService("RunService") + +local DraggerFramework = script.Parent.Parent +local getGeometry = require(DraggerFramework.Utility.getGeometry) +local JointPairs = require(DraggerFramework.Utility.JointPairs) +local JointUtil = require(DraggerFramework.Utility.JointUtil) + +local getFFlagPreserveMotor6D = require(DraggerFramework.Flags.getFFlagPreserveMotor6D) +local getFFlagOnlyGetGeometryOnce = require(DraggerFramework.Flags.getFFlagOnlyGetGeometryOnce) + +local DEFAULT_COLLISION_THRESHOLD = 0.001 + +-- Get all the instances the user has directly selected (actually part of the +-- selection) +local function getSelectedInstanceSet(selection) + local selectedInstanceSet = {} + for _, instance in pairs(selection) do + selectedInstanceSet[instance] = true + end + return selectedInstanceSet +end + +local PartMover = {} +PartMover.__index = PartMover + +--[[ + Default value for IK dragging translation and rotation stiffness. +]] +local DRAG_CONSTRAINT_STIFFNESS = 0.85 +local DRAG_CONSTRAINT_LESS_STIFFNESS = 0.40 + +function PartMover.new() + local self = setmetatable({ + _partSet = {}, + _toUnanchor = {}, + _facesToHighlightSet = {}, + _nearbyGeometry = { --[[ [BasePart x]: (self:_getGeometry(x)) ]] }, + }, PartMover) + self:_createMainPart() + return self +end + +--[[ + The part we're using to move stuff that needs to be ignored for raycasting. +]] +function PartMover:getIgnorePart() + return self._mainPart +end + +function PartMover:setDragged(parts, originalCFrameMap, breakJoints, customCenter, selection, allModels) + -- Separate out the Workspace parts which will be passed to + -- Workspace::ArePartsTouchingOthers for collision testing + local workspaceParts = table.create(16) + for _, part in ipairs(parts) do + if part:IsDescendantOf(Workspace) then + -- The part:GetRootPart() check should not be needed! + -- This is a hack because parts under the workspace are supposed to + -- always have a root part, but under some unknown extremely rare + -- circumstances they do not. + if part:GetRootPart() then + table.insert(workspaceParts, part) + end + end + end + self._workspaceParts = workspaceParts + + assert(not self._moving) + self._moving = true + self._originalCFrameMap = originalCFrameMap + if #parts == 0 then + self._parts = {} + return + end + self:_initPartSet(parts) + self._customCenter = customCenter or Vector3.new() + self:_prepareJoints(parts, breakJoints) + self._hasSetupGeometryTracking = false + self:_setupBulkMove(parts, getSelectedInstanceSet(selection)) + + local originalModelPivotMap = {} + for _, model in ipairs(allModels) do + originalModelPivotMap[model] = model:GetPivot() + end + self._originalModelPivotMap = originalModelPivotMap + + self._parts = parts + self._hasMovementWelds = false +end + +function PartMover:_setupBulkMove(parts, selectedInstanceSet) + local movingRootSet = {} + local originalCFrameMap = self._originalCFrameMap + + -- Directly selected instances need special handling, they must be moved + -- with CFrame changes. If they are not, the properties widget will not + -- show updates to their properties in real time, that is why there are + -- two loops below: A first pass to handle all the directly selected + -- instances, and a second pass to capture everything else. + + local moveWithCFrameChangeOriginalCFrameArray = {} + local moveWithCFrameChangePartArray = {} + local moveWithCFrameChangeNextIndex = 1 + for _, part in ipairs(parts) do + if selectedInstanceSet[part] then + -- Only need to care about parts which are their own root here. + -- In order to move directly selected parts, at least one of the + -- directly selected parts will usually become a root as the + -- selection is dis-jointed from the surroundings at the start + -- of the drag. The only edge-case this does not cover is the + -- case where you select a model and part welded to the model, + -- and the assembly root is in the model. This case is not + -- possible to handle so don't worry about it. + if (part:GetRootPart() or part) == part then + -- We don't need to check whether we're already in the set, + -- because the root == part check already implies that + -- we're not as long as the parts list doesn't have + -- duplicates (a condition which is enforced elsewhere). + movingRootSet[part] = true + + moveWithCFrameChangePartArray[moveWithCFrameChangeNextIndex] = part + moveWithCFrameChangeOriginalCFrameArray[moveWithCFrameChangeNextIndex] = + originalCFrameMap[part] + moveWithCFrameChangeNextIndex = moveWithCFrameChangeNextIndex + 1 + end + end + end + + local partsToBulkMoveArray = {} + local originalCFramesArray = {} + local nextIndexToInsertAt = 1 + for _, part in ipairs(parts) do + local movementRoot = part:GetRootPart() or part + if not movingRootSet[movementRoot] then + movingRootSet[movementRoot] = true + + partsToBulkMoveArray[nextIndexToInsertAt] = movementRoot + originalCFramesArray[nextIndexToInsertAt] = originalCFrameMap[movementRoot] + nextIndexToInsertAt = nextIndexToInsertAt + 1 + end + end + + -- Anchor all of the unanchored roots if we're dragging while simulation + -- is happening, so that the parts don't move around as we're trying + -- to drag them. + if RunService:IsRunning() then + for root, _ in pairs(movingRootSet) do + if not root.Anchored then + root.Anchored = true + self._toUnanchor[root] = true + end + end + end + + self._moveWithCFrameChangeParts = moveWithCFrameChangePartArray + self._moveWithCFrameChangeOriginalCFrames = moveWithCFrameChangeOriginalCFrameArray + self._moveWithCFrameChangeTargetCFrames = table.create(#moveWithCFrameChangePartArray) + self._bulkMoveParts = partsToBulkMoveArray + self._bulkMoveOriginalCFrames = originalCFramesArray + self._bulkMoveTargetCFrames = table.create(#partsToBulkMoveArray) +end + +function PartMover:_initPartSet(parts) + self._partSet = {} + for _, part in ipairs(parts) do + self._partSet[part] = true + end +end + +--[[ + _getGeometry caches into nearbyGeometry, so here we reset that table + and load the initial geometry of the parts to move into the cache. +]] +function PartMover:_setupGeometryTracking(parts) + if getFFlagOnlyGetGeometryOnce() then + -- Remove the parts argument with OnlyGetGeometryOnce flag + assert(parts == nil) + assert(not self._hasSetupGeometryTracking) + end + self._nearbyGeometry = {} + self._rootPartSet = {} + + if getFFlagOnlyGetGeometryOnce() then + for _, part in ipairs(self._workspaceParts) do + -- If the part somehow got removed from the workspace (for instance, + -- by a user plugin) since we added it to the _workspaceParts table, + -- then it won't have a root part. + local root = part:GetRootPart() + if root then + -- We have to track the roots separately, because some of the root parts + -- of the dragged parts may not be in the set of dragged parts. + self._rootPartSet[root] = true + end + end + else + for _, part in ipairs(parts) do + -- We have to track the roots separately, because some of the root parts + -- of the dragged parts may not be in the set of dragged parts. + self._rootPartSet[part:GetRootPart()] = true + end + end +end + +--[[ + Call once per move, setup geometry tracking. +]] +function PartMover:_ensureGeometryTrackingHasBeenSetup() + if not self._hasSetupGeometryTracking then + self:_setupGeometryTracking() + self._hasSetupGeometryTracking = true + end +end + +--[[ + Create an auxiliary part to weld everything to in order to move everything + as a single aggregate from the Lua side. +]] +function PartMover:_createMainPart() + local part = Instance.new("Part") + part.Name = "PartDragMover" + part.Transparency = 1 + part.Archivable = false + part.CanCollide = false + + -- 0 Density so that we don't effect the IK drag weighting + part.CustomPhysicalProperties = + PhysicalProperties.new( + 0, -- Density + 0.3, -- Friction + 0.5 -- Elasticity + ) + + self._mainPart = part + self._originalMainPartCFrame = part.CFrame +end + +function PartMover:_setupMainPart(customCenter) + -- Part must be anchored in run mode, so that physics can't simulate it. + -- However, the part must be UNanchored in normal mode, because IK dragging + -- won't work if the part is anchored. + self._mainPart.Anchored = RunService:IsRunning() + self._originalMainPartCFrame = CFrame.new(customCenter) + self._mainPart.CFrame = self._originalMainPartCFrame + self._mainPart.Parent = Workspace.Terrain + self._partSet[self._mainPart] = true +end + +--[[ + Every moved part is joined together so that we can move the parts as + a single solid body with only one property set from Lua. + If breakJoints is true, break joints to outsiders before doing the drag +]] +function PartMover:_prepareJoints(parts, breakJoints) + self._reenableWeldConstraints = {} + self._adjustAndReenableMotor6Ds = {} + self._alreadyConnectedToSets = {} + local FFlagPreserveMotor6D = getFFlagPreserveMotor6D() + for _, part in ipairs(parts) do + self._alreadyConnectedToSets[part] = {} + for _, joint in ipairs(part:GetJoints()) do + if joint:IsA("JointInstance") then + local other = JointUtil.getJointInstanceCounterpart(joint, part) + if breakJoints then + if not self._partSet[other] then + if FFlagPreserveMotor6D and joint:IsA("Motor6D") then + -- Disable Motor6Ds temporarily and adjust them + -- after the move. Motor6Ds need to feel more + -- "permanent" than other joints. + joint.Enabled = false + self._adjustAndReenableMotor6Ds[joint] = true + self._alreadyConnectedToSets[part][other] = true + else + -- We can't destroy these, otherwise Undo behavior + -- won't be able to put them back in the workspace. + joint.Parent = nil + end + end + else + self._alreadyConnectedToSets[part][other] = true + end + elseif joint:IsA("Constraint") then + -- Constraints don't effect non-IK movement, so + -- we don't have to break or unparent them. + + -- However we do have to record the constraints between + -- parts so that we know what joint pairs are redundant. + local other = JointUtil.getConstraintCounterpart(joint, part) + if other then + -- The if is because some constraints like VectorForce + -- will not have a counterpart. + self._alreadyConnectedToSets[part][other] = true + end + elseif joint:IsA("WeldConstraint") then + local other = JointUtil.getWeldConstraintCounterpart(joint, part) + self._alreadyConnectedToSets[part][other] = true + + if breakJoints then + -- Weld constraints to non-dragged parts need to be disabled, + -- and then re-enabled after the move. Note: To show up in + -- GetJoints, this weld must have been enabled. + if not self._partSet[other] then + joint.Enabled = false + self._reenableWeldConstraints[joint] = true + end + end + elseif joint:IsA("NoCollisionConstraint") then + local other = JointUtil.getNoCollisionConstraintCounterpart(joint, part) + self._alreadyConnectedToSets[part][other] = true + else + error("Unexpected Joint Type: " .. joint.ClassName) + end + end + end +end + +function PartMover:_installMovementWelds() + if self._hasMovementWelds then + return + end + + self:_setupMainPart(self._customCenter) + self._hasMovementWelds = true + self._temporaryJoints = {} + local mainPartCFrameInv = self._originalMainPartCFrame:Inverse() + for _, part in ipairs(self._parts) do + local moveJoint = Instance.new("Weld") + moveJoint.Archivable = false + moveJoint.Name = "Temp Movement Weld" + moveJoint.Part0 = self._mainPart + moveJoint.Part1 = part + moveJoint.C0 = mainPartCFrameInv * part.CFrame + moveJoint.Parent = self._mainPart + table.insert(self._temporaryJoints, moveJoint) + end +end + +-- Find the candidate joints after transforming the dragged parts by `transform` +function PartMover:computeJointPairs(globalTransform) + assert(self._moving) + + if getFFlagOnlyGetGeometryOnce() then + self:_ensureGeometryTrackingHasBeenSetup() + else + if not self._hasSetupGeometryTracking then + self:_setupGeometryTracking(self._workspaceParts) + end + end + + local jointPairs = JointPairs.new(self._parts, self._partSet, self._rootPartSet, + globalTransform, + self._alreadyConnectedToSets, function(part) + -- Note, the part may not be in originalCFrameMap, in which case + -- assumedCFrame = nil, which is the correct value for that case. + local assumedCFrame = self._originalCFrameMap[part] + return self:_getGeometry(part, assumedCFrame) + end) + + if RunService:IsRunning() then + if getFFlagOnlyGetGeometryOnce() then + self:_flushNonDraggedGeometryCache() + else + self:_clearOtherGeometry() + end + end + + return jointPairs +end + +function PartMover:_transformModelPivots(globalTransform) + if self._originalModelPivotMap then + for model, originalPivot in pairs(self._originalModelPivotMap) do + model.WorldPivot = globalTransform * originalPivot + end + end +end + +function PartMover:_transformToImpl(transform, mode) + if self._bulkMoveParts then + local targets = self._bulkMoveTargetCFrames + local originals = self._bulkMoveOriginalCFrames + for i = 1, #self._bulkMoveParts do + targets[i] = transform * originals[i] + end + Workspace:BulkMoveTo(self._bulkMoveParts, targets, mode) + targets = self._moveWithCFrameChangeTargetCFrames + originals = self._moveWithCFrameChangeOriginalCFrames + for i = 1, #self._moveWithCFrameChangeParts do + targets[i] = transform * originals[i] + end + Workspace:BulkMoveTo(self._moveWithCFrameChangeParts, targets, Enum.BulkMoveMode.FireAllEvents) + end + self:_transformModelPivots(transform) +end + +--[[ + The main function to move the parts geometrically. +]] +function PartMover:transformTo(transform) + assert(self._moving) + self._lastTransform = transform + self:_transformToImpl(transform, Enum.BulkMoveMode.FireCFrameChanged) +end + +--[[ + The main function to move the parts via inverse kinematics. +]] +function PartMover:transformToWithIk(transform, translateStiffness, rotateStiffness, collisionsMode) + assert(self._moving) + -- If we have no physical parts, then IK dragging is the same as + -- geometric dragging. (We still may have free parts to move) + if #self._parts == 0 then + self:transformTo(transform) + return transform + end + + -- Make sure the movement welds exist, they are needed to do IK movement + self:_installMovementWelds() + + local targetCFrame = transform * self._originalMainPartCFrame + + pcall(function() + Workspace:IKMoveTo( + self._mainPart, targetCFrame, + translateStiffness, rotateStiffness, + collisionsMode) + end) + local actualCFrame = self._mainPart.CFrame + local actualGlobalTransform = actualCFrame * self._originalMainPartCFrame:Inverse() + + self:_transformModelPivots(actualGlobalTransform) + + -- We need to clear all non-dragged parts cached geometry, since we may + -- have bumped into and moved other parts as part of the IK move. + self:_flushNonDraggedGeometryCache() + + return actualGlobalTransform +end + +function PartMover:moveToWithIk(transform, collisionsMode) + local translateStiffness = DRAG_CONSTRAINT_STIFFNESS + local rotateStiffness = DRAG_CONSTRAINT_LESS_STIFFNESS + return self:transformToWithIk(transform, translateStiffness, rotateStiffness, collisionsMode) +end + +function PartMover:rotateToWithIk(transform, collisionsMode) + local translateStiffness = DRAG_CONSTRAINT_LESS_STIFFNESS + local rotateStiffness = DRAG_CONSTRAINT_STIFFNESS + return self:transformToWithIk(transform, translateStiffness, rotateStiffness, collisionsMode) +end + + +--[[ + Are any of the parts to move intersecting other parts not in the set of + parts to move? +]] +function PartMover:isIntersectingOthers(overlapToIgnore) + assert(self._moving) + if getFFlagOnlyGetGeometryOnce() then + -- Pcall because a user plugin may have removed some of the _workspaceParts + -- from the workspace mid-move, and ArePartsTouchingOthers requires all the + -- passed parts to be in a WorldRoot. + local st, result = pcall(Workspace.ArePartsTouchingOthers, Workspace, + self._workspaceParts, overlapToIgnore or DEFAULT_COLLISION_THRESHOLD) + if st then + return result + else + return true + end + else + return Workspace:ArePartsTouchingOthers(self._workspaceParts, + overlapToIgnore or DEFAULT_COLLISION_THRESHOLD) + end +end + +--[[ + Finish moving parts, and removing all of the temporary joints. +]] +_G.committing = false +function PartMover:commit() + assert(self._moving) + self._moving = false + if self._mainPart then + self._mainPart.Parent = nil + end + if self._reenableWeldConstraints then + for weldConstraint, _ in pairs(self._reenableWeldConstraints) do + weldConstraint.Enabled = true + end + self._reenableWeldConstraints = nil + end + if getFFlagPreserveMotor6D() and self._adjustAndReenableMotor6Ds then + for motor6d, _ in pairs(self._adjustAndReenableMotor6Ds) do + if self._partSet[motor6d.Part0] then + -- Modify C0 + local part0 = motor6d.Part0 + motor6d.C0 = part0.CFrame:Inverse() * self._originalCFrameMap[part0] * motor6d.C0 + else + -- Modify C1 + local part1 = motor6d.Part1 + motor6d.C1 = part1.CFrame:Inverse() * self._originalCFrameMap[part1] * motor6d.C1 + end + motor6d.Enabled = true + end + end + self._facesToHighlightSet = {} + if self._temporaryJoints then + for _, joint in ipairs(self._temporaryJoints) do + joint:Destroy() + end + self._temporaryJoints = {} + end + for part, _ in pairs(self._toUnanchor) do + part.Anchored = false + end + self._toUnanchor = {} + + if self._bulkMoveParts then + self._moveWithCFrameChangeParts = nil + self._moveWithCFrameChangeOriginalCFrames = nil + self._moveWithCFrameChangeTargetCFrames = nil + self._bulkMoveParts = nil + self._bulkMoveOriginalCFrames = nil + self._bulkMoveTargetCFrames = nil + end + + -- Note: We didn't do anything with the freeParts during commit. That's + -- because there's nothing to do with them, they are not simulated in any + -- way, so the changes we already made to their CFrames suffice. +end + +local function augmentGeometry(geometry) + -- Add additional precomputed fields to geometry + -- Nothing to do for now. I needed something in here in the past and for + -- optimizations we will probably want to precompute some things in here. +end + +function PartMover:_getGeometry(part, assumedCFrame) + local geometry = self._nearbyGeometry[part] + if not geometry then + geometry = getGeometry(part, Vector3.new(), assumedCFrame) + self._nearbyGeometry[part] = geometry + augmentGeometry(geometry) + end + return geometry +end + +--[[ + Use this function to clear other part geometry after finishing a frame's + worth of operation. + + In run mode, we can't keep the geometry of parts other than those we're + moving cached between frames, because physics may move those parts. However + we still want to keep them cached _within_ a frame, because multiple parts + may need to check for joints vs a given part's geometry. +]] +if not getFFlagOnlyGetGeometryOnce() then + function PartMover:_clearOtherGeometry() + for part, _ in pairs(self._nearbyGeometry) do + if not self._partSet[part] then + self._nearbyGeometry[part] = nil + end + end + end +end + +function PartMover:_flushNonDraggedGeometryCache() + for part, _ in pairs(self._nearbyGeometry) do + if not self._partSet[part] then + self._nearbyGeometry[part] = nil + end + end +end + +return PartMover \ No newline at end of file diff --git a/src/Utility/SelectionHelper.lua b/src/Utility/SelectionHelper.lua new file mode 100644 index 0000000..894eb4c --- /dev/null +++ b/src/Utility/SelectionHelper.lua @@ -0,0 +1,124 @@ +--[[ + Provides utility functions related to the selection. +]] + +-- Services +local Workspace = game:GetService("Workspace") + +local DraggerFramework = script.Parent.Parent +local shouldDragAsFace = require(DraggerFramework.Utility.shouldDragAsFace) + +local getEngineFeatureModelPivotVisual = require(DraggerFramework.Flags.getEngineFeatureModelPivotVisual) + +local SelectionHelper = {} + +-- Returns: Did the selection change, The new selection, A change hint +function SelectionHelper.updateSelection(selectable, oldSelection, isExclusive, shouldExtendSelection) + local doExtendSelection = shouldExtendSelection + + if not selectable then + if doExtendSelection then + return false, oldSelection + else + local wasOldSelectionNonempty = (#oldSelection > 0) + return wasOldSelectionNonempty, {} + end + end + + if doExtendSelection and not (getEngineFeatureModelPivotVisual() and isExclusive) then + -- Add or remove from the selection when ctrl or shift is held. + local newSelection = {} + local added, removed = {}, {} + local didRemoveSelectableInstance = false + for _, item in ipairs(oldSelection) do + if item == selectable then + didRemoveSelectableInstance = true + else + table.insert(newSelection, item) + end + end + if didRemoveSelectableInstance then + table.insert(removed, selectable) + else + table.insert(newSelection, selectable) + table.insert(added, selectable) + end + return true, newSelection, {Added = added, Removed = removed} + else + local index = table.find(oldSelection, selectable) + if index and not isExclusive then + -- The instance is already in the selection. If the active instance + -- needs to be updated, and the instance isn't already the last item + -- in the list, move it to the end of the selection. + local lastIndex = #oldSelection + if index < lastIndex then + local newSelection = {} + table.move(oldSelection, 1, index, 1, newSelection) + table.move(oldSelection, index + 1, lastIndex, index, newSelection) + newSelection[lastIndex] = selectable + + -- Remove and then add the selectable to push it to + -- the end of the selection. + local hint = {Added = {selectable}, Removed = {selectable}} + return true, newSelection, hint + end + + -- Otherwise, leave the selection alone. + return false, oldSelection + else + -- The instance is not in the selection and the selection is not being + -- extended; overwrite the old selection. + return true, {selectable} + end + end +end + +function SelectionHelper.updateSelectionWithMultipleSelectables( + selectables, oldSelection, shouldXorSelection, shouldExtendSelection) + + if #selectables == 0 then + return (shouldXorSelection or shouldExtendSelection) and oldSelection or {} + end + + local newSelection + if shouldXorSelection or shouldExtendSelection then + newSelection = {} + -- Add or remove from the selection when ctrl or shift is held. + local alreadySelectedInstances = {} + for _, instance in ipairs(oldSelection) do + alreadySelectedInstances[instance] = true + end + + if shouldXorSelection then + local newInstancesToSelect = {} + for _, instance in ipairs(selectables) do + newInstancesToSelect[instance] = true + end + for _, selectable in ipairs(oldSelection) do + if not newInstancesToSelect[selectable] then + table.insert(newSelection, selectable) + end + end + for _, selectable in ipairs(selectables) do + if not alreadySelectedInstances[selectable] then + table.insert(newSelection, selectable) + end + end + elseif shouldExtendSelection then + for _, selectable in ipairs(oldSelection) do + table.insert(newSelection, selectable) + end + for _, selectable in ipairs(selectables) do + if not alreadySelectedInstances[selectable] then + table.insert(newSelection, selectable) + end + end + end + else + -- The selection is not being extended; overwrite the old selection. + newSelection = selectables + end + return newSelection +end + +return SelectionHelper diff --git a/src/Utility/SelectionWrapper.lua b/src/Utility/SelectionWrapper.lua new file mode 100644 index 0000000..5e1b4ce --- /dev/null +++ b/src/Utility/SelectionWrapper.lua @@ -0,0 +1,70 @@ +--[[ + A wrapper around a Selection object (anything with the same API the Roblox + SelectionService has), which disambiguates selection changed events which + were caused by something else setting the selection, vs selection changed + events which were caused by calling :Set on the Selection object. + + Also caches the selection between changes, so that repeated calls to + the get method do not need to check what the underlying selection is. +]] + +local DraggerFramework = script.Parent.Parent +local Signal = require(DraggerFramework.Utility.Signal) + +local SelectionWrapper = {} +SelectionWrapper.__index = SelectionWrapper + +local WRAPPER_COUNT = 0 + +function SelectionWrapper.new(selectionObject) + local self = setmetatable({ + _selectionObject = selectionObject, + _selection = selectionObject:Get(), + _isSettingSelection = false, + _destroyed = false, + }, SelectionWrapper) + + self.onSelectionExternallyChanged = Signal.new() + self._selectionChangedConnection = + selectionObject.SelectionChanged:Connect(function() + self:_handleSelectionChanged() + end) + + WRAPPER_COUNT = WRAPPER_COUNT + 1 + if WRAPPER_COUNT > 1 then + warn("More than one SelectionWrapper created at once, this is probably a mistake!") + end + + return self +end + +function SelectionWrapper:get() + return self._selection +end + +function SelectionWrapper:set(selection, hint) + self._selection = selection + self._isSettingSelection = true + self._selectionObject:Set(selection, hint) + self._isSettingSelection = false +end + +function SelectionWrapper:destroy() + assert(not self._destroyed) + self._selectionChangedConnection:Disconnect() + WRAPPER_COUNT = WRAPPER_COUNT - 1 + self._destroyed = true +end + +function SelectionWrapper:_handleSelectionChanged() + if not self._isSettingSelection then + self._selection = self._selectionObject:Get() + self.onSelectionExternallyChanged:Fire() + end +end + +function SelectionWrapper:getActiveSelectable() + return self._selection[#self._selection] +end + +return SelectionWrapper \ No newline at end of file diff --git a/src/Utility/Signal.lua b/src/Utility/Signal.lua new file mode 100644 index 0000000..e21dcc7 --- /dev/null +++ b/src/Utility/Signal.lua @@ -0,0 +1,250 @@ + +local DraggerFramework = script.Parent.Parent + +-- For now use the DeveloperFramework Lua Signal implementation. I want to +-- fix my implementation and return to using it later when I have time. +if true then + --[[ + A limited, simple implementation of a Signal. + + Handlers are fired in order, and (dis)connections are properly handled when + executing an event. + + Signal uses Immutable to avoid invalidating the 'Fire' loop iteration. + ]] + + local function RemoveFromDictionary(dictionary, ...) + local result = {} + + for key, value in pairs(dictionary) do + local found = false + for listKey = 1, select("#", ...) do + if key == select(listKey, ...) then + found = true + break + end + end + if not found then + result[key] = value + end + end + + return result + end + + local function Append(list, ...) + local new = {} + local len = #list + + for key = 1, len do + new[key] = list[key] + end + + for i = 1, select("#", ...) do + new[len + i] = select(i, ...) + end + + return new + end + + local Signal = {} + + Signal.__index = Signal + + function Signal.new() + local self = { + _listeners = {} + } + + setmetatable(self, Signal) + + return self + end + + function Signal:Connect(callback) + local listener = { + callback = callback, + isConnected = true, + } + self._listeners = Append(self._listeners, listener) + + local function disconnect() + listener.isConnected = false + self._listeners = RemoveFromDictionary(self._listeners, listener) + end + + return { + Disconnect = disconnect, + } + end + + function Signal:Fire(...) + for _, listener in ipairs(self._listeners) do + if listener.isConnected then + listener.callback(...) + end + end + end + + + return Signal +else + -------------------------------------------------------------------------------- + -- Batched Yield-Safe Signal Implementation -- + -- This is a signal class which has almost identical behavior to the -- + -- RBXScriptSignal. -- + -- * The first way it differs from RBXScriptSignal is that it passes event -- + -- arguments by reference instead of by value, so if you fire it with a table -- + -- argument the argument won't be serialized or copied, it will be passed by -- + -- reference. -- + -- * The second way it differs from RBXScriptSignal is that the fire() method -- + -- will raise an exception if an exception is raised synchonously within one -- + -- of the connected event handlers. This gives you a full stack trace of the -- + -- code that caused the exception unlike with RBXScriptSignal. -- + -- * It allows you to yield in event handlers without blocking the fire call, -- + -- fire events with nils in the middle of the event argument list, and -- + -- connect / disconnect events safely during event handlers. -- + -- It also uses efficient batching and flags to avoid creating extra threads. -- + -------------------------------------------------------------------------------- + + -- Helper to unpack and call a variable argument list with a function to call + -- as the first argument in the list. + local function callPacked(fn, ...) + fn(...) + end + + -- Coroutine runner to batch non-yielding event handlers together + local isCoRunnerReady = true + local function fnCoRunner(fn, ...) + fn(...) + isCoRunnerReady = true + while true do + callPacked(coroutine.yield()) + isCoRunnerReady = true + end + end + local coRunnerThread = coroutine.create(fnCoRunner) + + -- Connection class + local Connection = {} + Connection.__index = Connection + + function Connection.new(signal, fn) + return setmetatable({ + _connected = true, + _signal = signal, + _fn = fn, + _next = false + }, Connection) + end + + function Connection:Disconnect() + assert(self._connected, "Can't disconnect a connection twice.", 2) + self._connected = false + + -- Unhook the node, but DON'T clear it. That way any fire calls that are + -- currently sitting on this node will be able to iterate forwards off of + -- it, but any subsequent fire calls will not hit it, and it will be GCed + -- when no more fire calls are sitting on it. + if self._signal._handlerListHead == self then + self._signal._handlerListHead = self._next + else + local prev = self._signal._handlerListHead + while prev and prev._next ~= self do + prev = prev._next + end + if prev then + prev._next = self._next + end + end + end + + -- Make Connection strict + setmetatable(Connection, { + __index = function(key) + error(("Attempt to get Connection::%s (not a valid member)"):format(key), 2) + end, + __newindex = function(key, value) + error(("Attempt to set Connection::%s (not a valid member)"):format(key), 2) + end + }) + + -- Signal class + local Signal = {} + Signal.__index = Signal + + function Signal.new() + return setmetatable({ + _handlerListHead = false + }, Signal) + end + + function Signal:Connect(fn) + local connection = Connection.new(self, fn) + if self._handlerListHead then + connection._next = self._handlerListHead + self._handlerListHead = connection + else + self._handlerListHead = connection + end + return connection + end + + -- Signal::Fire(...) implemented by running the handler functions on the + -- coRunnerThread, and any time the resulting thread yielded without returning + -- to us, that means that it yielded to the Roblox scheduler and has been taken + -- over by Roblox scheduling, meaning we have to make a new coroutine runner. + function Signal:Fire(...) + local item = self._handlerListHead + while item do + if item._connected then + isCoRunnerReady = false + local st, err = coroutine.resume(coRunnerThread, item._fn, ...) + if not isCoRunnerReady then + -- The call handler yielded in Roblox yields, so the Roblox + -- thread scheduler will have "stolen" it. We need a new runner. + coRunnerThread = coroutine.create(fnCoRunner) + isCoRunnerReady = true + + -- Check if we encountered an error in the handler, the error + -- case will cause isCoRunnerReady to definitely be false, so + -- we can do the check in this if body. + -- If we did, throw from here. This is a deviation from what + -- RBXScriptSignal does but it vastily improves debuggability + -- by giving us a stack trace of the code that caused the error. + -- NOTE: We have to do this _after_ creating a new CoRunner + -- because the user may handle the error with pcall and continue + -- using the Signal afterwards. + if not st then + error("Error in event handler: "..err, 2) + end + end + end + item = item._next + end + end + + -- Implement Signal::wait() in terms of a temporary connection using + -- a Signal::connect() which disconnects itself. + function Signal:Wait() + local coCurrentlyRunning = coroutine.running() + local cn; + cn = self:connect(function(...) + cn:Disconnect() + coroutine.resume(coCurrentlyRunning, ...) + end) + return coroutine.yield() + end + + -- Make signal strict + setmetatable(Signal, { + __index = function(key) + error(("Attempt to get Signal::%s (not a valid member)"):format(key), 2) + end, + __newindex = function(key, value) + error(("Attempt to set Signal::%s (not a valid member)"):format(key), 2) + end + }) + + return Signal +end diff --git a/src/Utility/StandardCursor.lua b/src/Utility/StandardCursor.lua new file mode 100644 index 0000000..458b2e0 --- /dev/null +++ b/src/Utility/StandardCursor.lua @@ -0,0 +1,43 @@ +--[[ +This file is a workaround for STUDIOCORE-22344 + +We would like to just use the system cursors for the draggers, however, due to +the above unresolved issue, the system cursors do not work in Play Solo mode. + +To work around this, use the old set of dragger cursors instead while in Play +Solo mode. +]] + +local RunService = game:GetService("RunService") + +local StandardCursor = {} + +local function isPlaySolo() + return RunService:IsRunning() and not RunService:IsRunMode() +end + +function StandardCursor.getArrow() + if isPlaySolo() then + return "rbxasset://textures/advCursor-default.png" + else + return "rbxasset://SystemCursors/Arrow" + end +end + +function StandardCursor.getOpenHand() + if isPlaySolo() then + return "rbxasset://textures/advCursor-openedHand.png" + else + return "rbxasset://SystemCursors/OpenHand" + end +end + +function StandardCursor.getClosedHand() + if isPlaySolo() then + return "rbxasset://textures/advClosed-hand.png" + else + return "rbxasset://SystemCursors/ClosedHand" + end +end + +return StandardCursor \ No newline at end of file diff --git a/src/Utility/TemporaryTransparency.lua b/src/Utility/TemporaryTransparency.lua new file mode 100644 index 0000000..43deb27 --- /dev/null +++ b/src/Utility/TemporaryTransparency.lua @@ -0,0 +1,27 @@ +local NO_COLLISIONS_TRANSPARENCY = 0.4 + +local TemporaryTransparency = {} +TemporaryTransparency.__index = TemporaryTransparency + +function TemporaryTransparency.new(parts) + local self = setmetatable({ + _draggingModifiedParts = {}, + }, TemporaryTransparency) + + for _, part in ipairs(parts) do + if part:IsA("BasePart") then + part.LocalTransparencyModifier = NO_COLLISIONS_TRANSPARENCY + table.insert(self._draggingModifiedParts, part) + end + end + + return self +end + +function TemporaryTransparency:destroy() + for _, part in ipairs(self._draggingModifiedParts) do + part.LocalTransparencyModifier = 0 + end +end + +return TemporaryTransparency \ No newline at end of file diff --git a/src/Utility/ViewChangeDetector.lua b/src/Utility/ViewChangeDetector.lua new file mode 100644 index 0000000..ad1cb9e --- /dev/null +++ b/src/Utility/ViewChangeDetector.lua @@ -0,0 +1,51 @@ +--[[ + Checks whether the current mouse location or camera orientation has changed. + Used to response to the world position under the mouse changing. +]] + +local DraggerFramework = script.Parent.Parent + +local getFFlagMoreLuaDraggerFixes = require(DraggerFramework.Flags.getFFlagMoreLuaDraggerFixes) + +local Workspace = game:GetService("Workspace") + +local ViewChangeDetector = {} +ViewChangeDetector.__index = ViewChangeDetector + +function ViewChangeDetector.new(mouse) + if getFFlagMoreLuaDraggerFixes() then + local currentCamera = Workspace.CurrentCamera + return setmetatable({ + _mouse = mouse, + _lastCameraCFrame = currentCamera and currentCamera.CFrame or CFrame.new(), + _lastMouseX = mouse.X, + _lastMouseY = mouse.Y, + }, ViewChangeDetector) + else + return setmetatable({ + _mouse = mouse, + _lastCameraCFrame = Workspace.CurrentCamera.CFrame, + _lastMouseX = mouse.X, + _lastMouseY = mouse.Y, + }, ViewChangeDetector) + end +end + +function ViewChangeDetector:poll() + local camera = Workspace.CurrentCamera + if getFFlagMoreLuaDraggerFixes() and not camera then + return false + end + + local mouse = self._mouse + + if (self._lastCameraCFrame ~= camera.CFrame) or (self._lastMouseX ~= mouse.X) or (self._lastMouseY ~= mouse.Y) then + self._lastCameraCFrame = camera.CFrame + self._lastMouseX = mouse.X + self._lastMouseY = mouse.Y + return true + end + return false +end + +return ViewChangeDetector diff --git a/src/Utility/assertGoodCFrame.lua b/src/Utility/assertGoodCFrame.lua new file mode 100644 index 0000000..b78e7f5 --- /dev/null +++ b/src/Utility/assertGoodCFrame.lua @@ -0,0 +1,48 @@ +--[[ + Check that a CFrame has "good" values. That is: + * None of the values are NaN + * The position values are not particularly large + * The rotation matrix is orthonormal + + This is a general catchall to be used for DEBUGGING ONLY to investigate + CFrames becoming corrupted in some way. There are situations where a user + might legitimately want to have a CFrame with position values larger than + the TEST_AREA_RADIUS, so this should not be called in production code. +]] + +-- How big is the test place you're testing in? +local TEST_AREA_RADIUS = 10000 + +-- How close to orthonormal does the CFrame need to be? +local ORTHONORMAL_EPSILON = 0.001 + +return function(cframe) + local x, y, z, + d, e, f, + g, h, i, + j, k, l = cframe:GetComponents() + -- Note: For IEEE floating point NaNs, NaN == NaN is false. + -- We use that here to check for NaNs. + if x ~= x or y ~= y or z ~= z or + d ~= d or e ~= e or f ~= f or + g ~= g or h ~= h or i ~= i or + j ~= j or k ~= k or l ~= l then + error("Bad CFrame: "..tostring(cframe)) + end + if math.abs(x) + math.abs(y) + math.abs(z) > TEST_AREA_RADIUS * 3 then + error("Big CFrame: "..tostring(cframe)) + end + local right = cframe.RightVector + local top = cframe.UpVector + local back = cframe.LookVector + if math.abs(right:Dot(top)) > ORTHONORMAL_EPSILON or + math.abs(top:Dot(back)) > ORTHONORMAL_EPSILON or + math.abs(back:Dot(right)) > ORTHONORMAL_EPSILON then + error("Non orthogonal CFrame: "..tostring(cframe)) + end + if math.abs(1 - right.Magnitude) > ORTHONORMAL_EPSILON or + math.abs(1 - top.Magnitude) > ORTHONORMAL_EPSILON or + math.abs(1 - back.Magnitude) > ORTHONORMAL_EPSILON then + error("Non unitary units: "..tostring(cframe)) + end +end \ No newline at end of file diff --git a/src/Utility/classifyPivot.lua b/src/Utility/classifyPivot.lua new file mode 100644 index 0000000..ed2bc04 --- /dev/null +++ b/src/Utility/classifyPivot.lua @@ -0,0 +1,35 @@ +local ZERO_VECTOR = Vector3.new() +local PIVOT_NEAR_EDGE_THRESHOLD = 0.01 + +return function(cframe, offset, size) + if offset:FuzzyEq(ZERO_VECTOR, PIVOT_NEAR_EDGE_THRESHOLD) then + return "Center" + end + + local absOffset = Vector3.new(math.abs(offset.X), math.abs(offset.Y), math.abs(offset.Z)) + local halfSize = size / 2 + local howFarOutside = absOffset - halfSize + + local isInside = + howFarOutside.X < PIVOT_NEAR_EDGE_THRESHOLD and + howFarOutside.Y < PIVOT_NEAR_EDGE_THRESHOLD and + howFarOutside.Z < PIVOT_NEAR_EDGE_THRESHOLD + + if isInside then + if math.abs(howFarOutside.X) < PIVOT_NEAR_EDGE_THRESHOLD or + math.abs(howFarOutside.Y) < PIVOT_NEAR_EDGE_THRESHOLD or + math.abs(howFarOutside.Z) < PIVOT_NEAR_EDGE_THRESHOLD then + return "Surface" + else + return "Inside" + end + else + local fractionOutVector = howFarOutside / size + local fractionOut = math.max(fractionOutVector.X, fractionOutVector.Y, fractionOutVector.Z) + if fractionOut > 1 then + return "Far" + else + return "Outside" + end + end +end \ No newline at end of file diff --git a/src/Utility/computeDraggedDistance.lua b/src/Utility/computeDraggedDistance.lua new file mode 100644 index 0000000..53b1cc9 --- /dev/null +++ b/src/Utility/computeDraggedDistance.lua @@ -0,0 +1,40 @@ +local DraggerFramework = script.Parent.Parent +local Math = require(DraggerFramework.Utility.Math) + +--[[ + Returns the distance the mouse cursor was dragged from dragStartPosition + along dragDirection + + This is non-trivial when the cursor gets away from the axis on the screen. + Let p be the start of the handle, u be the direction of the handle, and eye + be the origin of the mouse ray (eye of the camera). + + Let v be the unit vector from p pointing to the eye, and let w = v x u. + + Then v is normal to plane defined by points eye, p and the vector u. + |v| = 0 if and only if the handle projects to a dot on the screen. + + Let cur be where the mouse ray intersects the plane at p spanned by u, v. + Find the normal projection of cur onto the handle ray, and use that to + compute the dragged distance. +]] +local function computeDraggedDistance(dragStartPosition, dragDirection, mouseRay) + local eye = mouseRay.Origin + local ray = mouseRay.Direction.Unit + local handleToEyeDirection = (eye - dragStartPosition).Unit + local eyePlaneNormal = dragDirection:Cross(handleToEyeDirection) + -- the handle axis projects to a point, can't compute drag distance + if eyePlaneNormal:Dot(eyePlaneNormal) < 0.0001 then + return false + end + local dragPlaneNormal = (dragDirection:Cross(eyePlaneNormal)).Unit + -- mouse ray nearly parallel to drag axis, halt drag before sending part to infinity + if ray:Dot(dragPlaneNormal) < 0.0001 then + return false + end + local cursorPosition = Math.intersectRayPlanePoint(eye, ray, dragStartPosition, dragPlaneNormal) + local draggedDistance = (cursorPosition - dragStartPosition):Dot(dragDirection) + return true, draggedDistance +end + +return computeDraggedDistance \ No newline at end of file diff --git a/src/Utility/getBoundingBoxScale.lua b/src/Utility/getBoundingBoxScale.lua new file mode 100644 index 0000000..3591c44 --- /dev/null +++ b/src/Utility/getBoundingBoxScale.lua @@ -0,0 +1,21 @@ +local BoundingBoxCorners = { + Vector3.new(0.5, 0.5, 0.5), + Vector3.new(-0.5, 0.5, 0.5), + Vector3.new(0.5, -0.5, 0.5), + Vector3.new(-0.5, -0.5, 0.5), + Vector3.new(0.5, 0.5, -0.5), + Vector3.new(-0.5, 0.5, -0.5), + Vector3.new(0.5, -0.5, -0.5), + Vector3.new(-0.5, -0.5, -0.5), +} + +local function getBoundingBoxScale(draggerContext, cframe, size) + local minScale = math.huge + for _, relativeCorner in ipairs(BoundingBoxCorners) do + local globalCorner = cframe:PointToWorldSpace(size * relativeCorner) + minScale = math.min(minScale, draggerContext:getHandleScale(globalCorner)) + end + return minScale +end + +return getBoundingBoxScale diff --git a/src/Utility/getFaceInstance.lua b/src/Utility/getFaceInstance.lua new file mode 100644 index 0000000..ddce1d1 --- /dev/null +++ b/src/Utility/getFaceInstance.lua @@ -0,0 +1,33 @@ + +--[[ + Get the FaceInstance (Decal or Texture) on a given part closest to a given + position. +]] + +local function getNormalId(normalizedPosition) + local x = math.abs(normalizedPosition.X) + local y = math.abs(normalizedPosition.Y) + local z = math.abs(normalizedPosition.Z) + if x > y and x > z then + return normalizedPosition.X > 0 and Enum.NormalId.Right or Enum.NormalId.Left + elseif y > z then + return normalizedPosition.Y > 0 and Enum.NormalId.Top or Enum.NormalId.Bottom + else + return normalizedPosition.Z > 0 and Enum.NormalId.Back or Enum.NormalId.Front + end +end + +local function getFaceInstance(part, position) + local localPosition = part.CFrame:PointToObjectSpace(position) + local normalizedPosition = localPosition / part.Size * 2 + local face = getNormalId(normalizedPosition) + + for _, child in pairs(part:GetChildren()) do + if child:IsA("FaceInstance") and child.Face == face then + return child + end + end + return nil +end + +return getFaceInstance \ No newline at end of file diff --git a/src/Utility/getGeometry.lua b/src/Utility/getGeometry.lua new file mode 100644 index 0000000..2dd6536 --- /dev/null +++ b/src/Utility/getGeometry.lua @@ -0,0 +1,236 @@ +local UniformScale = Vector3.new(1, 1, 1) + +local function getShape(part) + if part:IsA('WedgePart') then + return 'Wedge', UniformScale + elseif part:IsA('CornerWedgePart') then + return 'CornerWedge', UniformScale + elseif part:IsA('Terrain') then + return 'Terrain', UniformScale + elseif part:IsA('UnionOperation') then + return 'Brick', UniformScale + elseif part:IsA('MeshPart') then + return 'Brick', UniformScale + elseif part:IsA('Part') then + -- BasePart + if part.Shape == Enum.PartType.Ball then + return 'Sphere', UniformScale + elseif part.Shape == Enum.PartType.Cylinder then + return 'Cylinder', UniformScale + elseif part.Shape == Enum.PartType.Block then + return 'Brick', UniformScale + elseif part.Shape == Enum.PartType.CornerWedge then + return 'CornerWedge', UniformScale + elseif part.Shape == Enum.PartType.Wedge then + return 'Wedge', UniformScale + else + assert(false, "Unreachable") + end + else + return 'Brick', UniformScale + end +end + +return function(part, hit, assumedCFrame) + local cf = assumedCFrame or part.CFrame + local pos = cf.p + + local sx = part.Size.x/2 + local sy = part.Size.y/2 + local sz = part.Size.z/2 + + local xvec = cf.RightVector + local yvec = cf.UpVector + local zvec = -cf.LookVector + + local verts, edges, faces; + + local shape, scale = getShape(part) + + sx = sx * scale.X + sy = sy * scale.Y + sz = sz * scale.Z + + if shape == 'Brick' or shape == 'Sphere' or shape == 'Cylinder' then + --8 vertices + verts = { + pos +xvec*sx +yvec*sy +zvec*sz, --top 4 + pos +xvec*sx +yvec*sy -zvec*sz, + pos -xvec*sx +yvec*sy +zvec*sz, + pos -xvec*sx +yvec*sy -zvec*sz, + -- + pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 + pos +xvec*sx -yvec*sy -zvec*sz, + pos -xvec*sx -yvec*sy +zvec*sz, + pos -xvec*sx -yvec*sy -zvec*sz, + } + --12 edges + edges = { + {verts[1], verts[2], math.min(2*sx, 2*sy)}, --top 4 + {verts[3], verts[4], math.min(2*sx, 2*sy)}, + {verts[1], verts[3], math.min(2*sy, 2*sz)}, + {verts[2], verts[4], math.min(2*sy, 2*sz)}, + -- + {verts[5], verts[6], math.min(2*sx, 2*sy)}, --bottom 4 + {verts[7], verts[8], math.min(2*sx, 2*sy)}, + {verts[5], verts[7], math.min(2*sy, 2*sz)}, + {verts[6], verts[8], math.min(2*sy, 2*sz)}, + -- + {verts[1], verts[5], math.min(2*sx, 2*sz)}, --verticals + {verts[2], verts[6], math.min(2*sx, 2*sz)}, + {verts[3], verts[7], math.min(2*sx, 2*sz)}, + {verts[4], verts[8], math.min(2*sx, 2*sz)}, + } + --6 faces + faces = { + {verts[1], xvec, 'RightSurface', zvec, {verts[5], verts[6], verts[2], verts[1]}}, --right + {verts[3], -xvec, 'LeftSurface', zvec, {verts[3], verts[4], verts[8], verts[7]}}, --left + {verts[1], yvec, 'TopSurface', xvec, {verts[1], verts[2], verts[4], verts[3]}}, --top + {verts[5], -yvec, 'BottomSurface', xvec, {verts[7], verts[8], verts[6], verts[5]}}, --bottom + {verts[1], zvec, 'BackSurface', xvec, {verts[1], verts[3], verts[7], verts[5]}}, --back + {verts[2], -zvec, 'FrontSurface', xvec, {verts[6], verts[8], verts[4], verts[2]}}, --front + } + elseif shape == 'Sphere' or shape == 'Cylinder' then + -- Just have one face and vertex, at the hit pos + verts = { hit } + edges = {} --edge can be selected as the normal of the face if the user needs it + local norm = (hit-pos).Unit + local norm2 = norm:Cross(Vector3.new(0,1,0)).Unit + + local surfaceName + if math.abs(norm.X) > math.abs(norm.Y) and math.abs(norm.X) > math.abs(norm.Z) then + surfaceName = (norm.X > 0) and "RightSurface" or "LeftSurface" + elseif math.abs(norm.Y) > math.abs(norm.Z) then + surfaceName = (norm.Y > 0) and "TopSurface" or "BottomSurface" + else + surfaceName = (norm.Z > 0) and "BackSurface" or "FrontSurface" + end + faces = { + {hit, norm, surfaceName, norm2, {}} + } + elseif shape == 'CornerWedge' then + local slantVec1 = ( zvec*sy + yvec*sz).Unit + local slantVec2 = (-xvec*sy + yvec*sx).Unit + -- 5 verts + verts = { + pos +xvec*sx +yvec*sy -zvec*sz, --top 1 + -- + pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 + pos +xvec*sx -yvec*sy -zvec*sz, + pos -xvec*sx -yvec*sy +zvec*sz, + pos -xvec*sx -yvec*sy -zvec*sz, + } + -- 8 edges + edges = { + {verts[2], verts[3], 0}, -- bottom 4 + {verts[3], verts[5], 0}, + {verts[5], verts[4], 0}, + {verts[4], verts[2], 0}, + -- + {verts[1], verts[3], 0}, -- vertical + -- + {verts[1], verts[2], 0}, -- side diagonals + {verts[1], verts[5], 0}, + -- + {verts[1], verts[4], 0}, -- middle diagonal + } + -- 5 faces + faces = { + {verts[2], -yvec, 'BottomSurface', xvec, {verts[2], verts[3], verts[5], verts[4]}}, -- bottom + -- + {verts[1], xvec, 'RightSurface', -yvec, {verts[1], verts[3], verts[2]}}, -- sides + {verts[1], -zvec, 'FrontSurface', -yvec, {verts[1], verts[3], verts[5]}}, + -- + {verts[1], slantVec1, 'BackSurface', xvec, {verts[1], verts[2], verts[4]}}, -- tops + {verts[1], slantVec2, 'LeftSurface', zvec, {verts[1], verts[5], verts[4]}}, + } + + elseif shape == 'Wedge' then + local slantVec = (-zvec*sy + yvec*sz).Unit + --6 vertices + verts = { + pos +xvec*sx +yvec*sy +zvec*sz, --top 2 + pos -xvec*sx +yvec*sy +zvec*sz, + -- + pos +xvec*sx -yvec*sy +zvec*sz, --bottom 4 + pos +xvec*sx -yvec*sy -zvec*sz, + pos -xvec*sx -yvec*sy +zvec*sz, + pos -xvec*sx -yvec*sy -zvec*sz, + } + --9 edges + edges = { + {verts[1], verts[2], math.min(2*sy, 2*sz)}, --top 1 + -- + {verts[1], verts[4], math.min(2*sy, 2*sz)}, --slanted 2 + {verts[2], verts[6], math.min(2*sy, 2*sz)}, + -- + {verts[3], verts[4], math.min(2*sx, 2*sy)}, --bottom 4 + {verts[5], verts[6], math.min(2*sx, 2*sy)}, + {verts[3], verts[5], math.min(2*sy, 2*sz)}, + {verts[4], verts[6], math.min(2*sy, 2*sz)}, + -- + {verts[1], verts[3], math.min(2*sx, 2*sz)}, --vertical 2 + {verts[2], verts[5], math.min(2*sx, 2*sz)}, + } + --5 faces + faces = { + {verts[1], xvec, 'RightSurface', zvec, {verts[4], verts[1], verts[3]}}, --right + {verts[2], -xvec, 'LeftSurface', zvec, {verts[2], verts[6], verts[5]}}, --left + {verts[3], -yvec, 'BottomSurface', xvec, {verts[5], verts[6], verts[4], verts[3]}}, --bottom + {verts[1], zvec, 'BackSurface', xvec, {verts[1], verts[2], verts[5], verts[3]}}, --back + {verts[2], slantVec, 'FrontSurface', slantVec:Cross(xvec), {verts[2], verts[1], verts[4], verts[6]}}, --slanted + } + elseif shape == 'Terrain' then + assert(false, "Called GetGeometry on Terrain") + else + assert(false, "Bad shape: "..shape) + end + + local geometry = { + part = part; + shape = (shape == 'Sphere' or shape == 'Cylinder') and shape or 'Mesh'; + vertices = verts; + edges = edges; + faces = faces; + vertexMargin = math.min(sx, sy, sz) * 2; + } + + local geomId = 0 + + for _, dat in ipairs(faces) do + geomId = geomId + 1 + dat.id = geomId + dat.point = dat[1] + dat.normal = dat[2] + dat.surface = dat[3] + dat.direction = dat[4] + dat.vertices = dat[5] + dat.part = part + dat.type = 'Face' + --avoid Event bug (if both keys + indicies are present keys are discarded when passing tables) + dat[1], dat[2], dat[3], dat[4] = nil, nil, nil, nil + end + for _, dat in ipairs(edges) do + geomId = geomId + 1 + dat.id = geomId + dat.a, dat.b = dat[1], dat[2] + dat.direction = (dat.b - dat.a).Unit + dat.length = (dat.b - dat.a).Magnitude + dat.edgeMargin = dat[3] + dat.part = part + dat.vertexMargin = geometry.vertexMargin + dat.type = 'Edge' + --avoid Event bug (if both keys + indicies are present keys are discarded when passing tables) + dat[1], dat[2], dat[3] = nil, nil, nil + end + for i, dat in ipairs(verts) do + geomId = geomId + 1 + verts[i] = { + position = dat; + id = geomId; + type = 'Vertex'; + } + end + + return geometry +end \ No newline at end of file diff --git a/src/Utility/isProtectedInstance.lua b/src/Utility/isProtectedInstance.lua new file mode 100644 index 0000000..4b1c594 --- /dev/null +++ b/src/Utility/isProtectedInstance.lua @@ -0,0 +1,30 @@ + +--[[ + isProtectedInstance( instance ): Return true for instances which are + "protected" by Roblox. Accessing any method or property on these instances + will raise an error. + + Internally use a weak hash map to cache the protection status of the + instances, as determining the protection status is somewhat expensive. +]] + +local WeakIsProtectedCache = setmetatable({}, {__mode = "k"}) + +local function emptyErrorHandler() +end +local function safetyCheckerFunction(instance) + return instance.Name +end + +local function isProtectedInstance(instance) + local isProtected = WeakIsProtectedCache[instance] + if isProtected == nil then + -- Use xpcall even though we don't need the error handler because + -- xpcall is much faster than pcall on Roblox. + isProtected = not xpcall(safetyCheckerFunction, emptyErrorHandler, instance) + WeakIsProtectedCache[instance] = isProtected + end + return isProtected +end + +return isProtectedInstance \ No newline at end of file diff --git a/src/Utility/roundRotation.lua b/src/Utility/roundRotation.lua new file mode 100644 index 0000000..8bc37eb --- /dev/null +++ b/src/Utility/roundRotation.lua @@ -0,0 +1,22 @@ +--[[ + roundRotation(CFrame) -> CFrame + + Round a rotation CFrame which is approximately primary axis aligned to be + exactly primary axis aligned instead (such that all of its components are + -1, 0, or 1). + + Will not work on an arbitrary CFrame! This function is intended to be used + on CFrames which within a small amount of floating point error of already + being aligned. +]] +return function(cframe) + local x, y, z, + r0, r1, r2, + r3, r4, r5, + r6, r7, r8 = cframe:components() + assert(x == 0 and y == 0 and z == 0) + return CFrame.new(0, 0, 0, + math.floor(r0 + 0.5), math.floor(r1 + 0.5), math.floor(r2 + 0.5), + math.floor(r3 + 0.5), math.floor(r4 + 0.5), math.floor(r5 + 0.5), + math.floor(r6 + 0.5), math.floor(r7 + 0.5), math.floor(r8 + 0.5)) +end \ No newline at end of file diff --git a/src/Utility/setInsertPoint.lua b/src/Utility/setInsertPoint.lua new file mode 100644 index 0000000..43314dd --- /dev/null +++ b/src/Utility/setInsertPoint.lua @@ -0,0 +1,10 @@ +-- Wrapper around WorldRoot::SetInsertPoint, since that function is Roblox only, +-- but we want the DraggerFramework to be forkable. + +local Workspace = game:GetService("Workspace") + +return function(insertPoint) + pcall(function() + Workspace:SetInsertPoint(insertPoint, true) + end) +end \ No newline at end of file diff --git a/src/Utility/shouldDragAsFace.lua b/src/Utility/shouldDragAsFace.lua new file mode 100644 index 0000000..7b59434 --- /dev/null +++ b/src/Utility/shouldDragAsFace.lua @@ -0,0 +1,3 @@ +return function(instance) + return instance:IsA("FaceInstance") or instance:IsA("VideoFrame") or instance:IsA("SurfaceGui") +end \ No newline at end of file diff --git a/src/Utility/snapRotationToPrimaryDirection.lua b/src/Utility/snapRotationToPrimaryDirection.lua new file mode 100644 index 0000000..925509a --- /dev/null +++ b/src/Utility/snapRotationToPrimaryDirection.lua @@ -0,0 +1,72 @@ +local DraggerFramework = script.Parent.Parent + +local getFFlagOnlyGetGeometryOnce = require(DraggerFramework.Flags.getFFlagOnlyGetGeometryOnce) + +local PrimaryDirections = { + Vector3.new(1, 0, 0), + Vector3.new(-1, 0, 0), + Vector3.new(0, 1, 0), + Vector3.new(0, -1, 0), + Vector3.new(0, 0, 1), + Vector3.new(0, 0, -1) +} + +local function largestComponent(vector) + return math.max(math.abs(vector.X), math.abs(vector.Y), math.abs(vector.Z)) +end + +local function snapVectorToPrimaryDirection(direction) + local largestDot = -math.huge + local closestDirection + if getFFlagOnlyGetGeometryOnce() then + -- Start with direction as an escape hatch in case of Inf/NaN + closestDirection = direction + end + for _, target in ipairs(PrimaryDirections) do + local dot = direction:Dot(target) + if dot > largestDot then + largestDot = dot + closestDirection = target + end + end + return closestDirection +end + +return function(cframe) + local right = cframe.RightVector + local top = cframe.UpVector + local front = -cframe.LookVector + local largestRight = largestComponent(right) + local largestTop = largestComponent(top) + local largestFront = largestComponent(front) + if largestRight > largestTop and largestRight > largestFront then + -- Most aligned axis is X, the right, preserve that + right = snapVectorToPrimaryDirection(right) + if largestTop > largestFront then + top = snapVectorToPrimaryDirection(top) + else + local front = snapVectorToPrimaryDirection(front) + top = front:Cross(right).Unit + end + elseif largestTop > largestFront then + -- Most aligned axis is Y, the top, preserve that + top = snapVectorToPrimaryDirection(top) + if largestRight > largestFront then + right = snapVectorToPrimaryDirection(right) + else + local front = snapVectorToPrimaryDirection(front) + right = top:Cross(front).Unit + end + else + -- Most aligned axis is Z, the front, preserve that + local front = snapVectorToPrimaryDirection(front) + if largestRight > largestTop then + right = snapVectorToPrimaryDirection(right) + top = front:Cross(right).Unit + else + top = snapVectorToPrimaryDirection(top) + right = top:Cross(front).Unit + end + end + return CFrame.fromMatrix(Vector3.new(), right, top) +end \ No newline at end of file diff --git a/src/init.lua b/src/init.lua new file mode 100644 index 0000000..07d7c2d --- /dev/null +++ b/src/init.lua @@ -0,0 +1 @@ +return script \ No newline at end of file diff --git a/wally.lock b/wally.lock new file mode 100644 index 0000000..b4163ba --- /dev/null +++ b/wally.lock @@ -0,0 +1,8 @@ +# This file is automatically @generated by Wally. +# It is not intended for manual editing. +registry = "test" + +[[package]] +name = "stravant/draggerframework" +version = "0.1.0" +dependencies = [] diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..a21d264 --- /dev/null +++ b/wally.toml @@ -0,0 +1,10 @@ +[package] +name = "stravant/draggerframework" +description = "Copy of DraggerFramework from Roblox Studio" +version = "0.1.4" +license = "None" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies] +Roact = "roblox/roact@1.4.4"