-- Riggler Studio plugin 3.1 -- Your Riggler account inside Roblox Studio: generate motion from text, open anything -- from your history, library or the Explore catalogue, watch it in the widget or play it -- natively on the selected rig, and apply it as a KeyframeSequence fitted to that rig. -- -- Install: save as %LOCALAPPDATA%\Roblox\Plugins\Riggler.lua and restart Studio. -- Sign in: press "Sign in with your browser" and enter the code on /link while -- signed in to the Riggler website. The plugin receives a token of its own; no -- password is ever typed into Studio. HTTP requests must be allowed for the plugin. -- -- The motion contract, shared with the server and the browser preview: -- bones[Part].rotation = q = D_parent^-1 * D_Part (x, y, z, w), rig axes -- bones.Root.position = LowerTorso offset from bind, metres, rig axes -- D is each part's rotation away from the R15 rest pose (arms down, facing -Z). Each joint -- turns that into Pose.CFrame through its own bind frame, so one file lands correctly on -- Motor6D rigs, AnimationConstraint rigs, rigs with rotated joint attachments and, with -- rest-pose compensation, rigs that rest in an A-pose or T-pose. Soles stay out of the -- floor, and with the motion's contact flags two-bone IK on the rig pins planted feet and -- brings together hands that hold one thing. local HttpService = game:GetService("HttpService") local Selection = game:GetService("Selection") local ServerStorage = game:GetService("ServerStorage") local StudioService = game:GetService("StudioService") local ChangeHistoryService = game:GetService("ChangeHistoryService") local RunService = game:GetService("RunService") local Players = game:GetService("Players") local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider") local VERSION = "4.7" local FORMAT = "motionforge.r15.v2" local PACK_FORMAT = "riggler.pack.v1" local PARTS = { "LowerTorso", "UpperTorso", "Head", "LeftUpperArm", "LeftLowerArm", "LeftHand", "RightUpperArm", "RightLowerArm", "RightHand", "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "RightUpperLeg", "RightLowerLeg", "RightFoot", } local CONTRACT_PARENT = { UpperTorso = "LowerTorso", Head = "UpperTorso", LeftUpperArm = "UpperTorso", LeftLowerArm = "LeftUpperArm", LeftHand = "LeftLowerArm", RightUpperArm = "UpperTorso", RightLowerArm = "RightUpperArm", RightHand = "RightLowerArm", LeftUpperLeg = "LowerTorso", LeftLowerLeg = "LeftUpperLeg", LeftFoot = "LeftLowerLeg", RightUpperLeg = "LowerTorso", RightLowerLeg = "RightUpperLeg", RightFoot = "RightLowerLeg", } -- R6 keeps one rigid part per limb. A clip folded for it says so, and everything below reads -- the list and the hierarchy off the clip instead of assuming fifteen parts. local R6 = { parts = { "Torso", "Head", "Left Arm", "Right Arm", "Left Leg", "Right Leg" }, parent = { Head = "Torso", ["Left Arm"] = "Torso", ["Right Arm"] = "Torso", ["Left Leg"] = "Torso", ["Right Leg"] = "Torso" }, } -- R15 or R6, read off the rig rather than trusted from its Humanoid, because plenty of rigs -- in the wild carry the wrong RigType. Hung on the R6 table: the main chunk is at Luau's limit -- of 200 locals and a new top-level name there stops the whole plugin loading. function R6.kindOf(model) if not model then return nil end if model:FindFirstChild("UpperTorso") or model:FindFirstChild("LowerTorso") then return "R15" end if model:FindFirstChild("Torso") and model:FindFirstChild("Left Arm") then return "R6" end local humanoid = model:FindFirstChildOfClass("Humanoid") if humanoid and humanoid.RigType == Enum.HumanoidRigType.R6 then return "R6" end return "R15" end local function partsOf(motion) return (motion and motion.rig == "R6") and R6.parts or PARTS end local function parentOf(motion, name) if motion and motion.rig == "R6" then return R6.parent[name] end return CONTRACT_PARENT[name] end -- studs per metre when the rig has no measurable legs (stock R15 leg height / SMPL hip height) local FALLBACK_STUDS_PER_METRE = 2.0 ------------------------------------------------------------------ settings and session local SETTINGS_KEY, SESSION_KEY = "Riggler.settings.v3", "Riggler.session.v3" local DEFAULTS = { server = "http://127.0.0.1:8001", ik = true, floor = true, restPose = true, mirror = false, inPlace = false, speed = 1, priority = "Action", loop = "Auto", animSaves = true, reduce = 1, } local settings = {} local storedSettings = plugin:GetSetting(SETTINGS_KEY) or plugin:GetSetting("MotionForge.settings.v3") for key, value in pairs(DEFAULTS) do local stored = nil if type(storedSettings) == "table" then stored = storedSettings[key] end if stored ~= nil and type(stored) == type(value) then settings[key] = stored else settings[key] = value end end local function cleanServer(url) url = tostring(url or ""):gsub("%s+", ""):gsub("/+$", "") if url == "" then url = DEFAULTS.server end if not url:match("^https?://") then url = "http://" .. url end return url end settings.server = cleanServer(settings.server) local function saveSettings() plugin:SetSetting(SETTINGS_KEY, settings) end -- A copy that ran under the old name kept its session and settings under the old keys. -- Read those when the new ones are empty, so nobody has to sign Studio in again; the -- first save writes them back under the new name. local session = plugin:GetSetting(SESSION_KEY) or plugin:GetSetting("MotionForge.session.v3") if type(session) ~= "table" or type(session.token) ~= "string" then session = nil end ------------------------------------------------------------------ the rig local function rotationFrom(q) if type(q) ~= "table" then return CFrame.identity end local x, y, z, w = q[1] or 0, q[2] or 0, q[3] or 0, q[4] or 1 local n = math.sqrt(x * x + y * y + z * z + w * w) if n < 1e-9 then return CFrame.identity end return CFrame.new(0, 0, 0, x / n, y / n, z / n, w / n) end -- Every joint, keyed by the part it drives. Motor6D and AnimationConstraint -- expose different members; asking an AnimationConstraint for Part1 or C1 -- throws, so each class is read through its own API. local function readRig(rig) local hrp = rig:FindFirstChild("HumanoidRootPart") if not (hrp and hrp:IsA("BasePart")) then error("The selected rig has no HumanoidRootPart", 0) end local joints = {} for _, item in ipairs(rig:GetDescendants()) do local part0, part1, c0, c1 if item:IsA("Motor6D") then part0, part1, c0, c1 = item.Part0, item.Part1, item.C0, item.C1 elseif item:IsA("AnimationConstraint") then local a0, a1 = item.Attachment0, item.Attachment1 if a0 and a1 then part0, part1, c0, c1 = a0.Parent, a1.Parent, a0.CFrame, a1.CFrame end end if part0 and part1 and part0:IsA("BasePart") and part1:IsA("BasePart") and part1:IsDescendantOf(rig) then joints[part1.Name] = { part0 = part0.Name, c0 = c0, c1 = c1, instance = item } end end -- Bind pose relative to the root, built from joint offsets alone -- (Part1 = Part0 * C0 * Transform * C1:Inverse(), Transform identity at bind), -- so a rig left posed by an earlier preview still reads its true bind. local bind = { [hrp.Name] = CFrame.identity } local function bindOf(name, depth) if bind[name] then return bind[name] end local joint = joints[name] if not joint or depth > 32 then return nil end local parent = bindOf(joint.part0, depth + 1) if not parent then return nil end bind[name] = parent * joint.c0 * joint.c1:Inverse() return bind[name] end for name in pairs(joints) do bindOf(name, 0) end return hrp.Name, joints, bind end local function pivotsAtBind(joints, bind) local pivotBind = {} for name, joint in pairs(joints) do local parent = bind[joint.part0] if parent then pivotBind[name] = (parent * joint.c0).Position end end return pivotBind end -- Hip pivot to ankle pivot, the counterpart of the motion's source_hip_height_m. local function legHeight(joints, bind) local function pivot(name) local joint = joints[name] local parent = joint and bind[joint.part0] return parent and (parent * joint.c0).Position end local hips, left, right = pivot("LowerTorso"), pivot("LeftFoot"), pivot("RightFoot") if hips and left and right then return hips.Y - (left.Y + right.Y) / 2 end return nil end -- A rig that rests in an A-pose or T-pose would carry that offset into every pose, because -- the contract's identity is the R15 rest (limbs hanging straight, torso upright). Each such -- segment is swung back to the R15 rest direction first; stock rigs are left untouched. -- Every segment runs from one joint pivot to the next, so a hand or head whose part centre -- sits off its joint is never mistaken for an A-pose. local REST_SEGMENTS = { LowerTorso = { "UpperTorso", Vector3.new(0, 1, 0) }, UpperTorso = { "Head", Vector3.new(0, 1, 0) }, LeftUpperArm = { "LeftLowerArm", Vector3.new(0, -1, 0) }, LeftLowerArm = { "LeftHand", Vector3.new(0, -1, 0) }, RightUpperArm = { "RightLowerArm", Vector3.new(0, -1, 0) }, RightLowerArm = { "RightHand", Vector3.new(0, -1, 0) }, LeftUpperLeg = { "LeftLowerLeg", Vector3.new(0, -1, 0) }, LeftLowerLeg = { "LeftFoot", Vector3.new(0, -1, 0) }, RightUpperLeg = { "RightLowerLeg", Vector3.new(0, -1, 0) }, RightLowerLeg = { "RightFoot", Vector3.new(0, -1, 0) }, } local function restCompensation(bind, pivotBind) local turns, count, worst = {}, 0, 0 for part, spec in pairs(REST_SEGMENTS) do local from = pivotBind[part] local to = pivotBind[spec[1]] if from and to and (to - from).Magnitude > 1e-3 then local direction = (to - from).Unit local angle = math.deg(math.acos(math.clamp(direction:Dot(spec[2]), -1, 1))) local axis = direction:Cross(spec[2]) if angle > 3 and angle < 80 and axis.Magnitude > 1e-6 then turns[part] = CFrame.fromAxisAngle(axis.Unit, math.rad(angle)) count = count + 1 worst = math.max(worst, angle) end end end return turns, count, worst end -- Mirror (left <-> right), in place and speed act on the motion before it meets the rig. -- Mirroring across the rig's X = 0 plane maps a rotation (x, y, z, w) to (x, -y, -z, w). local function mirroredName(name) if name:sub(1, 4) == "Left" then return "Right" .. name:sub(5) elseif name:sub(1, 5) == "Right" then return "Left" .. name:sub(6) end return name end local function transformMotion(motion, opts) local speed = math.clamp(tonumber(opts.speed) or 1, 0.1, 4) if not opts.mirror and not opts.inPlace and speed == 1 then return motion end local out = {} for key, value in pairs(motion) do out[key] = value end local fps = tonumber(motion.fps) or 30 out.fps = fps * speed out.duration = (tonumber(motion.duration) or ((#motion.frames - 1) / fps)) / speed out.frames = {} for index, frame in ipairs(motion.frames) do local bones = {} for name, bone in pairs(frame.bones or {}) do local copy = {} if type(bone.rotation) == "table" then local q = bone.rotation copy.rotation = opts.mirror and { q[1] or 0, -(q[2] or 0), -(q[3] or 0), q[4] or 1 } or q end if type(bone.position) == "table" then local p = bone.position local x, y, z = p[1] or 0, p[2] or 0, p[3] or 0 if opts.mirror then x = -x end if opts.inPlace then x, z = 0, 0 end copy.position = { x, y, z } end bones[opts.mirror and mirroredName(name) or name] = copy end local contact = frame.contact if type(contact) == "table" then local c = {} for key, value in pairs(contact) do c[key] = value end if opts.mirror then c.LeftFoot, c.RightFoot = contact.RightFoot, contact.LeftFoot end if opts.inPlace then c.LeftFoot, c.RightFoot = false, false -- a foot that travels in place cannot stay planted end contact = c end out.frames[index] = { time = (tonumber(frame.time) or (index - 1) / fps) / speed, bones = bones, contact = contact } end return out end -- Keyframe reduction. Every source frame is still solved; only the frames the rest cannot -- reproduce within `tolerance` degrees become Keyframes, so a clip arrives in the Animation -- Editor with keys you can actually work with. Frames where a foot lands or leaves are always -- kept: the IK anchors to them, and interpolating across a landing would slide the foot. local function quatAngle(a, b) local dot = math.abs(a[1] * b[1] + a[2] * b[2] + a[3] * b[3] + a[4] * b[4]) return math.deg(2 * math.acos(math.clamp(dot, -1, 1))) end local function quatSlerp(a, b, t) local dot = a[1] * b[1] + a[2] * b[2] + a[3] * b[3] + a[4] * b[4] local bx, by, bz, bw = b[1], b[2], b[3], b[4] if dot < 0 then dot, bx, by, bz, bw = -dot, -bx, -by, -bz, -bw end local wa, wb if dot > 0.9995 then wa, wb = 1 - t, t else local theta = math.acos(math.clamp(dot, -1, 1)) local s = math.sin(theta) wa, wb = math.sin((1 - t) * theta) / s, math.sin(t * theta) / s end local x, y, z, w = wa * a[1] + wb * bx, wa * a[2] + wb * by, wa * a[3] + wb * bz, wa * a[4] + wb * bw local n = math.sqrt(x * x + y * y + z * z + w * w) if n < 1e-9 then return a end return { x / n, y / n, z / n, w / n } end local MAX_KEY_GAP = 30 -- never let a key carry more than a second of motion local function keptFrames(motion, tolerance) local frames = motion.frames local count = #frames local keep = { [1] = true, [count] = true } local previous = nil for index, frame in ipairs(frames) do local contact = frame.contact local state = "" if type(contact) == "table" then state = tostring(contact.LeftFoot) .. tostring(contact.RightFoot) .. tostring(contact.hands) end if previous ~= nil and state ~= previous then keep[math.max(1, index - 1)] = true keep[index] = true end previous = state end local anchor = 1 for index = 2, count - 1 do if keep[index] then anchor = index elseif index - anchor >= MAX_KEY_GAP then keep[index] = true anchor = index else local span = index + 1 - anchor local worst = 0 local from, to = frames[anchor].bones or {}, frames[index + 1].bones or {} for between = anchor + 1, index do local t = (between - anchor) / span local middle = frames[between].bones or {} for _, name in ipairs(partsOf(motion)) do local a, b, c = from[name], to[name], middle[name] if a and b and c and a.rotation and b.rotation and c.rotation then worst = math.max(worst, quatAngle(quatSlerp(a.rotation, b.rotation, t), c.rotation)) end end if worst > tolerance then break end end if worst > tolerance then keep[index] = true anchor = index end end end return keep end local function buildSequence(motion, rig, opts) opts = opts or {} if type(motion) ~= "table" or motion.format ~= FORMAT then error(string.format("This motion is %s; the plugin imports %s. Regenerate it with the updated Riggler server.", tostring(type(motion) == "table" and motion.format or "not a motion"), FORMAT), 0) end if type(motion.frames) ~= "table" or #motion.frames == 0 then error("The motion has no frames", 0) end local rootName, joints, bind = readRig(rig) if not joints.LowerTorso then error("The selected rig has no LowerTorso joint; select an R15 character", 0) end -- Rr: each joint's frame at bind, in rig axes. Pose.CFrame = Rr^-1 * q * Rr. local jointFrame = {} for name, joint in pairs(joints) do local parent = bind[joint.part0] if parent then jointFrame[name] = (parent * joint.c0).Rotation end end local gap = legHeight(joints, bind) local sourceHip = tonumber(motion.source_hip_height_m) local scale = FALLBACK_STUDS_PER_METRE if gap and gap > 0 and sourceHip and sourceHip > 0 then scale = gap / sourceHip end -- Soles may not sink below the floor the rig stands on at bind. The source body and -- an R15 are proportioned differently, so a deep squat can push a foot under it. local feet = {} for _, name in ipairs({ "LeftFoot", "RightFoot" }) do local part = rig:FindFirstChild(name) if part and part:IsA("BasePart") and joints[name] then feet[name] = part.Size end end local CORNERS = { Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1), Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1), Vector3.new(-1, 1, -1), Vector3.new(1, 1, -1), Vector3.new(-1, 1, 1), Vector3.new(1, 1, 1), } local function lowestSole(poseFor) local world = { [rootName] = CFrame.identity } local function solve(name, depth) if world[name] then return world[name] end local joint = joints[name] if not joint or depth > 32 then return nil end local parent = solve(joint.part0, depth + 1) if not parent then return nil end world[name] = parent * joint.c0 * (poseFor[name] or CFrame.identity) * joint.c1:Inverse() return world[name] end local low for name, size in pairs(feet) do local cf = solve(name, 0) if cf then for _, corner in ipairs(CORNERS) do local y = (cf * (corner * size / 2)).Y if not low or y < low then low = y end end end end return low end local floor = lowestSole({}) local lifted, maxLift = 0, 0 local sequence = Instance.new("KeyframeSequence") sequence.Name = opts.name or ("Riggler_" .. os.date("%Y%m%d_%H%M%S")) if opts.loop ~= nil then sequence.Loop = opts.loop else sequence.Loop = motion.loop == true end sequence.Priority = opts.priority or Enum.AnimationPriority.Action local fps = tonumber(motion.fps) or 30 local tolerance = tonumber(opts.reduce) or 0 local keep = {} if tolerance > 0 and #motion.frames > 2 then keep = keptFrames(motion, tolerance) end local keys = 0 local pivotBind = pivotsAtBind(joints, bind) local rest, restCount, restDegrees = {}, 0, 0 if opts.restPose ~= false then rest, restCount, restDegrees = restCompensation(bind, pivotBind) end -- IK. The source body and an R15 differ in proportion, so matching joint angles -- alone lets a planted foot slide and keeps hands apart that should be holding one -- thing. With the motion's contact flags, a planted foot is pinned where it landed, -- keeps the turn it landed with and settles its lowest corner onto the floor; the -- hips come down when a pinned foot would be out of the leg's reach; while a foot -- stands, no foot dips into the floor; hands that hold one thing are drawn to their -- midpoint. Knees and elbows bend about their own hinge, and each contact eases out -- over RELEASE frames. local LEGS = { { "LeftUpperLeg", "LeftLowerLeg", "LeftFoot" }, { "RightUpperLeg", "RightLowerLeg", "RightFoot" } } local ARMS = { { "LeftUpperArm", "LeftLowerArm", "LeftHand" }, { "RightUpperArm", "RightLowerArm", "RightHand" } } -- the way each hinge folds, in the upper segment's bind frame: knees forward, elbows back local KNEE, ELBOW = Vector3.new(0, 0, -1), Vector3.new(0, 0, 1) local RELEASE = math.max(1, math.floor(0.15 * fps + 0.5)) local DROP_LIMIT = 0.3 * (gap or 1.7) local function hasChain(chain) return (pivotBind[chain[1]] and pivotBind[chain[2]] and pivotBind[chain[3]]) ~= nil end local function chainLength(chain) return (pivotBind[chain[2]] - pivotBind[chain[1]]).Magnitude + (pivotBind[chain[3]] - pivotBind[chain[2]]).Magnitude end -- joint pivots for a set of world deltas: every part turns about its own joint local function pivotsFor(D, shift) local at = {} local function pivot(name, depth) if at[name] then return at[name] end local joint = joints[name] if not joint or not pivotBind[name] or depth > 32 then return nil end if joint.part0 == rootName then at[name] = pivotBind[name] + shift else local base = pivot(joint.part0, depth + 1) if not base then return nil end at[name] = base + (D[joint.part0] or CFrame.identity):VectorToWorldSpace(pivotBind[name] - pivotBind[joint.part0]) end return at[name] end for name in pairs(joints) do pivot(name, 0) end return at end local function swing(from, to) if from.Magnitude < 1e-6 or to.Magnitude < 1e-6 then return CFrame.identity end local a, b = from.Unit, to.Unit local axis = a:Cross(b) local s = axis.Magnitude if s < 1e-7 then return CFrame.identity end return CFrame.fromAxisAngle(axis / s, math.atan2(s, a:Dot(b))) end -- two-bone solve: bend upper+lower so the tip joint lands on target local function reach(D, shift, chain, target, weight, pole) local upper, lower, tip = chain[1], chain[2], chain[3] local at = pivotsFor(D, shift) local H, K, A = at[upper], at[lower], at[tip] if not (H and K and A) then return end local L1 = (pivotBind[lower] - pivotBind[upper]).Magnitude local L2 = (pivotBind[tip] - pivotBind[lower]).Magnitude local T = A:Lerp(target, weight) local toT = T - H if toT.Magnitude < 1e-6 then return end local axis = toT.Unit local d = math.clamp(toT.Magnitude, math.abs(L1 - L2) + 1e-3, L1 + L2 - 1e-3) T = H + axis * d local along = (L1 * L1 - L2 * L2 + d * d) / (2 * d) local out = math.sqrt(math.max(0, L1 * L1 - along * along)) -- Keep the plane the limb already bends in, but never fold against the hinge (a -- knee does not bend backward), and lean on the hinge as the limb straightens so -- the plane cannot flip between frames. local hinge = pole - axis * pole:Dot(axis) if hinge.Magnitude < 1e-6 then return end hinge = hinge.Unit local bend = (K - H) - axis * (K - H):Dot(axis) local against = bend:Dot(hinge) if against < 0 then bend = bend - hinge * against end bend = bend + hinge * (0.1 * L1) local K2 = H + axis * along + bend.Unit * out local r1 = swing(K - H, K2 - H) local r2 = swing(r1:VectorToWorldSpace(A - K), T - K2) D[upper] = r1 * D[upper] D[lower] = r2 * r1 * D[lower] D[tip] = r2 * r1 * D[tip] end local function hingeOf(D, chain, axis) return (D[chain[1]] or CFrame.identity):VectorToWorldSpace(axis) end -- lowest corner of a foot below its ankle pivot, for a given foot turn local function soleBelowAnkle(D, foot) local rot = D * bind[foot].Rotation local centre = D:VectorToWorldSpace(bind[foot].Position - pivotBind[foot]) local low for _, corner in ipairs(CORNERS) do local y = (centre + rot:VectorToWorldSpace(corner * feet[foot] / 2)).Y if not low or y < low then low = y end end return low end local function ease(state) state.release = state.release - 1 local t = math.max(0, state.release) / (RELEASE + 1) state.weight = t * t * (3 - 2 * t) end local plantState = {} local handState = { weight = 0, release = 0 } local handWidth = 1 local leftHand, rightHand = rig:FindFirstChild("LeftHand"), rig:FindFirstChild("RightHand") if leftHand and rightHand and leftHand:IsA("BasePart") and rightHand:IsA("BasePart") then handWidth = math.min(leftHand.Size.X, rightHand.Size.X) end local plantedFrames, heldFrames = 0, 0 local dropped, maxDrop, raised = 0, 0, 0 for index, frame in ipairs(motion.frames) do local bones = frame.bones or {} -- World deltas by forward kinematics over the contract hierarchy. local delta = { [rootName] = CFrame.identity } for _, name in ipairs(partsOf(motion)) do local bone = bones[name] local parent = parentOf(motion, name) delta[name] = (parent and delta[parent] or CFrame.identity) * rotationFrom(bone and bone.rotation) end for name, turn in pairs(rest) do if delta[name] then delta[name] = delta[name] * turn end end local offset = bones.Root and bones.Root.position local shift = Vector3.zero if type(offset) == "table" then shift = Vector3.new((offset[1] or 0) * scale, (offset[2] or 0) * scale, (offset[3] or 0) * scale) end local contact = nil if opts.ik ~= false then contact = frame.contact end if type(contact) == "table" then -- where each planted ankle has to be this frame local targets = {} for _, chain in ipairs(LEGS) do local foot = chain[3] if hasChain(chain) and feet[foot] then local state = plantState[foot] if not state then state = { weight = 0, release = 0, age = 0 } plantState[foot] = state end if contact[foot] then if state.weight <= 0 then -- landing: pin the foot where it is, turned as it is; it settles onto the floor local at = pivotsFor(delta, shift) state.footDelta = delta[foot] state.floorY = floor - soleBelowAnkle(delta[foot], foot) -- a foot that lands under the floor starts on it; one above it settles down state.anchor = Vector3.new(at[foot].X, math.max(at[foot].Y, state.floorY), at[foot].Z) state.age = 0 end state.weight, state.release = 1, RELEASE state.age = state.age + 1 elseif state.weight > 0 then ease(state) end if state.weight > 0 then local settle = math.min(1, state.age / RELEASE) settle = settle * settle * (3 - 2 * settle) local anchor = state.anchor targets[foot] = { chain = chain, state = state, point = Vector3.new(anchor.X, anchor.Y + (state.floorY - anchor.Y) * settle, anchor.Z), } end end end -- the hips come down rather than a pinned foot being dragged out of reach local drop = 0 local at = pivotsFor(delta, shift) for foot, target in pairs(targets) do local H = at[target.chain[1]] local T = at[foot]:Lerp(target.point, target.state.weight) local longest = 0.995 * chainLength(target.chain) local flat = (T.X - H.X) ^ 2 + (T.Z - H.Z) ^ 2 if flat < longest * longest then drop = math.max(drop, (H.Y - T.Y) - math.sqrt(longest * longest - flat)) end end drop = math.min(drop, DROP_LIMIT) if drop > 1e-3 then shift = shift - Vector3.new(0, drop, 0) dropped = dropped + 1 maxDrop = math.max(maxDrop, drop) end for foot, target in pairs(targets) do reach(delta, shift, target.chain, target.point, target.state.weight, hingeOf(delta, target.chain, KNEE)) delta[foot] = delta[foot]:Lerp(target.state.footDelta, target.state.weight) plantedFrames = plantedFrames + 1 end -- while a foot stands, no foot may dip into the floor: a swinging or releasing foot -- is lifted by its own leg rather than the body being raised off the standing one if next(targets) then local now = pivotsFor(delta, shift) for _, chain in ipairs(LEGS) do local foot = chain[3] if hasChain(chain) and feet[foot] and now[foot] then local sole = now[foot].Y + soleBelowAnkle(delta[foot], foot) if sole < floor - 1e-3 then local turned = delta[foot] reach(delta, shift, chain, now[foot] + Vector3.new(0, floor - sole, 0), 1, hingeOf(delta, chain, KNEE)) delta[foot] = turned raised = raised + 1 end end end end if hasChain(ARMS[1]) and hasChain(ARMS[2]) then if contact.hands then handState.weight, handState.release = 1, RELEASE elseif handState.weight > 0 then ease(handState) end if handState.weight > 0 then local now = pivotsFor(delta, shift) local left, right = now.LeftHand, now.RightHand local span = right - left local width = math.min(span.Magnitude, math.max(handWidth, (tonumber(contact.handGap) or 0) * scale)) local across = span.Magnitude > 1e-4 and span.Unit or (delta.UpperTorso or CFrame.identity):VectorToWorldSpace(Vector3.new(1, 0, 0)) local middle = (left + right) / 2 reach(delta, shift, ARMS[1], middle - across * (width / 2), handState.weight, hingeOf(delta, ARMS[1], ELBOW)) reach(delta, shift, ARMS[2], middle + across * (width / 2), handState.weight, hingeOf(delta, ARMS[2], ELBOW)) heldFrames = heldFrames + 1 end end end local keyframe = Instance.new("Keyframe") keyframe.Time = tonumber(frame.time) or (index - 1) / fps local rootPose = Instance.new("Pose") rootPose.Name = rootName rootPose.Parent = keyframe local poses = { [rootName] = rootPose } local rootJoint, rootRelative -- Poses nest by the rig's own Part0 -> Part1 graph, so each rotation is -- expressed against the part that really carries the joint. local function ensure(name, depth) if poses[name] then return poses[name] end local joint, frameRr = joints[name], jointFrame[name] -- A clip folded for R6 carries the Motor6D transform itself, so the only thing it -- needs from this rig is which part owns which joint. local raw = motion.pose_space == "motor6d" and bones[name] or nil if not joint or not frameRr or (not raw and not delta[name]) or depth > 32 then return nil end local parentPose = ensure(joint.part0, depth + 1) if not parentPose then return nil end local pose = Instance.new("Pose") pose.Name = name if raw then local q, p = raw.rotation, raw.position pose.CFrame = CFrame.new( p and p[1] or 0, p and p[2] or 0, p and p[3] or 0, q and q[1] or 0, q and q[2] or 0, q and q[3] or 0, q and q[4] or 1) if joint.part0 == rootName then -- root travel is in world axes; the joint reads it in its own pose.CFrame = CFrame.new(frameRr:VectorToObjectSpace(shift)) * pose.CFrame rootJoint = name end else local relative = (delta[joint.part0] or CFrame.identity):Inverse() * delta[name] if joint.part0 == rootName then relative = CFrame.new(shift) * relative rootJoint, rootRelative = name, relative end pose.CFrame = frameRr:Inverse() * relative * frameRr end pose.Weight = 1 pose.Parent = parentPose poses[name] = pose return pose end for _, name in ipairs(partsOf(motion)) do ensure(name, 0) end if opts.floor ~= false and floor and rootJoint and rootRelative then local poseFor = {} for name, pose in pairs(poses) do poseFor[name] = pose.CFrame end local low = lowestSole(poseFor) if low and low < floor - 1e-3 then -- raising the root joint lifts everything below it rigidly, so one pass is exact local lift = floor - low poses[rootJoint].CFrame = jointFrame[rootJoint]:Inverse() * (CFrame.new(0, lift, 0) * rootRelative) * jointFrame[rootJoint] lifted = lifted + 1 maxLift = math.max(maxLift, lift) end end if tolerance <= 0 or keep[index] then keyframe.Parent = sequence keys = keys + 1 else keyframe:Destroy() -- solved, but the frames around it already say this end if index % 60 == 0 then task.wait() end end return sequence, keys, scale, { frames = #motion.frames, keys = keys, lifted = lifted, maxLift = maxLift, plantedFrames = plantedFrames, heldFrames = heldFrames, dropped = dropped, maxDrop = maxDrop, raised = raised, compensated = restCount, compensationDegrees = restDegrees, } end -- Each keyframe's part CFrames relative to the root part, for playback in the widget. local function bakeFrames(sequence, rig) local rootName, joints = readRig(rig) local keyframes = sequence:GetKeyframes() table.sort(keyframes, function(a, b) return a.Time < b.Time end) local frames = {} for index, keyframe in ipairs(keyframes) do local poses = {} for _, pose in ipairs(keyframe:GetDescendants()) do if pose:IsA("Pose") then poses[pose.Name] = pose.CFrame end end local world = { [rootName] = CFrame.identity } local function solve(name, depth) if world[name] then return world[name] end local joint = joints[name] if not joint or depth > 32 then return nil end local parent = solve(joint.part0, depth + 1) if not parent then return nil end world[name] = parent * joint.c0 * (poses[name] or CFrame.identity) * joint.c1:Inverse() return world[name] end for name in pairs(joints) do solve(name, 0) end frames[index] = { time = keyframe.Time, world = world } end return frames end local function sampleIndex(frames, t) local n = #frames if n <= 1 or t <= frames[1].time then return 1, 1, 0 end if t >= frames[n].time then return n, n, 0 end local lo, hi = 1, n while hi - lo > 1 do local mid = math.floor((lo + hi) / 2) if frames[mid].time <= t then lo = mid else hi = mid end end return lo, hi, (t - frames[lo].time) / math.max(1e-6, frames[hi].time - frames[lo].time) end local function selectedRig() for _, item in ipairs(Selection:Get()) do local model = item:IsA("Model") and item or item:FindFirstAncestorOfClass("Model") while model do local humanoid = model:FindFirstChildOfClass("Humanoid") if (humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15 or humanoid.RigType == Enum.HumanoidRigType.R6)) or (not humanoid and model:FindFirstChild("HumanoidRootPart") and (model:FindFirstChild("LowerTorso") or model:FindFirstChild("Torso"))) then return model end model = model:FindFirstAncestorOfClass("Model") end end return nil end local function jointKind(rig) for _, item in ipairs(rig:GetDescendants()) do if item:IsA("AnimationConstraint") then return "AJU" elseif item:IsA("Motor6D") then return "Motor6D" end end return "no joints" end local function priorityEnum(name) for _, item in ipairs(Enum.AnimationPriority:GetEnumItems()) do if item.Name == name then return item end end return Enum.AnimationPriority.Action end local function slug(value) local s = tostring(value or "Motion"):gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") if s == "" then s = "Motion" end return s:sub(1, 40) end local function buildOptions(title) local loop = nil if settings.loop == "On" then loop = true elseif settings.loop == "Off" then loop = false end return { ik = settings.ik, floor = settings.floor, restPose = settings.restPose, loop = loop, reduce = settings.reduce, priority = priorityEnum(settings.priority), name = "MF_" .. slug(title), } end ------------------------------------------------------------------ the server local signOut -- defined with the UI -- One call to the Riggler API. Returns the decoded body and errors with the server's -- own message; with opts.raw it returns (body, status) whatever the status. local function request(method, path, body, opts) opts = opts or {} local headers = { Accept = "application/json" } local token = opts.token or (session and session.token) if token and not opts.anonymous then headers.Authorization = "Bearer " .. token end local req = { Url = settings.server .. path, Method = method, Headers = headers } if body ~= nil then headers["Content-Type"] = "application/json" req.Body = HttpService:JSONEncode(body) end local ok, response = pcall(function() return HttpService:RequestAsync(req) end) if not ok then error("Can't reach Riggler at " .. settings.server .. ". Start the server, and allow HTTP requests for this plugin if Studio asks. (" .. tostring(response) .. ")", 0) end local data = nil if response.Body and #response.Body > 0 then local decoded, value = pcall(function() return HttpService:JSONDecode(response.Body) end) if decoded then data = value end end if opts.raw then return data, response.StatusCode end if response.StatusCode == 401 and not opts.anonymous then signOut("Your session ended. Sign in again.") error("Signed out", 0) end if not response.Success then local detail = type(data) == "table" and data.detail if response.StatusCode == 402 then -- the server says what is needed; the plugin adds where to go and do it error((type(detail) == "string" and detail or "That needs Riggler Pro.") .. " Upgrade at " .. tostring(settings.server) .. "/app/pricing", 0) end error(type(detail) == "string" and detail or ("Riggler answered " .. tostring(response.StatusCode) .. " " .. tostring(response.StatusMessage)), 0) end return data end local BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -- Luau has no base64; a video chunk has to travel as text in a JSON body. local function base64(data) local out = {} for index = 1, #data, 3 do local a, b, c = data:byte(index, index + 2) local n = a * 65536 + (b or 0) * 256 + (c or 0) local chunk = BASE64:sub(math.floor(n / 262144) + 1, math.floor(n / 262144) + 1) .. BASE64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) .. (b and BASE64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) or "=") .. (c and BASE64:sub(n % 64 + 1, n % 64 + 1) or "=") table.insert(out, chunk) end return table.concat(out) end local function query(params) local parts = {} for key, value in pairs(params) do if value ~= nil and value ~= "" and value ~= "All" then table.insert(parts, key .. "=" .. HttpService:UrlEncode(tostring(value))) end end return #parts > 0 and ("?" .. table.concat(parts, "&")) or "" end ------------------------------------------------------------------ UI kit local THEME = { bg = Color3.fromRGB(12, 18, 15), panel = Color3.fromRGB(19, 31, 25), panel2 = Color3.fromRGB(26, 43, 35), field = Color3.fromRGB(14, 24, 19), line = Color3.fromRGB(43, 66, 55), text = Color3.fromRGB(242, 246, 244), muted = Color3.fromRGB(155, 158, 156), purple = Color3.fromRGB(46, 194, 120), purpleDim = Color3.fromRGB(65, 125, 95), pink = Color3.fromRGB(154, 165, 160), mint = Color3.fromRGB(117, 233, 192), red = Color3.fromRGB(255, 120, 140), amber = Color3.fromRGB(255, 197, 107), } local order = 0 local function make(className, props, children) local item = Instance.new(className) local parent = nil for key, value in pairs(props or {}) do if key == "Parent" then parent = value else item[key] = value end end if item:IsA("GuiObject") and (props == nil or props.LayoutOrder == nil) then order = order + 1 item.LayoutOrder = order end for _, child in ipairs(children or {}) do child.Parent = item end if parent then item.Parent = parent end return item end local function corner(radius) return make("UICorner", { CornerRadius = UDim.new(0, radius or 8) }) end local function stroke(color) return make("UIStroke", { Color = color or THEME.line, Thickness = 1, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }) end local function pad(top, side, bottom) return make("UIPadding", { PaddingTop = UDim.new(0, top), PaddingBottom = UDim.new(0, bottom or top), PaddingLeft = UDim.new(0, side or top), PaddingRight = UDim.new(0, side or top), }) end local function vlist(gap) return make("UIListLayout", { Padding = UDim.new(0, gap or 6), SortOrder = Enum.SortOrder.LayoutOrder }) end local function hlist(gap) return make("UIListLayout", { Padding = UDim.new(0, gap or 6), SortOrder = Enum.SortOrder.LayoutOrder, FillDirection = Enum.FillDirection.Horizontal, VerticalAlignment = Enum.VerticalAlignment.Center, }) end local function text(parent, value, style) style = style or {} return make("TextLabel", { Parent = parent, BackgroundTransparency = 1, Text = value or "", RichText = style.rich == true, TextColor3 = style.color or THEME.text, Font = style.font or Enum.Font.Gotham, TextSize = style.size or 13, TextXAlignment = style.align or Enum.TextXAlignment.Left, TextWrapped = true, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, }) end local function sized(fraction, height) height = height or 30 if not fraction or fraction >= 1 then return UDim2.new(1, 0, 0, height) end local gaps = math.floor(1 / fraction + 0.5) - 1 return UDim2.new(fraction, -math.ceil(gaps * 6 * fraction), 0, height) end local STYLES = { primary = { THEME.purple, Color3.new(1, 1, 1), THEME.purple }, ghost = { THEME.panel2, THEME.text, THEME.line }, danger = { THEME.panel2, THEME.red, Color3.fromRGB(96, 44, 62) }, } local function button(parent, value, kind, fraction) local style = STYLES[kind or "ghost"] return make("TextButton", { Parent = parent, Text = value, AutoButtonColor = true, BackgroundColor3 = style[1], TextColor3 = style[2], Font = Enum.Font.GothamBold, TextSize = 12, Size = sized(fraction), TextTruncate = Enum.TextTruncate.AtEnd, }, { corner(7), stroke(style[3]), pad(0, 8) }) end local function row(parent, height) return make("Frame", { Parent = parent, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, height or 30) }, { hlist(6) }) end local function input(parent, placeholder, value, height, multiline) return make("TextBox", { Parent = parent, Text = value or "", PlaceholderText = placeholder or "", ClearTextOnFocus = false, BackgroundColor3 = THEME.field, TextColor3 = THEME.text, PlaceholderColor3 = THEME.muted, Font = Enum.Font.Gotham, TextSize = 13, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = multiline and Enum.TextYAlignment.Top or Enum.TextYAlignment.Center, MultiLine = multiline == true, TextWrapped = multiline == true, Size = UDim2.new(1, 0, 0, height or 32), }, { corner(7), stroke(), pad(6, 9) }) end local function choice(parent, caption, options, get, set, fraction) local b = button(parent, "", "ghost", fraction) b.TextXAlignment = Enum.TextXAlignment.Left local function refresh() local current, shown = get(), nil for _, option in ipairs(options) do if option[1] == current then shown = option[2] end end b.Text = caption .. ": " .. (shown or tostring(current)) end b.MouseButton1Click:Connect(function() local current, index = get(), 0 for i, option in ipairs(options) do if option[1] == current then index = i end end set(options[index % #options + 1][1]) refresh() end) refresh() return b, refresh end local function clear(container) for _, child in ipairs(container:GetChildren()) do if child:IsA("GuiObject") then child:Destroy() end end end local function listRow(parent, title, meta, onClick, accent) local item = make("TextButton", { Parent = parent, Text = "", AutoButtonColor = true, BackgroundColor3 = THEME.panel, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, }, { corner(8), stroke(accent), pad(7, 9), vlist(2) }) make("TextLabel", { Parent = item, BackgroundTransparency = 1, Text = title, TextColor3 = THEME.text, Font = Enum.Font.GothamBold, TextSize = 13, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, Size = UDim2.new(1, 0, 0, 16), }) make("TextLabel", { Parent = item, BackgroundTransparency = 1, Text = meta, TextColor3 = THEME.muted, Font = Enum.Font.Gotham, TextSize = 11, TextXAlignment = Enum.TextXAlignment.Left, TextWrapped = true, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, }) if onClick then item.MouseButton1Click:Connect(onClick) end return item end ------------------------------------------------------------------ widget local toolbar = plugin:CreateToolbar("Riggler") local openButton = toolbar:CreateButton("Riggler", "Open Riggler: text, photo and video motion for R15 and R6", "rbxassetid://4458901886") openButton.ClickableWhenViewportHidden = true local widget = plugin:CreateDockWidgetPluginGui("RigglerWidget", DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, true, false, 360, 660, 300, 460)) widget.Title = "Riggler" openButton.Click:Connect(function() widget.Enabled = not widget.Enabled end) local root = make("Frame", { Parent = widget, Size = UDim2.fromScale(1, 1), BackgroundColor3 = THEME.bg, BorderSizePixel = 0 }) -- forward declarations: these are defined further down and called from handlers above them local showTab, openClip, openRemote, refreshRig, updateCard, renderVariants, bakeView, stopLive, onSignedIn, showSignIn local current = nil -- { kind, id, title, prompt, motion } local view = nil -- widget playback: { frames, time, playing, length } local live = nil -- native playback on the selected rig local stock = nil -- the widget's preview rig local lastSequence = nil local lastRig = nil -- Apply selects the KeyframeSequence it creates, so the rig you were working on is -- remembered and keeps being the target until you select another one. local function targetRig() local rig = selectedRig() if rig then lastRig = rig return rig end if lastRig and lastRig.Parent then return lastRig end return nil end ---------------------------------------------------------------- sign-in screen local signInScreen = make("Frame", { Parent = root, Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, Visible = false }, { pad(26, 20), vlist(10) }) text(signInScreen, "Riggler", { font = Enum.Font.GothamBold, size = 22 }) text(signInScreen, "Sign in to your Riggler account to generate motions and use your history, library and packs in Studio.", { color = THEME.muted, size = 13 }) text(signInScreen, "Server", { font = Enum.Font.GothamBold, size = 12, color = THEME.muted }) local serverBox = input(signInScreen, "http://127.0.0.1:8001", settings.server) local signInButton = button(signInScreen, "Sign in with your browser", "primary", 1) signInButton.Size = UDim2.new(1, 0, 0, 36) local codePanel = make("Frame", { Parent = signInScreen, BackgroundColor3 = THEME.panel, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, Visible = false, }, { corner(10), stroke(THEME.purple), pad(12), vlist(8) }) text(codePanel, "Your code", { color = THEME.muted, size = 12 }) local codeLabel = text(codePanel, "", { font = Enum.Font.GothamBold, size = 30, align = Enum.TextXAlignment.Center }) text(codePanel, "Open this address in your browser while signed in to Riggler, check the code and press Connect Studio:", { color = THEME.muted, size = 12 }) local linkBox = input(codePanel, "", "", 30) linkBox.TextEditable = false linkBox.TextSize = 12 local waitingLabel = text(codePanel, "Waiting for approval…", { color = THEME.amber, size = 12 }) local cancelSignIn = button(codePanel, "Cancel", "ghost", 1) local signInStatus = text(signInScreen, "", { size = 12, color = THEME.red }) text(signInScreen, "No account yet? Create one on the Riggler website (same address, /signup).", { color = THEME.muted, size = 11 }) serverBox.FocusLost:Connect(function() settings.server = cleanServer(serverBox.Text) serverBox.Text = settings.server saveSettings() end) local pendingSignIn = nil local function beginSignIn() signInStatus.Text = "" settings.server = cleanServer(serverBox.Text) serverBox.Text = settings.server saveSettings() local attempt = {} pendingSignIn = attempt signInButton.Text = "Requesting a code…" local ok, err = pcall(function() local link = request("POST", "/auth/device", { label = "Roblox Studio · " .. tostring(game.Name) }, { anonymous = true }) codeLabel.Text = link.user_code linkBox.Text = settings.server .. (link.verification_path or "/link") .. "?code=" .. link.user_code codePanel.Visible = true signInButton.Visible = false waitingLabel.Text = "Waiting for approval…" local deadline = os.clock() + (tonumber(link.expires_in) or 600) task.spawn(function() local failure = nil while pendingSignIn == attempt and os.clock() < deadline do task.wait(tonumber(link.interval) or 3) if pendingSignIn ~= attempt then return end local polled, data = pcall(request, "POST", "/auth/device/token", { device_code = link.device_code }, { anonymous = true, raw = true }) if polled and type(data) == "table" then if data.status == "ok" then pendingSignIn = nil session = { token = data.token, user = data.user } plugin:SetSetting(SESSION_KEY, session) onSignedIn() return elseif data.status == "expired" or data.status == "invalid" then failure = data.detail or "The code expired. Start again." break end end end if pendingSignIn == attempt then pendingSignIn = nil showSignIn(failure or "The code expired. Start again.") end end) end) signInButton.Text = "Sign in with your browser" if not ok then pendingSignIn = nil signInStatus.Text = tostring(err) end end signInButton.MouseButton1Click:Connect(function() task.spawn(beginSignIn) end) cancelSignIn.MouseButton1Click:Connect(function() pendingSignIn = nil showSignIn("") end) ---------------------------------------------------------------- main screen local mainScreen = make("Frame", { Parent = root, Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, Visible = false }) local header = make("Frame", { Parent = mainScreen, Size = UDim2.new(1, 0, 0, 50), BackgroundColor3 = THEME.panel, BorderSizePixel = 0 }) make("Frame", { Parent = header, Size = UDim2.fromOffset(26, 26), Position = UDim2.new(0, 12, 0.5, -13), BackgroundColor3 = THEME.purple }, { corner(8), make("UIGradient", { Color = ColorSequence.new(THEME.purple, THEME.pink), Rotation = 45 }), }) make("TextLabel", { Parent = header, BackgroundTransparency = 1, Position = UDim2.fromOffset(46, 8), Size = UDim2.new(0.5, 0, 0, 18), Text = "Riggler", Font = Enum.Font.GothamBold, TextSize = 15, TextColor3 = THEME.text, TextXAlignment = Enum.TextXAlignment.Left, }) local serverLine = make("TextLabel", { Parent = header, BackgroundTransparency = 1, Position = UDim2.fromOffset(46, 27), Size = UDim2.new(0.55, 0, 0, 14), Text = "Motion Lab · v" .. VERSION, Font = Enum.Font.Gotham, TextSize = 11, TextColor3 = THEME.muted, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, }) local accountButton = make("TextButton", { Parent = header, AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -10, 0.5, 0), Size = UDim2.fromOffset(0, 28), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = THEME.panel2, Text = "", AutoButtonColor = true, }, { corner(14), stroke(THEME.purpleDim), pad(0, 10), hlist(6) }) local accountDot = make("Frame", { Parent = accountButton, Size = UDim2.fromOffset(8, 8), BackgroundColor3 = THEME.mint }, { corner(4) }) local accountLabel = make("TextLabel", { Parent = accountButton, BackgroundTransparency = 1, Size = UDim2.fromOffset(0, 28), AutomaticSize = Enum.AutomaticSize.X, Text = "", Font = Enum.Font.GothamBold, TextSize = 12, TextColor3 = THEME.text, }) local rigBar = make("Frame", { Parent = mainScreen, Position = UDim2.fromOffset(0, 50), Size = UDim2.new(1, 0, 0, 30), BackgroundColor3 = Color3.fromRGB(8, 13, 11), BorderSizePixel = 0 }, { pad(0, 12), hlist(8) }) local rigDot = make("Frame", { Parent = rigBar, Size = UDim2.fromOffset(9, 9), BackgroundColor3 = THEME.amber }, { corner(5) }) local rigLabel = make("TextLabel", { Parent = rigBar, BackgroundTransparency = 1, Size = UDim2.new(1, -20, 1, 0), Text = "Select an R15 or R6 rig in Workspace", Font = Enum.Font.GothamMedium, TextSize = 12, TextColor3 = THEME.text, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, }) local TABS = { "Create", "Edit", "History", "Library", "Explore", "Settings" } local tabBar = make("Frame", { Parent = mainScreen, Position = UDim2.fromOffset(0, 80), Size = UDim2.new(1, 0, 0, 38), BackgroundTransparency = 1 }, { pad(5, 8), hlist(4) }) local tabButtons, pages, loaders = {}, {}, {} local activeTab = "Create" for _, name in ipairs(TABS) do local tab = make("TextButton", { Parent = tabBar, Text = name, Font = Enum.Font.GothamBold, TextSize = 12, AutoButtonColor = true, Size = UDim2.new(1 / #TABS, -4, 1, 0), BackgroundColor3 = THEME.panel, TextColor3 = THEME.muted, }, { corner(7) }) tabButtons[name] = tab tab.MouseButton1Click:Connect(function() showTab(name) end) end local content = make("Frame", { Parent = mainScreen, Position = UDim2.fromOffset(0, 118), Size = UDim2.new(1, 0, 1, -118), BackgroundTransparency = 1, ClipsDescendants = true }) -- the open clip: a live preview in the widget and everything you can do with it local card = make("Frame", { Parent = content, Position = UDim2.fromOffset(8, 4), Size = UDim2.new(1, -16, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundColor3 = THEME.panel, Visible = false, }, { corner(10), stroke(), pad(8), vlist(6) }) local viewport = make("ViewportFrame", { Parent = card, Size = UDim2.new(1, 0, 0, 150), BackgroundColor3 = Color3.fromRGB(25, 45, 35), Ambient = Color3.fromRGB(160, 165, 162), LightColor = Color3.new(1, 1, 1), LightDirection = Vector3.new(-0.4, -1, 0.5), }, { corner(8) }) local worldModel = make("WorldModel", { Parent = viewport }) local viewCamera = make("Camera", { Parent = viewport, FieldOfView = 30 }) viewport.CurrentCamera = viewCamera local viewNote = make("TextLabel", { Parent = viewport, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1), Text = "", Font = Enum.Font.GothamMedium, TextSize = 12, TextColor3 = THEME.muted, ZIndex = 3, }) local clipTitle = text(card, "", { font = Enum.Font.GothamBold, size = 14 }) local clipMeta = text(card, "", { color = THEME.muted, size = 11 }) local variantRow = make("Frame", { Parent = card, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 26), Visible = false }, { hlist(4) }) -- Which rig this clip is for. R15 is what was generated; R6 is folded from it on request, so -- the toggle simply re-opens the clip and everything downstream follows. R6.rigRow = row(card) R6.r15Button = button(R6.rigRow, "R15", "primary", 0.5) R6.r6Button = button(R6.rigRow, "R6", "ghost", 0.5) function R6.paintRig() local want = (current and current.rig) or "R15" local on, off = STYLES.primary, STYLES.ghost R6.r15Button.BackgroundColor3 = (want == "R15" and on or off)[1] R6.r15Button.TextColor3 = (want == "R15" and on or off)[2] R6.r6Button.BackgroundColor3 = (want == "R6" and on or off)[1] R6.r6Button.TextColor3 = (want == "R6" and on or off)[2] end local previewRow = row(card) local playButton = button(previewRow, "Pause", "ghost", 0.5) local liveButton = button(previewRow, "Preview on rig", "ghost", 0.5) local applyButton = button(card, "Apply to selected rig", "primary", 1) applyButton.Size = UDim2.new(1, 0, 0, 34) local clipRow = row(card) local saveButton = button(clipRow, "Save to library", "ghost", 1 / 3) local publishButton = button(clipRow, "Publish…", "ghost", 1 / 3) local closeButton = button(clipRow, "Close", "ghost", 1 / 3) local clipStatus = text(card, "", { size = 12, color = THEME.muted }) local pagesHolder = make("Frame", { Parent = content, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1) }) local function layoutContent() local top = card.Visible and (card.AbsoluteSize.Y + 10) or 0 pagesHolder.Position = UDim2.fromOffset(0, top) pagesHolder.Size = UDim2.new(1, 0, 1, -top) end card:GetPropertyChangedSignal("AbsoluteSize"):Connect(layoutContent) card:GetPropertyChangedSignal("Visible"):Connect(layoutContent) local function page(name) local frame = make("ScrollingFrame", { Parent = pagesHolder, Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 5, ScrollBarImageColor3 = THEME.line, CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollingDirection = Enum.ScrollingDirection.Y, Visible = false, }, { pad(8, 10, 14), vlist(8) }) pages[name] = frame return frame end local function holder(parent) return make("Frame", { Parent = parent, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y }, { vlist(6) }) end local function heading(parent, value) return text(parent, value, { font = Enum.Font.GothamBold, size = 13 }) end local function setStatus(label, message, color) label.Text = message or "" label.TextColor3 = color or THEME.muted end local function note(err) local message = tostring(err):gsub("^.-:%d+: ", "") return message end ---------------------------------------------------------------- Create local createPage = page("Create") heading(createPage, "Describe a motion") local promptBox = input(createPage, "A person punches, steps backward, then kicks.", "", 66, true) local createLength, createMode, createTakes = 0, "Natural", 1 -- What this account may do, and the controls that answer to it. One table, because the chunk -- is a handful of names away from Luau's 200-local limit and going over stops the plugin dead. local PLAN = { paid = false, note = function() end } local createOptions = row(createPage) choice(createOptions, "Length", { { 0, "Auto" }, { 2, "2 s" }, { 3, "3 s" }, { 4, "4 s" }, { 5, "5 s" }, { 6, "6 s" }, { 8, "8 s" }, { 10, "10 s" }, }, function() return createLength end, function(value) createLength = value end, 0.5) choice(createOptions, "Mode", { { "Natural", "Natural" }, { "In-place", "In place" }, { "Loop", "Loop" } }, function() return createMode end, function(value) createMode = value end, 0.5) PLAN.takesButton, PLAN.takesRefresh = choice(createPage, "Takes of this prompt", { { 1, "1" }, { 2, "2" }, { 3, "3" }, { 4, "4" } }, function() return createTakes end, function(value) if value > 1 and not PLAN.paid then PLAN.note("Several takes of one prompt is a Pro feature.") createTakes = 1 return end createTakes = value end, 1) local createActions = row(createPage) local enhanceButton = button(createActions, "Enhance prompt", "ghost", 0.5) local generateButton = button(createActions, "Generate", "primary", 0.5) local createStatus = text(createPage, "Your prompt runs on the Riggler server; the clip appears in History too.", { size = 12, color = THEME.muted }) PLAN.line = text(createPage, "", { size = 12, color = THEME.muted }) PLAN.note = function(what) createStatus.Text = what .. " Upgrade at " .. settings.server .. "/app/pricing" createStatus.TextColor3 = THEME.amber end PLAN.show = function(user) -- The server decides what an account may do; this only says it out loud, beside Generate. local plan, left = tostring(user.plan or "free"), tonumber(user.free_left) or 0 PLAN.paid = plan == "pro" or plan == "owner" if plan == "owner" then PLAN.line.Text = "You run this server · every feature on" elseif plan == "pro" then PLAN.line.Text = "Riggler Pro · every feature on" elseif left > 0 then PLAN.line.Text = string.format("Free · %d of 3 generations left", left) else PLAN.line.Text = "Free · no generations left · upgrade at " .. settings.server .. "/app/pricing" end PLAN.line.TextColor3 = PLAN.paid and THEME.mint or (left > 0 and THEME.muted or THEME.amber) if not PLAN.paid and createTakes ~= 1 then createTakes = 1 end PLAN.takesRefresh() if not PLAN.paid then PLAN.takesButton.Text = PLAN.takesButton.Text .. " (Pro)" end end heading(createPage, "Try") local EXAMPLES = { "A person walks forward, then waves with the right hand.", "A person punches, steps backward, then kicks.", "A person runs forward, then jumps.", "A person dances in place.", } for _, example in ipairs(EXAMPLES) do local b = button(createPage, example, "ghost", 1) b.TextXAlignment = Enum.TextXAlignment.Left b.Font = Enum.Font.Gotham b.MouseButton1Click:Connect(function() promptBox.Text = example end) end heading(createPage, "From a video") text(createPage, "Pick a clip and Riggler solves it here, the same way the website does. Big files are sent in pieces.", { size = 11, color = THEME.muted }) local videoRow = row(createPage) local videoButton = button(videoRow, "Solve a video…", "ghost", 1) local videoStatus = text(createPage, "", { size = 12, color = THEME.muted }) heading(createPage, "Files") local fileRow = row(createPage) local openFileButton = button(fileRow, "Open motion JSON…", "ghost", 0.5) local packFileButton = button(fileRow, "Import pack file…", "ghost", 0.5) ---------------------------------------------------------------- History / Library / Explore local historyPage = page("History") local historyTop = row(historyPage, 32) local historySearch = input(historyTop, "Search your history…", "", 32) historySearch.Size = UDim2.new(1, -86, 0, 32) local historyRefresh = button(historyTop, "Refresh", "ghost") historyRefresh.Size = UDim2.fromOffset(80, 32) local historyList = holder(historyPage) local libraryPage = page("Library") local libraryTop = row(libraryPage, 32) local librarySearch = input(libraryTop, "Search your library…", "", 32) librarySearch.Size = UDim2.new(1, -86, 0, 32) local libraryRefresh = button(libraryTop, "Refresh", "ghost") libraryRefresh.Size = UDim2.fromOffset(80, 32) local CATEGORY_CHOICES = { { "All", "All" } } for _, name in ipairs({ "Locomotion", "Combat", "Dance", "Sports", "Gestures", "Emotions", "Interactions", "Daily", "Work", "Other" }) do table.insert(CATEGORY_CHOICES, { name, name }) end local libraryCategory = "All" choice(libraryPage, "Category", CATEGORY_CHOICES, function() return libraryCategory end, function(value) libraryCategory = value showTab("Library") end, 1) local libraryList = holder(libraryPage) heading(libraryPage, "Packs") text(libraryPage, "Importing a pack builds every animation in it on the selected rig, into one folder.", { size = 11, color = THEME.muted }) local packList = holder(libraryPage) local explorePage = page("Explore") local exploreCategory = "All" choice(explorePage, "Category", CATEGORY_CHOICES, function() return exploreCategory end, function(value) exploreCategory = value showTab("Explore") end, 1) local exploreList = holder(explorePage) ---------------------------------------------------------------- Settings local settingsPage = page("Settings") heading(settingsPage, "Account") local accountInfo = text(settingsPage, "", { size = 12, color = THEME.muted }) local signOutButton = button(settingsPage, "Sign out of this Studio", "danger", 1) heading(settingsPage, "Server") local serverSetting = input(settingsPage, "http://127.0.0.1:8001", settings.server) serverSetting.FocusLost:Connect(function() settings.server = cleanServer(serverSetting.Text) serverSetting.Text = settings.server serverBox.Text = settings.server saveSettings() end) heading(settingsPage, "Fitting the clip to the rig") local SETTING_ROWS = { { "ik", "Plant feet and join hands (IK)", { { true, "On" }, { false, "Off" } } }, { "floor", "Keep soles above the floor", { { true, "On" }, { false, "Off" } } }, { "restPose", "Compensate A-pose / T-pose rigs", { { true, "On" }, { false, "Off" } } }, { "mirror", "Mirror left and right", { { false, "Off" }, { true, "On" } } }, { "inPlace", "In place (no root travel)", { { false, "Off" }, { true, "On" } } }, { "speed", "Speed", { { 0.5, "0.5×" }, { 0.75, "0.75×" }, { 1, "1×" }, { 1.25, "1.25×" }, { 1.5, "1.5×" }, { 2, "2×" } } }, { "loop", "Loop", { { "Auto", "As generated" }, { "On", "On" }, { "Off", "Off" } } }, { "priority", "Priority", { { "Idle", "Idle" }, { "Movement", "Movement" }, { "Action", "Action" }, { "Action2", "Action2" }, { "Action3", "Action3" }, { "Action4", "Action4" }, { "Core", "Core" } } }, { "animSaves", "List in the Animation Editor", { { true, "On" }, { false, "Off" } } }, { "reduce", "Keyframes", { { 1, "Balanced (1°)" }, { 2, "Light (2°)" }, { 0, "Every frame" } } }, } for _, spec in ipairs(SETTING_ROWS) do local key = spec[1] choice(settingsPage, spec[2], spec[3], function() return settings[key] end, function(value) settings[key] = value saveSettings() if current then task.spawn(function() pcall(bakeView) end) end end, 1) end text(settingsPage, "Mirror, in place and speed change the clip before it meets the rig; the widget preview, Preview on rig and Apply all use these settings.", { size = 11, color = THEME.muted }) local versionLabel = text(settingsPage, "Riggler plugin " .. VERSION .. " · motion format " .. FORMAT, { size = 11, color = THEME.muted }) ---------------------------------------------------------------- behaviour local function updateAccount() local user = session and session.user or {} local name = tostring(user.name or user.email or "Signed in") accountLabel.Text = name accountInfo.Text = "Signed in as " .. tostring(user.email or name) .. " on " .. settings.server serverLine.Text = settings.server:gsub("^https?://", "") .. " · v" .. VERSION PLAN.show(user) end accountButton.MouseButton1Click:Connect(function() showTab("Settings") end) function refreshRig() local rig = selectedRig() local remembered = false if not rig and lastRig and lastRig.Parent then rig, remembered = lastRig, true end if not rig then rigDot.BackgroundColor3 = THEME.amber rigLabel.Text = "Select an R15 or R6 rig in Workspace" return end local extra = "" pcall(function() local _, joints, bind = readRig(rig) local _, count, degrees = restCompensation(bind, pivotsAtBind(joints, bind)) if count > 0 then extra = string.format(" · rests %.0f° off R15%s", degrees, settings.restPose and ", compensated" or "") end end) rigDot.BackgroundColor3 = THEME.mint rigLabel.Text = (remembered and "Rig (last used): " or "Rig: ") .. rig.Name .. " · R15 (" .. jointKind(rig) .. ")" .. extra end Selection.SelectionChanged:Connect(function() refreshRig() end) function showTab(name) activeTab = name for tabName, frame in pairs(pages) do frame.Visible = tabName == name end for tabName, tab in pairs(tabButtons) do tab.BackgroundColor3 = tabName == name and THEME.purpleDim or THEME.panel tab.TextColor3 = tabName == name and Color3.new(1, 1, 1) or THEME.muted end local loader = loaders[name] if loader then task.spawn(function() local ok, err = pcall(loader) if not ok and tostring(err) ~= "Signed out" and pages[name] then local list = ({ History = historyList, Library = libraryList, Explore = exploreList })[name] if list then clear(list) text(list, note(err), { color = THEME.red, size = 12 }) end end end) end end local function describeClip(info) local motion = info.motion local frames = #motion.frames local seconds = tonumber(motion.duration) or ((frames - 1) / (tonumber(motion.fps) or 30)) local source = ({ job = "History", library = "Library", explore = "Explore", community = "Community", file = "File" })[info.kind] or "Clip" local planted = false for _, frame in ipairs(motion.frames) do if type(frame.contact) == "table" and (frame.contact.LeftFoot or frame.contact.RightFoot) then planted = true break end end return string.format("R15 · %d keyframes · %.2fs · %s%s%s", frames, seconds, source, planted and " · feet planted" or "", motion.loop and " · loop" or "") end local function updatePlayButton() playButton.Text = (view and view.playing) and "Pause" or "Play" end local function updateLiveButton() liveButton.Text = live and "Stop rig preview" or "Preview on rig" end -- one prompt, several takes: the job wrote a motion file per variation function renderVariants() clear(variantRow) local count = current and tonumber(current.variations) or 1 variantRow.Visible = count > 1 if not variantRow.Visible then return end text(variantRow, "Takes", { color = THEME.muted, size = 11 }).Size = UDim2.fromOffset(38, 26) for index = 1, count do local pick = button(variantRow, "V" .. index, (current.variant or 1) == index and "primary" or "ghost") pick.Size = UDim2.fromOffset(40, 26) pick.MouseButton1Click:Connect(function() task.spawn(function() local ok, err = pcall(function() openRemote({ kind = "job", id = current.id, title = current.title, prompt = current.prompt, variations = count, variant = index }, string.format("/jobs/%s/motion?variant=%d", current.id, index)) end) if not ok and tostring(err) ~= "Signed out" then setStatus(clipStatus, note(err), THEME.red) end end) end) end end function updateCard() card.Visible = current ~= nil renderVariants() if not current then return end clipTitle.Text = current.title or "Motion" clipMeta.Text = describeClip(current) saveButton.Visible = current.kind ~= "library" saveButton.Size = sized(1 / 3) updatePlayButton() updateLiveButton() layoutContent() end local TINT = { LowerTorso = Color3.fromRGB(168, 35, 56), UpperTorso = Color3.fromRGB(213, 44, 67), Torso = Color3.fromRGB(213, 44, 67), Head = Color3.fromRGB(242, 201, 76) } -- The preview body is whichever kind the clip is for. An R6 clip names six parts that an R15 -- body does not have, so posing it against one keyed nothing at all. local function ensureStock(kind) kind = (kind == "R6") and "R6" or "R15" R6.stocks = R6.stocks or {} if R6.stocks[kind] then if stock and stock ~= R6.stocks[kind] and stock.model then stock.model.Parent = nil -- only one body on the stage at a time end stock = R6.stocks[kind] stock.model.Parent = worldModel return stock end local rig = Players:CreateHumanoidModelFromDescription(Instance.new("HumanoidDescription"), Enum.HumanoidRigType[kind]) rig.Name = "RigglerPreview" .. kind local copy = rig:Clone() for _, item in ipairs(copy:GetDescendants()) do if item:IsA("JointInstance") or item:IsA("Constraint") or item:IsA("BaseScript") or item:IsA("Humanoid") then item:Destroy() end end local parts = {} for _, item in ipairs(copy:GetDescendants()) do if item:IsA("BasePart") then item.Anchored = true item.CanCollide = false local tint = TINT[item.Name] or (item.Name:sub(1, 4) == "Left" and Color3.fromRGB(61, 123, 242)) or (item.Name:sub(1, 5) == "Right" and Color3.fromRGB(242, 112, 61)) if tint then item.Color = tint end parts[item.Name] = item end end if stock and stock.model then stock.model.Parent = nil end copy.Parent = worldModel local _, _, bind = readRig(rig) local low = math.huge for _, name in ipairs({ "LeftFoot", "RightFoot", "Left Leg", "Right Leg" }) do local part = rig:FindFirstChild(name) if part and bind[name] then low = math.min(low, bind[name].Position.Y - part.Size.Y / 2) end end if low == math.huge then low = -3 end if not R6.floor then R6.floor = make("Part", { Parent = worldModel, Anchored = true, Size = Vector3.new(60, 1, 60), CFrame = CFrame.new(0, -0.5, 0), Color = Color3.fromRGB(36, 60, 48), Material = Enum.Material.SmoothPlastic, }) end stock = { rig = rig, parts = parts, model = copy, origin = CFrame.new(0, -low, 0) } R6.stocks[kind] = stock return stock end local function placeView(t) if not (view and stock) then return end local lo, hi, alpha = sampleIndex(view.frames, t) local A, B = view.frames[lo].world, view.frames[hi].world for name, part in pairs(stock.parts) do local a, b = A[name], B[name] if a and b then part.CFrame = stock.origin * a:Lerp(b, alpha) end end local hips = Vector3.new(0, 3, 0) local middle = (A.LowerTorso and B.LowerTorso and "LowerTorso") or (A.Torso and B.Torso and "Torso") if middle then hips = (stock.origin * A[middle]:Lerp(B[middle], alpha)).Position end local target = Vector3.new(hips.X, math.max(1.8, hips.Y), hips.Z) viewCamera.CFrame = CFrame.lookAt(target + Vector3.new(6, 1.8, -10), target) end function bakeView() local info = current if not info then return end viewNote.Text = "Preparing preview…" local s = ensureStock(info.motion.rig) local sequence = buildSequence(transformMotion(info.motion, settings), s.rig, buildOptions(info.title)) local frames = bakeFrames(sequence, s.rig) sequence:Destroy() if current ~= info then return end view = { frames = frames, time = 0, playing = true, length = frames[#frames].time } viewNote.Text = "" placeView(0) updatePlayButton() end function openClip(info) if type(info.motion) ~= "table" or info.motion.format ~= FORMAT or type(info.motion.frames) ~= "table" or #info.motion.frames == 0 then error("That clip is " .. tostring(type(info.motion) == "table" and info.motion.format or "not a motion") .. "; regenerate it with the Riggler server.", 0) end stopLive() current = info current.rig = current.rig or (current.motion.rig == "R6" and "R6" or "R15") R6.paintRig() view = nil updateCard() setStatus(clipStatus, "Select an R15 or R6 rig, then Preview on rig or Apply.") local ok, err = pcall(bakeView) if not ok then viewNote.Text = "Preview unavailable" setStatus(clipStatus, note(err), THEME.red) end end function openRemote(info, path) setStatus(clipStatus, "Loading…") info.path = info.path or path -- so the rig toggle can ask for the same clip again info.rig = info.rig or "R15" info.motion = request("GET", path) openClip(info) end -- Re-open the clip that is already open, folded for the other rig. The server does the fold, so -- this is the same request with one parameter added. function R6.pickRig(which) if not current then error("Open a clip first.", 0) end if (current.rig or "R15") == which then return end if not current.path then error("This clip is not on the server. Open it from History, Library or Explore to switch rigs.", 0) end local path = current.path if which == "R6" then path = path .. (string.find(path, "?", 1, true) and "&" or "?") .. "rig=r6" end setStatus(clipStatus, "Loading " .. which .. "…") openRemote({ kind = current.kind, id = current.id, title = current.title, prompt = current.prompt, variations = current.variations, variant = current.variant, path = current.path, rig = which }, path) end local function animatorFor(rig) local host = rig:FindFirstChildOfClass("Humanoid") or rig:FindFirstChildOfClass("AnimationController") if not host then return nil, false end local animator = host:FindFirstChildOfClass("Animator") if animator then return animator, false end animator = Instance.new("Animator") animator.Archivable = false animator.Parent = host return animator, true end function stopLive() if not live then return end local state = live live = nil pcall(function() state.track:Stop(0) state.animator:StepAnimations(0) end) for _, item in ipairs(state.rig:GetDescendants()) do if item:IsA("Motor6D") or item:IsA("AnimationConstraint") then pcall(function() item.Transform = CFrame.identity end) end end if state.created then state.animator:Destroy() end updateLiveButton() end -- Native playback: the clip, fitted to this rig, registered as a temporary animation and -- stepped by the rig's own Animator each frame, exactly as the game would play it. local function startLive() if not current then error("Open a clip first.", 0) end local rig = targetRig() if not rig then error("Select an R15 or R6 rig in Workspace to preview on it.", 0) end -- A clip folded for one rig cannot pose the other: the part names do not even match, so -- without this it would run, key nothing and look like the plugin had simply failed. local want = (current.motion and current.motion.rig == "R6") and "R6" or "R15" if R6.kindOf(rig) ~= want then error(("That clip is %s and the selected rig is %s. Switch the rig on the clip, or select %s rig.") :format(want, R6.kindOf(rig), want == "R6" and "an R6" or "an R15"), 0) end -- The editor holds the rig by its C0s. Let it go before anything else drives the rig, or the -- preview's Transforms stack on the editor's pose and readRig reads that pose as the bind. if R6.closeEditor then R6.closeEditor() end stopLive() local animator, created = animatorFor(rig) if not animator then error("That rig has no Humanoid or AnimationController to play on.", 0) end local sequence = buildSequence(transformMotion(current.motion, settings), rig, buildOptions(current.title)) sequence.Loop = true local animation = Instance.new("Animation") animation.AnimationId = KeyframeSequenceProvider:RegisterKeyframeSequence(sequence) local track = animator:LoadAnimation(animation) track.Looped = true track:Play(0, 1, 1) live = { rig = rig, animator = animator, created = created, track = track } updateLiveButton() setStatus(clipStatus, "Playing on " .. rig.Name .. " (preview only; nothing is saved).", THEME.mint) end local function saveForAnimationEditor(rig, sequence) local saves = ServerStorage:FindFirstChild("RBX_ANIMSAVES") if not saves then saves = make("Model", { Name = "RBX_ANIMSAVES", Parent = ServerStorage }) end local holderValue = nil for _, child in ipairs(saves:GetChildren()) do if child:IsA("ObjectValue") and child.Value == rig then holderValue = child break end end if not holderValue then holderValue = make("ObjectValue", { Name = rig.Name, Value = rig, Parent = saves }) end local copy = sequence:Clone() local name, n = sequence.Name, 1 while holderValue:FindFirstChild(name) do n = n + 1 name = sequence.Name .. "_" .. n end copy.Name = name copy.Parent = holderValue end local function motionFolder() local folder = ServerStorage:FindFirstChild("Riggler") if not folder then folder = make("Folder", { Name = "Riggler", Parent = ServerStorage }) end return folder end local function applyCurrent() if not current then error("Open a clip first.", 0) end local rig = targetRig() if not rig then error("Select an R15 or R6 rig in Workspace first.", 0) end -- A clip folded for one rig cannot pose the other: the part names do not even match, so -- without this it would run, key nothing and look like the plugin had simply failed. local want = (current.motion and current.motion.rig == "R6") and "R6" or "R15" if R6.kindOf(rig) ~= want then error(("That clip is %s and the selected rig is %s. Switch the rig on the clip, or select %s rig.") :format(want, R6.kindOf(rig), want == "R6" and "an R6" or "an R15"), 0) end if R6.closeEditor then R6.closeEditor() end -- same: apply must see the rig's real bind stopLive() local recording = ChangeHistoryService:TryBeginRecording("Riggler apply") local ok, sequence, count, scale, stats = pcall(buildSequence, transformMotion(current.motion, settings), rig, buildOptions(current.title)) if ok then sequence.Parent = motionFolder() if settings.animSaves then saveForAnimationEditor(rig, sequence) end Selection:Set({ sequence }) lastSequence = sequence end if recording then ChangeHistoryService:FinishRecording(recording, ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel) end if not ok then error(sequence, 0) end if stats.compensated > 0 then print(string.format("[Riggler] rest pose compensation on %s: compensatedJoints=%d maxDeltaDegrees=%.2f", rig.Name, stats.compensated, stats.compensationDegrees)) end return sequence, count, scale, stats, rig end local function applyReport(sequence, count, stats, rig) local notes = {} if stats.plantedFrames > 0 or stats.heldFrames > 0 then table.insert(notes, string.format("IK planted feet on %d foot-frames, held hands on %d", stats.plantedFrames, stats.heldFrames)) end if stats.dropped > 0 then table.insert(notes, string.format("hips lowered on %d (up to %.2f studs)", stats.dropped, stats.maxDrop)) end if stats.lifted > 0 then table.insert(notes, string.format("soles lifted above the floor on %d", stats.lifted)) end if stats.compensated > 0 then table.insert(notes, string.format("rest pose compensated on %d joints (%.1f°)", stats.compensated, stats.compensationDegrees)) end if stats.keys and stats.frames and stats.keys < stats.frames then table.insert(notes, 1, string.format("%d keys from %d frames", stats.keys, stats.frames)) end return string.format("Created %s on %s: %d keyframes in ServerStorage/Riggler%s.%s", sequence.Name, rig.Name, count, settings.animSaves and " and the Animation Editor list" or "", #notes > 0 and (" " .. table.concat(notes, "; ") .. ".") or "") end local function importPack(pack) if type(pack) ~= "table" or pack.format ~= PACK_FORMAT or type(pack.motions) ~= "table" then error("That is not a Riggler pack.", 0) end local rig = targetRig() if not rig then error("Select an R15 or R6 rig in Workspace, then import the pack.", 0) end stopLive() local recording = ChangeHistoryService:TryBeginRecording("Riggler pack import") local folder = make("Folder", { Name = slug(pack.name or "Pack") }) local made, failed = 0, 0 for _, entry in ipairs(pack.motions) do local ok, sequence = pcall(buildSequence, transformMotion(entry.motion, settings), rig, buildOptions(entry.title)) if ok then sequence.Parent = folder made = made + 1 if settings.animSaves then saveForAnimationEditor(rig, sequence) end else failed = failed + 1 end end folder.Parent = motionFolder() Selection:Set({ folder }) if recording then ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) end return folder, made, failed end local function guard(label, fn) return function(...) local args = { ... } task.spawn(function() local ok, err = pcall(fn, table.unpack(args)) if not ok and tostring(err) ~= "Signed out" then setStatus(label, note(err), THEME.red) end end) end end playButton.MouseButton1Click:Connect(function() if view then view.playing = not view.playing updatePlayButton() end end) R6.r15Button.MouseButton1Click:Connect(guard(clipStatus, function() R6.pickRig("R15") end)) R6.r6Button.MouseButton1Click:Connect(guard(clipStatus, function() R6.pickRig("R6") end)) liveButton.MouseButton1Click:Connect(guard(clipStatus, function() if live then stopLive() setStatus(clipStatus, "Rig preview stopped; the rig is back in its rest pose.") else setStatus(clipStatus, "Fitting the clip to the rig…") startLive() end end)) applyButton.MouseButton1Click:Connect(guard(clipStatus, function() setStatus(clipStatus, "Fitting the clip to the rig…") local sequence, count, _, stats, rig = applyCurrent() setStatus(clipStatus, applyReport(sequence, count, stats, rig), THEME.mint) end)) saveButton.MouseButton1Click:Connect(guard(clipStatus, function() if not current then return end local body = { title = current.title } if current.kind == "job" then body.job_id = current.id body.variant = current.variant or 1 elseif current.kind == "explore" then body.explore_id = current.id else body.motion = current.motion end local item = request("POST", "/library", body) setStatus(clipStatus, string.format("Saved “%s” to your library as V%d.", tostring(item.title), tonumber(item.version) or 1), THEME.mint) end)) publishButton.MouseButton1Click:Connect(guard(clipStatus, function() if not (lastSequence and lastSequence.Parent) then error("Apply the clip first; Publish uploads the KeyframeSequence that Apply creates.", 0) end Selection:Set({ lastSequence }) plugin:SaveSelectedToRoblox() end)) closeButton.MouseButton1Click:Connect(function() stopLive() current, view = nil, nil updateCard() end) enhanceButton.MouseButton1Click:Connect(guard(createStatus, function() local prompt = promptBox.Text:gsub("^%s+", ""):gsub("%s+$", "") if prompt == "" then error("Write a prompt first.", 0) end setStatus(createStatus, "Rewriting…") local result = request("POST", "/enhance", { prompt = prompt }) promptBox.Text = result.prompt setStatus(createStatus, "Rewritten by " .. (result.source == "llama" and "Llama 3" or "the keyword planner") .. ": " .. table.concat(result.actions or {}, " → "), THEME.mint) end)) local function generate(prompt) local job = request("POST", "/text-jobs", { prompt = prompt, mode = createMode, duration = createLength > 0 and createLength or nil, variations = createTakes > 1 and createTakes or nil }) for _ = 1, 900 do task.wait(1) local state = request("GET", "/jobs/" .. job.job_id) if state.status == "failed" then error(state.error or "Generation failed", 0) elseif state.status == "complete" then local info = { kind = "job", id = job.job_id, title = state.title or prompt, prompt = prompt, variations = tonumber(state.variations) or createTakes, variant = 1 } openRemote(info, "/jobs/" .. job.job_id .. "/motion") return info end setStatus(createStatus, ({ queued = "Queued…", planning = "Planning the moves…", generating = "Keyframing for R15…" })[state.status] or (tostring(state.status) .. "…")) end error("Generation is taking long; it will appear in History when it finishes.", 0) end generateButton.MouseButton1Click:Connect(guard(createStatus, function() local prompt = promptBox.Text:gsub("^%s+", ""):gsub("%s+$", "") if prompt == "" then error("Describe the motion first.", 0) end generateButton.Active = false generateButton.Text = "Generating…" local ok, err = pcall(generate, prompt) generateButton.Active = true generateButton.Text = "Generate" if not ok then error(err, 0) end setStatus(createStatus, "Ready. It is also in your History.", THEME.mint) end)) -- Studio cannot POST a whole video in one request, so the file goes up in base64 pieces and -- the job that comes back is an ordinary video job: same queue, same history, same result. local function solveVideo(file) local raw = file:GetBinaryContents() local total = #raw local start = request("POST", "/uploads", { filename = file.Name, bytes = total }) local size = tonumber(start.chunk_bytes) or 700000 local sent, index = 0, 0 while sent < total do local piece = raw:sub(sent + 1, math.min(sent + size, total)) request("POST", "/uploads/chunk", { upload_id = start.upload_id, index = index, data = base64(piece) }) sent = sent + #piece index = index + 1 setStatus(videoStatus, string.format("Uploading %s… %d%%", file.Name, math.floor(100 * sent / total))) end setStatus(videoStatus, "Solving the video on the server…") local job = request("POST", "/uploads/finish", { upload_id = start.upload_id }) for _ = 1, 3600 do task.wait(2) local state = request("GET", "/jobs/" .. job.job_id) if state.status == "failed" then error(state.error or "The video could not be solved", 0) elseif state.status == "cancelled" then error("Cancelled.", 0) elseif state.status == "complete" then openRemote({ kind = "job", id = job.job_id, title = state.title or file.Name, variations = 1, variant = 1 }, "/jobs/" .. job.job_id .. "/motion") return state end setStatus(videoStatus, "Solving the video… " .. tostring(state.stage or state.status)) end error("The solve is taking long; it will appear in History when it finishes.", 0) end videoButton.MouseButton1Click:Connect(guard(videoStatus, function() local file = StudioService:PromptImportFile({ "mp4", "mov", "webm" }) if not file then return end videoButton.Active = false local ok, err = pcall(solveVideo, file) videoButton.Active = true if not ok then error(err, 0) end setStatus(videoStatus, "Solved. It is in your History too.", THEME.mint) end)) openFileButton.MouseButton1Click:Connect(guard(createStatus, function() local file = StudioService:PromptImportFile({ "json" }) if not file then return end local data = HttpService:JSONDecode(file:GetBinaryContents()) if type(data) == "table" and data.format == PACK_FORMAT then local folder, made, failed = importPack(data) setStatus(createStatus, string.format("Imported %d animations into ServerStorage/Riggler/%s%s.", made, folder.Name, failed > 0 and string.format(" (%d failed)", failed) or ""), THEME.mint) return end openClip({ kind = "file", title = file.Name:gsub("%.json$", ""), motion = data }) setStatus(createStatus, "Opened " .. file.Name .. ".", THEME.mint) end)) packFileButton.MouseButton1Click:Connect(guard(createStatus, function() local file = StudioService:PromptImportFile({ "json" }) if not file then return end local folder, made, failed = importPack(HttpService:JSONDecode(file:GetBinaryContents())) setStatus(createStatus, string.format("Imported %d animations into ServerStorage/Riggler/%s%s.", made, folder.Name, failed > 0 and string.format(" (%d failed)", failed) or ""), THEME.mint) end)) local function when(created) local ok, value = pcall(os.date, "%b %d %H:%M", math.floor(tonumber(created) or 0)) return ok and value or "" end loaders.History = function() local data = request("GET", "/jobs" .. query({ q = historySearch.Text })) clear(historyList) if #data.jobs == 0 then text(historyList, historySearch.Text ~= "" and "Nothing matches that search." or "No history yet. Generate a motion on the Create tab or the website.", { size = 12, color = THEME.muted }) end for _, job in ipairs(data.jobs) do local meta if job.status == "complete" then meta = string.format("R15 · %s keyframes · %.2fs · %s · %s", tostring(job.frame_count or "?"), tonumber(job.duration) or 0, job.kind == "video" and "video" or "text", when(job.created)) elseif job.status == "failed" then meta = "Failed: " .. tostring(job.error or "unknown error") else meta = "Working: " .. tostring(job.stage or job.status) .. "…" end listRow(historyList, tostring(job.title or job.filename or "Motion"), meta, guard(clipStatus, function() if job.status ~= "complete" or job.kind == "analysis" then return end openRemote({ kind = "job", id = job.id, title = job.title or job.filename, prompt = job.prompt, variations = tonumber(job.variations) or 1, variant = 1 }, "/jobs/" .. job.id .. "/motion") end), job.status == "failed" and Color3.fromRGB(96, 44, 62) or nil) end end historyRefresh.MouseButton1Click:Connect(function() showTab("History") end) historySearch.FocusLost:Connect(function() showTab("History") end) loaders.Library = function() local data = request("GET", "/library" .. query({ q = librarySearch.Text, category = libraryCategory })) clear(libraryList) if #data.items == 0 then text(libraryList, "Nothing saved yet. Save a clip from the card above, or from the website.", { size = 12, color = THEME.muted }) end for _, item in ipairs(data.items) do local tags = table.concat(item.tags or {}, ", ") listRow(libraryList, string.format("%s · V%d", tostring(item.title), tonumber(item.version) or 1), string.format("%s · %.2fs · %s keyframes%s%s", tostring(item.category), tonumber(item.duration) or 0, tostring(item.frame_count or "?"), tags ~= "" and (" · " .. tags) or "", item.public and " · shared" or ""), guard(clipStatus, function() openRemote({ kind = "library", id = item.id, title = item.title, prompt = item.prompt }, "/library/" .. item.id .. "/motion") end)) end local packs = request("GET", "/packs") clear(packList) if #packs.packs == 0 then text(packList, "No packs yet. Make one from your library on the website.", { size = 12, color = THEME.muted }) end for _, pack in ipairs(packs.packs) do listRow(packList, "Import pack: " .. tostring(pack.name), string.format("%d animations · builds them all on the selected rig", #(pack.items or {})), guard(clipStatus, function() setStatus(clipStatus, "Importing " .. tostring(pack.name) .. "…") card.Visible = true local folder, made, failed = importPack(request("GET", "/packs/" .. pack.id .. "/export")) setStatus(clipStatus, string.format("Imported %d animations into ServerStorage/Riggler/%s%s.", made, folder.Name, failed > 0 and string.format(" (%d failed)", failed) or ""), THEME.mint) end), THEME.purpleDim) end end libraryRefresh.MouseButton1Click:Connect(function() showTab("Library") end) librarySearch.FocusLost:Connect(function() showTab("Library") end) loaders.Explore = function() local data = request("GET", "/explore" .. query({ category = exploreCategory })) clear(exploreList) for _, item in ipairs(data.items) do listRow(exploreList, tostring(item.title), string.format("%s · %.1fs · %s", tostring(item.category), tonumber(item.duration) or 0, tostring(item.prompt)), guard(clipStatus, function() openRemote({ kind = "explore", id = item.id, title = item.title, prompt = item.prompt }, "/explore/" .. item.id .. "/motion") end)) end if #data.community > 0 then heading(exploreList, "Shared by the community") for _, item in ipairs(data.community) do listRow(exploreList, tostring(item.title), string.format("%s · %.1fs · by %s", tostring(item.category), tonumber(item.duration) or 0, tostring(item.author or "someone")), guard(clipStatus, function() openRemote({ kind = "community", id = item.id, title = item.title, prompt = item.prompt }, "/library/" .. item.id .. "/motion") end)) end end if #data.items == 0 and #data.community == 0 then text(exploreList, "Nothing in this category yet.", { size = 12, color = THEME.muted }) end end signOutButton.MouseButton1Click:Connect(function() signOut("Signed out.") end) function showSignIn(message) pendingSignIn = nil mainScreen.Visible = false signInScreen.Visible = true codePanel.Visible = false signInButton.Visible = true serverBox.Text = settings.server signInStatus.Text = message or "" end signOut = function(message) local token = session and session.token session = nil plugin:SetSetting(SESSION_KEY, nil) if token then task.spawn(function() pcall(request, "POST", "/auth/logout", nil, { token = token, raw = true }) end) end stopLive() current, view = nil, nil updateCard() showSignIn(message) end function onSignedIn() signInScreen.Visible = false mainScreen.Visible = true serverSetting.Text = settings.server updateAccount() refreshRig() showTab(activeTab) task.spawn(function() -- the server ships the plugin file, so it knows whether this copy is behind local ok, latest = pcall(request, "GET", "/plugin/version", nil, { anonymous = true }) if ok and type(latest) == "table" and type(latest.version) == "string" and latest.version ~= "unknown" and latest.version ~= VERSION then versionLabel.Text = string.format("Riggler plugin %s · version %s is on the server: download it from %s/plugin/MotionForge.lua and restart Studio", VERSION, latest.version, settings.server) versionLabel.TextColor3 = THEME.amber end end) task.spawn(function() local ok, me = pcall(request, "GET", "/auth/me") if ok and type(me) == "table" and session then session.user = me.user plugin:SetSetting(SESSION_KEY, session) accountDot.BackgroundColor3 = THEME.mint updateAccount() elseif not ok and tostring(me) ~= "Signed out" then accountDot.BackgroundColor3 = THEME.red setStatus(createStatus, note(me), THEME.red) end end) end RunService.Heartbeat:Connect(function(dt) if view and view.playing and stock and card.Visible and widget.Enabled and view.length > 0 then view.time = (view.time + dt) % view.length placeView(view.time) end if live then if not live.rig:IsDescendantOf(workspace) then stopLive() else local ok = pcall(function() live.animator:StepAnimations(dt) end) if not ok then stopLive() end end end end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() if not widget.Enabled then stopLive() end end) plugin.Unloading:Connect(function() stopLive() end) -- Automation hook for the Riggler test harness: drives the same functions as the -- buttons. It never exposes the session token. local automation = Instance.new("BindableFunction") automation.Name = "RigglerAutomation" automation.Parent = widget local function automationState() local rig = targetRig() return { version = VERSION, signedIn = session ~= nil, user = session and session.user or nil, server = settings.server, tab = activeTab, rig = rig and rig:GetFullName() or nil, rigText = rigLabel.Text, live = live ~= nil, clip = current and { kind = current.kind, id = current.id, title = current.title, frames = #current.motion.frames } or nil, viewFrames = view and #view.frames or 0, clipStatus = clipStatus.Text, createStatus = createStatus.Text, signInCode = codePanel.Visible and codeLabel.Text or nil, signInLink = codePanel.Visible and linkBox.Text or nil, signInStatus = signInStatus.Text, settings = settings, } end automation.OnInvoke = function(action, args) args = args or {} if action == "state" then return automationState() elseif action == "setting" then settings[args.key] = args.value if args.key == "server" then settings.server = cleanServer(args.value) end saveSettings() return settings[args.key] elseif action == "signIn" then task.spawn(beginSignIn) for _ = 1, 50 do if codePanel.Visible or signInStatus.Text ~= "" then break end task.wait(0.1) end return { code = codeLabel.Text, link = linkBox.Text, error = signInStatus.Text } elseif action == "signOut" then signOut(args.message or "Signed out.") return true elseif action == "tab" then showTab(args.name) task.wait(args.wait or 1) local list = ({ History = historyList, Library = libraryList, Explore = exploreList })[args.name] local rows = {} if list then for _, child in ipairs(list:GetChildren()) do if child:IsA("TextButton") then local first = child:FindFirstChildOfClass("TextLabel") table.insert(rows, first and first.Text or "?") elseif child:IsA("TextLabel") then table.insert(rows, "[" .. child.Text .. "]") end end end return rows elseif action == "open" then local paths = { job = "/jobs/%s/motion", library = "/library/%s/motion", explore = "/explore/%s/motion" } openRemote({ kind = args.kind, id = args.id, title = args.title or args.id }, string.format(paths[args.kind], args.id)) return automationState() elseif action == "generate" then createLength = args.length or 0 local info = generate(args.prompt) return { id = info.id, frames = #current.motion.frames, viewFrames = view and #view.frames or 0 } elseif action == "live" then if args.on then startLive() else stopLive() end return live ~= nil elseif action == "apply" then local sequence, count, _, stats, rig = applyCurrent() return { name = sequence:GetFullName(), keyframes = count, stats = stats, rig = rig.Name, report = applyReport(sequence, count, stats, rig) } elseif action == "importPack" then local folder, made, failed = importPack(args.pack or request("GET", "/packs/" .. args.id .. "/export")) return { folder = folder:GetFullName(), made = made, failed = failed } elseif action == "save" then local body = { title = current.title } if current.kind == "job" then body.job_id = current.id elseif current.kind == "explore" then body.explore_id = current.id else body.motion = current.motion end return request("POST", "/library", body) elseif action == "close" then stopLive() current, view = nil, nil updateCard() return true end error("Unknown automation action " .. tostring(action), 0) end if session then onSignedIn() else showSignIn("") end updateCard() ---------------------------------------------------------------- Edit -- A keyframe editor for the clip that is open. Riggler generates the motion; this is where the -- bits a generator never gets right get fixed -- a hand that clips the hip, a step that lands -- half a frame late -- without leaving Studio and without hand-keying the whole thing. -- -- The shape of it follows Blender's pose mode, because that is the workflow people already know: -- select the part in the viewport, turn it with the tool you already use, and the key is written -- for you. Studio's own Rotate tool is the gizmo; this panel is the dope sheet and the N panel. -- -- The contract stores a dense frame per tick, which is fine to play and miserable to edit: every -- frame is a key, so nothing can be retimed and nothing can be eased. Loading reduces each part -- to the keys that actually carry its shape, editing happens on those, and saving resamples back -- to dense frames. A 91-frame clip typically comes in at eight to fifteen keys a part. -- -- It lives in its own function so its locals have their own budget: the main chunk sits at 198 -- of Luau's 200, and one more there stops the whole plugin loading. function R6.buildEditor(host) -- Fetched here rather than at the top of the file, for that same budget. local TweenService = game:GetService("TweenService") local History = game:GetService("ChangeHistoryService") local EASES = { "Linear", "Sine", "Quad", "Cubic", "Quart", "Back", "Bounce", "Elastic" } local DIRS = { "InOut", "In", "Out" } local TOLERANCE = 0.75 -- degrees a key may be off the straight line before it is kept local MIRROR = { Left = "Right", Right = "Left" } local edit = nil -- { motion, tracks, root, fps, length, parts, posed, joints, rr } local playhead, playing, selected, dragging = 0, false, nil, false local autoKey, clipboard, watch = true, nil, 0 local rows, ticks = {}, {} local picked = {} -- every rig part selected in the viewport, not just the first local dragKey, rangeA, rangeB = nil, nil, nil local onion, pathOn = false, false local ghosts, ghostFolder, pathFolder = {}, nil, nil local gizmo, glow, readout, dragFrom = nil, nil, nil, nil local snap, loopPlay, speed, playRange = true, true, 1, false local zoom, pan = 1, 0 -- how much of the clip the timeline shows, and from where local SPEEDS = { 0.25, 0.5, 1, 2 } -- Forward declarations. Lua resolves a name when the closure is COMPILED, so a handler written -- above one of these would have compiled it as a global and quietly done nothing the moment it -- ran -- which is exactly what the gizmo drag and the easing buttons were doing. Declaring the -- locals here means `function seek(...)` below assigns this local rather than a new global. local seek, setPose, load, nearest local hud = {} -- the viewport bar's widgets; filled in when it is built local togglePlay, toggleOnion, togglePath, toggleAuto, toggleSnap, toggleLoop local toggleRangePlay, cycleSpeed, keyChosen, deleteHere, gotoPrevKey, gotoNextKey, shiftTime ---------------------------------------------------------------- values -- A bone is a rotation, and on an R6 clip a position as well. One CFrame holds both, which -- also gives correct slerp for free when sampling between keys. local function boneCF(bone) local q = bone and bone.rotation local p = bone and bone.position local turn = q and CFrame.new(0, 0, 0, q[1] or 0, q[2] or 0, q[3] or 0, q[4] or 1) or CFrame.identity if p then return CFrame.new(p[1] or 0, p[2] or 0, p[3] or 0) * turn end return turn end local function gap(a, b) local _, angle = a:ToObjectSpace(b):ToAxisAngle() return math.deg(math.abs(angle)) + (a.Position - b.Position).Magnitude * 45 end -- Drop a key only when the line that replaces it still matches the ORIGINAL curve everywhere -- between its neighbours. Checking just the key being dropped is not the same thing: each -- removal is judged against the last reduced shape, so the error compounds -- measured at -- 1.11 degrees out of a 0.75 degree tolerance before this was fixed. local function reduce(keys) local dense, first, last = {}, keys[1].t, keys[#keys].t for index, key in ipairs(keys) do dense[index] = key.cf end local step = (#dense > 1) and ((last - first) / (#dense - 1)) or 0 local function originalAt(t) if step <= 1e-9 then return dense[1] end return dense[math.clamp(math.floor((t - first) / step + 1.5), 1, #dense)] end local changed = true while changed and #keys > 2 do changed = false local i = 2 while i < #keys do local before, after = keys[i - 1], keys[i + 1] local span, worst = after.t - before.t, 0 if span > 1e-6 then local steps = math.max(2, math.ceil(span * 30)) for s = 0, steps do local t = before.t + span * s / steps worst = math.max(worst, gap(before.cf:Lerp(after.cf, (t - before.t) / span), originalAt(t))) if worst >= TOLERANCE then break end end end if worst < TOLERANCE then table.remove(keys, i) changed = true else i = i + 1 end end end return keys end local function sampleTrack(keys, t) if #keys == 0 then return CFrame.identity end if t <= keys[1].t then return keys[1].cf end if t >= keys[#keys].t then return keys[#keys].cf end for i = 1, #keys - 1 do local a, b = keys[i], keys[i + 1] if t <= b.t then local span = b.t - a.t local raw = span > 1e-6 and (t - a.t) / span or 0 return a.cf:Lerp(b.cf, TweenService:GetValue(raw, Enum.EasingStyle[b.ease or "Linear"], Enum.EasingDirection[b.dir or "InOut"])) end end return keys[#keys].cf end local function sampleRoot(t) local keys = edit.root if #keys == 0 then return Vector3.zero end if t <= keys[1].t then return keys[1].v end if t >= keys[#keys].t then return keys[#keys].v end for i = 1, #keys - 1 do local a, b = keys[i], keys[i + 1] if t <= b.t then local span = b.t - a.t return a.v:Lerp(b.v, span > 1e-6 and (t - a.t) / span or 0) end end return keys[#keys].v end local function bonesAt(t) local bones, motor6d = {}, edit.motion.pose_space == "motor6d" for _, name in ipairs(edit.parts) do local cf = sampleTrack(edit.tracks[name], t) local axis, angle = cf:ToAxisAngle() local s = math.sin(angle / 2) local bone = { rotation = { axis.X * s, axis.Y * s, axis.Z * s, math.cos(angle / 2) } } if motor6d then bone.position = { cf.Position.X, cf.Position.Y, cf.Position.Z } end bones[name] = bone end local at = sampleRoot(t) bones.Root = { position = { at.X, at.Y, at.Z } } return bones end ---------------------------------------------------------------- the rig -- Motor6D.Transform does nothing in Edit mode, so the rig is posed through C0 -- the same way -- Roblox's own converters pose a dummy. The defaults are kept so it can be put back. local function restoreRig() if not (edit and edit.posed) then return end for motor, c0 in pairs(edit.posed) do pcall(function() motor.C0 = c0 motor:SetAttribute("RigglerBind", nil) end) end end -- What the rig looks like now, so a part the user turns can be told apart from one we posed. local function markRig() if not edit then return end edit.mark = {} for _, name in ipairs(edit.parts) do local part = edit.rig:FindFirstChild(name) if part then edit.mark[name] = part.CFrame end end end -- The pose each joint needs, worked out straight from the clip. This is what buildSequence -- does minus the apply-time work: no foot IK, no floor planting, no Instances. Scrubbing has -- to be cheap, and IK belongs at apply time anyway -- Blender does not solve constraints to -- move a playhead either. -- The joint pose for every part at one instant. Tracks already hold CFrames, so this stays in -- CFrames the whole way: the old path turned each one into a quaternion table and straight back -- into a CFrame, which is thirty-odd throwaway tables a frame for nothing. local function posesAt(t) local out = {} local travel = sampleRoot(t) * edit.scale if edit.motion.pose_space == "motor6d" then for _, name in ipairs(edit.parts) do local cf = sampleTrack(edit.tracks[name], t) local joint = edit.joints[name] if joint and joint.part0 == edit.rootName and edit.rr[name] then cf = CFrame.new(edit.rr[name]:VectorToObjectSpace(travel)) * cf end out[name] = cf end return out end -- R15: the contract stores each rotation against its parent, so a part's world delta is the -- chain of them, and the joint wants that difference back in its own frame. local delta, seen = {}, {} local function solve(name, depth) if seen[name] then return delta[name] end if depth > 32 then return CFrame.identity end seen[name] = true local parent = parentOf(edit.motion, name) delta[name] = (parent and solve(parent, depth + 1) or CFrame.identity) * sampleTrack(edit.tracks[name], t) return delta[name] end for _, name in ipairs(edit.parts) do solve(name, 0) end -- A rig that rests in an A-pose or T-pose is swung back to the R15 rest first, exactly as -- the apply path does. Leaving this out is what put every arm in the wrong place on entry. for name, turn in pairs(edit.rest) do if delta[name] then delta[name] = delta[name] * turn end end for _, name in ipairs(edit.parts) do local rr = edit.rr[name] local joint = edit.joints[name] if rr and joint then local parent = parentOf(edit.motion, name) local relative = ((parent and delta[parent]) or CFrame.identity):Inverse() * delta[name] if joint.part0 == edit.rootName then relative = CFrame.new(travel) * relative -- the clip's root travel end out[name] = rr:Inverse() * relative * rr end end return out end local function poseRig(t) if not (edit and edit.rig and edit.rig.Parent) then return end local poses = posesAt(t) for name, pose in pairs(poses) do local joint = edit.joints[name] local motor = joint and joint.instance if motor and edit.posed[motor] then -- A part sits at part0 * C0 * Transform * C1^-1. Transform belongs to the animation -- system and cannot be cleared from a plugin in Edit mode, so whatever it holds is -- divided back out here; the pose then lands exactly once instead of twice. motor.C0 = edit.posed[motor] * pose * motor.Transform:Inverse() end end -- Roblox has not moved the parts yet this tick, so marking them now would record the -- previous pose -- and the watcher would read that as the user having moved them. edit.remark = true end -- Where every part sits at one instant, in the rig's own space. Onion skins and motion paths -- both want this, and the plugin already knows how to work it out. -- Where every part sits at one instant, in the rig's own space, from the joint poses rather -- than from a baked sequence: part1 = part0 * C0 * pose * C1^-1, walked from the root. local function worldAt(t) if not edit then return nil end local poses = posesAt(t) local world, seen = { [edit.rootName] = CFrame.identity }, {} local function solve(name, depth) if world[name] then return world[name] end if seen[name] or depth > 32 then return nil end seen[name] = true local joint = edit.joints[name] if not joint then return nil end local parent = solve(joint.part0, depth + 1) if not parent then return nil end world[name] = parent * joint.c0 * (poses[name] or CFrame.identity) * joint.c1:Inverse() return world[name] end for name in pairs(edit.joints) do seen = {} solve(name, 0) end return world end local function clearGhosts() if ghostFolder then ghostFolder:Destroy() end ghostFolder, ghosts = nil, {} end local function clearPath() if pathFolder then pathFolder:Destroy() end pathFolder = nil end -- Onion skins: the pose at the key before and the key after, ghosted in place. Clones are -- anchored and unarchivable so they never end up in the saved place. local function showGhosts() if not (edit and onion) then clearGhosts() return end local hrp = edit.rig:FindFirstChild("HumanoidRootPart") if not hrp then return end if not ghostFolder then ghostFolder = make("Folder", { Parent = edit.rig.Parent or workspace, Name = "RigglerOnion" }) ghostFolder.Archivable = false for _, side in ipairs({ { "before", Color3.fromRGB(120, 165, 255) }, { "after", Color3.fromRGB(255, 172, 110) } }) do local set = {} for _, name in ipairs(edit.parts) do local part = edit.rig:FindFirstChild(name) if part and part:IsA("BasePart") then local ghost = part:Clone() ghost:ClearAllChildren() ghost.Name = side[1] .. " " .. name ghost.Anchored, ghost.CanCollide, ghost.CastShadow = true, false, false ghost.Transparency, ghost.Color = 0.72, side[2] ghost.Material = Enum.Material.SmoothPlastic ghost.Archivable = false ghost.Parent = ghostFolder set[name] = ghost end end ghosts[side[1]] = set end end local keys = selected and edit.tracks[selected] local at = { before = nil, after = nil } if keys then for _, key in ipairs(keys) do if key.t < playhead - 1e-4 and (not at.before or key.t > at.before) then at.before = key.t end if key.t > playhead + 1e-4 and (not at.after or key.t < at.after) then at.after = key.t end end end for side, when in pairs(at) do local world = when and worldAt(when) for name, ghost in pairs(ghosts[side] or {}) do if world and world[name] then ghost.CFrame = hrp.CFrame * world[name] ghost.Transparency = 0.72 else ghost.Transparency = 1 end end end end -- The line a part travels over the whole clip, the way Blender draws a motion path. Rebuilt -- only when it has to be: every dot costs a solve. local function showPath() clearPath() if not (edit and pathOn and selected) then return end local hrp = edit.rig:FindFirstChild("HumanoidRootPart") if not hrp then return end pathFolder = make("Folder", { Parent = edit.rig.Parent or workspace, Name = "RigglerPath" }) pathFolder.Archivable = false local steps = math.clamp(math.floor(edit.length * edit.fps / 2), 8, 70) for index = 0, steps do local t = edit.length * index / steps local world = worldAt(t) if world and world[selected] then local dot = make("Part", { Parent = pathFolder, Anchored = true, CanCollide = false, CastShadow = false, Shape = Enum.PartType.Ball, Size = Vector3.new(0.16, 0.16, 0.16), Material = Enum.Material.Neon, Color = THEME.mint, Transparency = math.abs(t - playhead) < (1 / edit.fps) and 0 or 0.45, CFrame = hrp.CFrame * world[selected], }) dot.Archivable = false end end end local function clearGizmo() if gizmo then gizmo:Destroy() end if glow then glow:Destroy() end if readout then readout:Destroy() end gizmo, glow, readout, dragFrom = nil, nil, nil, nil end -- Handles on the part itself. Everything the panel can do to a pose, the viewport can now do -- too, which is the half of an animation editor a docked panel cannot be. local function showGizmo() if not (edit and selected) then clearGizmo() return end local part = edit.rig:FindFirstChild(selected) if not part then clearGizmo() return end if not gizmo then local core = game:GetService("CoreGui") gizmo = make("ArcHandles", { Parent = core, Name = "RigglerGizmo", Color3 = THEME.mint }) gizmo.Archivable = false glow = make("SelectionBox", { Parent = core, Name = "RigglerPick", Color3 = THEME.mint, LineThickness = 0.03, Transparency = 0.4 }) glow.Archivable = false readout = make("BillboardGui", { Parent = core, Name = "RigglerReadout", Size = UDim2.fromOffset(190, 22), StudsOffset = Vector3.new(0, 2.6, 0), AlwaysOnTop = true }) readout.Archivable = false make("TextLabel", { Parent = readout, BackgroundTransparency = 0.25, BackgroundColor3 = THEME.bg, TextColor3 = THEME.mint, Font = Enum.Font.Code, TextSize = 12, Size = UDim2.fromScale(1, 1), Text = "" }, { corner(5) }) -- A drag turns the pose that is on screen, and lands as a key when it is let go. gizmo.MouseButton1Down:Connect(function() if edit and selected then dragFrom = sampleTrack(edit.tracks[selected], playhead) end end) gizmo.MouseDrag:Connect(function(axis, angle) if not (edit and selected and dragFrom) then return end local turn = (axis == Enum.Axis.X and CFrame.Angles(angle, 0, 0)) or (axis == Enum.Axis.Y and CFrame.Angles(0, angle, 0)) or CFrame.Angles(0, 0, angle) setPose(selected, dragFrom * turn) seek(playhead) end) gizmo.MouseButton1Up:Connect(function() if dragFrom then dragFrom = nil History:SetWaypoint("Riggler: turn " .. tostring(selected)) setStatus(editStatus, string.format("%s keyed at %.2fs", tostring(selected), playhead), THEME.mint) end end) end gizmo.Adornee, glow.Adornee, readout.Adornee = part, part, part end local function showReadout() if not (readout and edit) then return end local label = readout:FindFirstChildOfClass("TextLabel") if label then label.Text = string.format(" %s frame %d %.2fs", tostring(selected), math.floor(playhead * edit.fps + 0.5), playhead) end end -- The pose CFrame buildSequence would have written, read back off the rig. Turning a part with -- Studio's Rotate tool changes where the part sits, so the joint is solved from that. local function poseOf(name) local joint = edit.joints[name] local part1 = edit.rig:FindFirstChild(name) local part0 = joint and edit.rig:FindFirstChild(joint.part0) if not (joint and part0 and part1 and edit.posed[joint.instance]) then return nil end local liveC0 = part0.CFrame:Inverse() * part1.CFrame * joint.c1 return edit.posed[joint.instance]:Inverse() * liveC0 end -- Pose space back to contract space. The plugin writes Pose.CFrame = Rr^-1 * relative * Rr, -- so reading a pose back means undoing that; an R6 clip carries the transform as it is. local function fromPose(name, poseCF) if edit.motion.pose_space == "motor6d" then return poseCF end local rr = edit.rr[name] return rr and (rr * poseCF * rr:Inverse()) or poseCF end ---------------------------------------------------------------- panel local head = row(host) local loadButton = button(head, "Load the open clip", "primary", 0.62) local dropButton = button(head, "Close", "ghost", 0.38) local editStatus = text(host, "Open a clip, select a rig, then load it here to edit its keys.", { size = 12, color = THEME.muted }) text(host, "The editor opens over the viewport, beside the rig it is editing.", { size = 11, color = THEME.muted }) -- The old panel controls still hold the editor's state -- which ease is picked, whether snap -- is on -- so they stay built, just not on the tab. The bar over the viewport is the editor. local vault = make("Frame", { Parent = host, BackgroundTransparency = 1, Visible = false, Size = UDim2.fromOffset(0, 0) }) local body = make("Frame", { Parent = vault, BackgroundTransparency = 1, Visible = false, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y }, { vlist(8) }) local clock = text(body, "", { font = Enum.Font.Code, size = 12 }) local transport = row(body) local startButton = button(transport, "|<", "ghost", 0.2) local prevButton = button(transport, "<", "ghost", 0.2) local playButton = button(transport, "Play", "primary", 0.2) local nextButton = button(transport, ">", "ghost", 0.2) local endButton = button(transport, ">|", "ghost", 0.2) local scrub = make("Frame", { Parent = body, BackgroundColor3 = THEME.field, Active = true, Size = UDim2.new(1, 0, 0, 22) }, { corner(6), stroke() }) local cursor = make("Frame", { Parent = scrub, BackgroundColor3 = THEME.mint, BorderSizePixel = 0, Size = UDim2.new(0, 2, 1, 0) }) local trackList = make("ScrollingFrame", { Parent = body, BackgroundTransparency = 1, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 124), CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 4, ScrollBarImageColor3 = THEME.line }, { vlist(3) }) local pick = text(body, "No part selected", { font = Enum.Font.GothamBold, size = 12 }) -- Blender's Item panel: the numbers, editable, for the part at the playhead. local turnRow = row(body) local turnX = input(turnRow, "X", "0", 26) local turnY = input(turnRow, "Y", "0", 26) local turnZ = input(turnRow, "Z", "0", 26) turnX.Size, turnY.Size, turnZ.Size = sized(1 / 3, 26), sized(1 / 3, 26), sized(1 / 3, 26) text(body, "Rotation in degrees at the playhead. Or turn the part with Studio's Rotate tool.", { size = 11, color = THEME.muted }) local keyRow = row(body) local keyButton = button(keyRow, "Key part", "primary", 0.34) local grabButton = button(keyRow, "Key from rig", "ghost", 0.33) local dropKey = button(keyRow, "Delete key", "danger", 0.33) local moveRow = row(body) local toKey = button(moveRow, "|< key", "ghost", 0.25) local nextKey = button(moveRow, "key >|", "ghost", 0.25) local easeButton = button(moveRow, "Linear", "ghost", 0.25) local dirButton = button(moveRow, "InOut", "ghost", 0.25) local poseRow = row(body) local copyButton = button(poseRow, "Copy pose", "ghost", 0.34) local pasteButton = button(poseRow, "Paste", "ghost", 0.33) local mirrorButton = button(poseRow, "Mirror", "ghost", 0.33) local autoRow = row(body) local autoButton = button(autoRow, "Auto-key · on", "primary", 0.5) local resetButton = button(autoRow, "Reset part", "ghost", 0.5) local viewRow = row(body) local onionButton = button(viewRow, "Onion · off", "ghost", 0.5) local pathButton = button(viewRow, "Path · off", "ghost", 0.5) local playRow = row(body) local focusButton = button(playRow, "Focus rig", "ghost", 0.25) local snapButton = button(playRow, "Snap · on", "primary", 0.25) local loopButton = button(playRow, "Loop · on", "primary", 0.25) local speedButton = button(playRow, "1x", "ghost", 0.25) local timeRow = row(body) local insertButton = button(timeRow, "Insert frame", "ghost", 0.34) local removeButton = button(timeRow, "Remove frame", "ghost", 0.33) local rangePlayButton = button(timeRow, "Play range · off", "ghost", 0.33) -- A time range, so a run of keys can be shifted or stretched together. local rangeRow = row(body) local markA = button(rangeRow, "[ A", "ghost", 0.25) local markB = button(rangeRow, "B ]", "ghost", 0.25) local shiftBack = button(rangeRow, "<< shift", "ghost", 0.25) local shiftOn = button(rangeRow, "shift >>", "ghost", 0.25) local scaleRow = row(body) local squashButton = button(scaleRow, "squash", "ghost", 0.34) local stretchButton = button(scaleRow, "stretch", "ghost", 0.33) local clearRange = button(scaleRow, "clear range", "ghost", 0.33) -- A small curve view: the selected part's rotation on each axis, across the clip. local curve = make("Frame", { Parent = body, BackgroundColor3 = THEME.field, Size = UDim2.new(1, 0, 0, 56), ClipsDescendants = true }, { corner(6), stroke() }) local curveInk = make("Frame", { Parent = curve, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1) }) -- Kept out of the ink layer so the playhead can move without redrawing the curve. local curveHead = make("Frame", { Parent = curve, BackgroundColor3 = THEME.mint, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0) }) local saveRow = row(body) local saveButton = button(saveRow, "Save into the clip", "primary", 0.5) local revertButton = button(saveRow, "Revert", "ghost", 0.5) ---------------------------------------------------------------- drawing -- Where a time sits across the visible window, and back again. Everything the timeline draws -- goes through these, so zooming is one change rather than fifteen. local function seen() return (edit and edit.length or 1) / zoom end local function holdPan() pan = math.clamp(pan, 0, math.max(0, (edit and edit.length or 0) - seen())) end local function atX(t) local width = seen() return width > 0 and (t - pan) / width or 0 end local function timeAt(frac) return pan + frac * seen() end local function inRange(t) return rangeA and rangeB and t >= math.min(rangeA, rangeB) - 1e-4 and t <= math.max(rangeA, rangeB) + 1e-4 end -- Keys are grabbable: press one and drag it along its own row to retime it. local function drawTicks(name) local strip = ticks[name] if not strip then return end strip:ClearAllChildren() for _, key in ipairs(edit.tracks[name]) do local tick = make("TextButton", { Parent = strip, Text = "", AutoButtonColor = false, Active = true, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0), BackgroundColor3 = inRange(key.t) and THEME.amber or ((selected == name) and THEME.mint or THEME.muted), Position = UDim2.new(edit.length > 0 and key.t / edit.length or 0, 0, 0, 3), Size = UDim2.new(0, 7, 1, -6), }, { corner(2) }) tick.MouseButton1Down:Connect(function() dragKey = { name = name, key = key } end) end end local function showNumbers() if not (edit and selected) then turnX.Text, turnY.Text, turnZ.Text = "", "", "" return end local x, y, z = sampleTrack(edit.tracks[selected], playhead):ToEulerAnglesXYZ() turnX.Text = string.format("%.1f", math.deg(x)) turnY.Text = string.format("%.1f", math.deg(y)) turnZ.Text = string.format("%.1f", math.deg(z)) if hud.turnX and not hud.turnX:IsFocused() then hud.turnX.Text = turnX.Text end if hud.turnY and not hud.turnY:IsFocused() then hud.turnY.Text = turnY.Text end if hud.turnZ and not hud.turnZ:IsFocused() then hud.turnZ.Text = turnZ.Text end end local AXES = { { "X", Color3.fromRGB(255, 120, 140) }, { "Y", Color3.fromRGB(140, 230, 160) }, { "Z", Color3.fromRGB(120, 170, 255) } } local function drawCurve() curveInk:ClearAllChildren() if hud.curveInk then hud.curveInk:ClearAllChildren() end if not (edit and selected) then return end local keys = edit.tracks[selected] local samples, most = {}, 1e-3 for step = 0, 47 do local t = edit.length * step / 47 local x, y, z = sampleTrack(keys, t):ToEulerAnglesXYZ() local turn = { math.deg(x), math.deg(y), math.deg(z) } samples[step] = turn for _, value in ipairs(turn) do most = math.max(most, math.abs(value)) end end for axis, spec in ipairs(AXES) do for step = 0, 47 do local where = UDim2.new(step / 47, 0, 0.5 - (samples[step][axis] / most) * 0.42, 0) make("Frame", { Parent = curveInk, BorderSizePixel = 0, BackgroundColor3 = spec[2], AnchorPoint = Vector2.new(0.5, 0.5), Position = where, Size = UDim2.fromOffset(2, 2) }) if hud.curveInk then make("Frame", { Parent = hud.curveInk, BorderSizePixel = 0, BackgroundColor3 = spec[2], AnchorPoint = Vector2.new(0.5, 0.5), Position = where, Size = UDim2.fromOffset(2, 2) }) end end end for _, key in ipairs(keys) do make("Frame", { Parent = curveInk, BorderSizePixel = 0, BackgroundColor3 = THEME.text, AnchorPoint = Vector2.new(0.5, 0), Size = UDim2.fromOffset(1, 6), Position = UDim2.new(edit.length > 0 and key.t / edit.length or 0, 0, 0, 2), }) end end -- Split in two: what moves every frame, and what only changes when the keys do. Rebuilding -- the ruler, the ticks and 150 curve samples on every playhead step was most of the cost of -- scrubbing. local function refreshLight() if not edit then return end clock.Text = string.format("%05.2f / %05.2f frame %d", playhead, edit.length, math.floor(playhead * edit.fps + 0.5)) cursor.Position = UDim2.new(edit.length > 0 and playhead / edit.length or 0, -1, 0, 0) local at = UDim2.new(edit.length > 0 and playhead / edit.length or 0, -1, 0, 0) if curveHead then curveHead.Position = at end if hud.curveHead then hud.curveHead.Position = at end if not playing then showNumbers() end if R6.overlayLight then R6.overlayLight() end end local function refresh() if not edit then return end local total = 0 for _, keys in pairs(edit.tracks) do total = total + #keys end clock.Text = string.format("%05.2f / %05.2f frame %d %d keys", playhead, edit.length, math.floor(playhead * edit.fps + 0.5), total) cursor.Position = UDim2.new(edit.length > 0 and playhead / edit.length or 0, -1, 0, 0) for _, name in ipairs(edit.parts) do if rows[name] then rows[name].BackgroundColor3 = (selected == name) and THEME.panel2 or THEME.panel end end local many = 0 for _ in pairs(picked) do many = many + 1 end pick.Text = selected and (selected .. " · " .. #edit.tracks[selected] .. " keys" .. (many > 1 and (" · " .. many .. " parts selected") or "")) or "No part selected" showNumbers() drawCurve() if R6.overlayDraw then R6.overlayDraw() end end local function selectPart(name) selected = name picked = { [name] = true } for _, other in ipairs(edit.parts) do drawTicks(other) end refresh() showGizmo() showGhosts() showPath() end -- Whatever is selected in the viewport, or just the primary part when nothing else is. local function chosen() local list = {} for name in pairs(picked) do if edit.tracks[name] then table.insert(list, name) end end if #list == 0 and selected then list = { selected } end table.sort(list) return list end local function buildTracks() trackList:ClearAllChildren() make("UIListLayout", { Parent = trackList, Padding = UDim.new(0, 3), SortOrder = Enum.SortOrder.LayoutOrder }) rows, ticks = {}, {} for index, name in ipairs(edit.parts) do local line = make("TextButton", { Parent = trackList, Text = "", AutoButtonColor = false, LayoutOrder = index, BackgroundColor3 = THEME.panel, Size = UDim2.new(1, -4, 0, 22), }, { corner(6) }) make("TextLabel", { Parent = line, BackgroundTransparency = 1, Text = name, Font = Enum.Font.Gotham, TextSize = 11, TextColor3 = THEME.text, TextXAlignment = Enum.TextXAlignment.Left, Position = UDim2.fromOffset(8, 0), Size = UDim2.new(0, 86, 1, 0), }) ticks[name] = make("Frame", { Parent = line, BackgroundTransparency = 1, Position = UDim2.new(0, 96, 0, 0), Size = UDim2.new(1, -104, 1, 0) }) rows[name] = line line.InputChanged:Connect(function(i) if dragKey and dragKey.name == name and i.UserInputType == Enum.UserInputType.MouseMovement then local strip = ticks[name] local across = (i.Position.X - strip.AbsolutePosition.X) / math.max(1, strip.AbsoluteSize.X) dragKey.key.t = math.clamp(across * edit.length, 0, edit.length) table.sort(edit.tracks[name], function(a, b) return a.t < b.t end) drawTicks(name) end end) line.InputEnded:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 and dragKey then dragKey = nil seek(playhead) History:SetWaypoint("Riggler: retime key") end end) line.MouseButton1Click:Connect(function() selectPart(name) local part = edit.rig:FindFirstChild(name) if part then Selection:Set({ part }) end -- the viewport follows the panel end) drawTicks(name) end end ---------------------------------------------------------------- keys -- Put the camera on the rig, the way View Selected does. CameraType is left as it is: forcing -- it to Scriptable is what kills right-drag orbit in Edit mode. local function focusRig() if not (edit and edit.rig and edit.rig.Parent) then return end local camera = workspace.CurrentCamera if not camera then return end local pivot, size = edit.rig:GetBoundingBox() local away = math.max(6, size.Magnitude * 1.35) local from = pivot.Position + Vector3.new(0.55, 0.38, 1).Unit * away camera.CFrame = CFrame.lookAt(from, pivot.Position) camera.Focus = CFrame.new(pivot.Position) end function seek(t, exact) if not edit then return end if snap and not exact and edit.fps > 0 then t = math.floor(t * edit.fps + 0.5) / edit.fps -- land on whole frames end playhead = math.clamp(t, 0, edit.length) poseRig(playhead) refreshLight() -- Ghosts are two more solves, and the readout is a string build: neither is worth doing -- sixty times a second while the clip is playing. if not playing then showReadout() if not dragFrom then showGhosts() end end end local function keyAt(name, t) local keys = edit.tracks[name] for _, key in ipairs(keys) do if math.abs(key.t - t) < 1e-4 then return key end end local key = { t = t, cf = sampleTrack(keys, t), ease = "Linear", dir = "InOut" } table.insert(keys, key) table.sort(keys, function(a, b) return a.t < b.t end) return key end function setPose(name, cf) local key = keyAt(name, playhead) key.cf = cf drawTicks(name) end -- Read the whole rig back: every part that has been turned becomes a key. local function captureRig(only) if not edit then error("Nothing is loaded.", 0) end local taken = {} for _, name in ipairs(edit.parts) do if not only or only == name then local poseCF = poseOf(name) if poseCF then local want = fromPose(name, poseCF) if not only then local mark = edit.mark and edit.mark[name] local part = edit.rig:FindFirstChild(name) if mark and part and gap(mark, part.CFrame) < 0.05 then poseCF = nil -- untouched since we posed it end end if poseCF then setPose(name, want) table.insert(taken, name) end end end end if #taken == 0 then error("Nothing has moved. Turn a part with Studio's Rotate tool, then key it.", 0) end seek(playhead) History:SetWaypoint("Riggler: key from rig") return taken end ---------------------------------------------------------------- loading and saving local function unload() if R6.overlaySet then R6.overlaySet(false) end restoreRig() clearGizmo() clearGhosts() clearPath() rangeA, rangeB, picked = nil, nil, {} edit, selected, playing = nil, nil, false body.Visible = false playButton.Text = "Play" setStatus(editStatus, "Open a clip, select a rig, then load it here to edit its keys.") end function load() if not current then error("Open a clip first, from Create, History, Library or Explore.", 0) end local rig = targetRig() if not rig then error("Select an R15 or R6 rig in Workspace to edit against.", 0) end local motion = current.motion local fps = tonumber(motion.fps) or 30 local frames = motion.frames local length = tonumber(frames[#frames].time) or ((#frames - 1) / fps) local parts, tracks = {}, {} for name in pairs(frames[1].bones or {}) do if name ~= "Root" then table.insert(parts, name) end end table.sort(parts) for _, name in ipairs(parts) do local keys = {} for index, frame in ipairs(frames) do table.insert(keys, { t = tonumber(frame.time) or ((index - 1) / fps), cf = boneCF((frame.bones or {})[name]), ease = "Linear", dir = "InOut" }) end tracks[name] = reduce(keys) end local root = {} for index, frame in ipairs(frames) do local p = (frame.bones and frame.bones.Root and frame.bones.Root.position) or { 0, 0, 0 } table.insert(root, { t = tonumber(frame.time) or ((index - 1) / fps), v = Vector3.new(p[1] or 0, p[2] or 0, p[3] or 0) }) end -- Take the rig cleanly. Only the animation system can clear a Motor6D's Transform, so the -- way to drop one left behind by a preview is to stop the track and step the animator once. stopLive() for _, item in ipairs(rig:GetDescendants()) do if item:IsA("Animator") then pcall(function() for _, playing in ipairs(item:GetPlayingAnimationTracks()) do playing:Stop(0) end item:StepAnimations(0) end) end end local posed, held = {}, 0 for _, item in ipairs(rig:GetDescendants()) do if item:IsA("Motor6D") then local _, angle = (item.Transform - item.Transform.Position):ToAxisAngle() held = math.max(held, math.deg(math.abs(angle))) posed[item] = item.C0 -- C0 is saved with the place. If Studio closes or saves while the editor holds -- this rig, the posed value is what survives -- so the real bind rides along on -- the joint and is put back at startup. item:SetAttribute("RigglerBind", item.C0) end end local rootName, joints, bind = readRig(rig) local rr = {} for name, joint in pairs(joints) do local parent = bind[joint.part0] if parent then rr[name] = (parent * joint.c0).Rotation end end -- The same compensation and scale the apply path works out, done once for this rig. local restTurns = {} if settings.restPose ~= false then restTurns = restCompensation(bind, pivotsAtBind(joints, bind)) end local reach = legHeight(joints, bind) local sourceHip = tonumber(motion.source_hip_height_m) local scale = (reach > 0 and sourceHip and sourceHip > 0) and (reach / sourceHip) or FALLBACK_STUDS_PER_METRE edit = { motion = motion, title = current.title, rig = rig, parts = parts, tracks = tracks, root = root, fps = fps, length = length, posed = posed, joints = joints, rr = rr, rootName = rootName, rest = restTurns, scale = scale } playhead, selected, playing = 0, parts[1], false buildTracks() body.Visible = true showGizmo() if R6.overlaySet then R6.overlaySet(true) end seek(0) focusRig() -- look at what you are about to pose local kept = 0 for _, keys in pairs(tracks) do kept = kept + #keys end setStatus(editStatus, string.format("%d frames reduced to %d keys across %d parts on %s.%s", #frames, kept, #parts, rig.Name, held > 1 and string.format(" (%.0f deg of animation was still on the joints; it is divided out)", held) or ""), THEME.mint) end local function save() if not edit then error("Nothing is loaded.", 0) end local frames, step = {}, 1 / edit.fps local count = math.max(2, math.floor(edit.length / step + 0.5) + 1) for index = 1, count do local t = math.min(edit.length, (index - 1) * step) table.insert(frames, { time = t, bones = bonesAt(t) }) end local source = edit.motion.frames for index, frame in ipairs(frames) do local nearest = math.clamp(math.floor(frame.time * edit.fps + 0.5) + 1, 1, #source) if source[nearest].contact then frames[index].contact = source[nearest].contact end end local out = {} for key, value in pairs(edit.motion) do out[key] = value end out.frames = frames out.duration = edit.length current.motion = out edit.motion = out restoreRig() openClip(current) setStatus(editStatus, string.format("Saved %d frames into the clip. Apply, save or publish as usual.", #frames), THEME.mint) end ---------------------------------------------------------------- wiring loadButton.MouseButton1Click:Connect(guard(editStatus, load)) dropButton.MouseButton1Click:Connect(guard(editStatus, unload)) saveButton.MouseButton1Click:Connect(guard(editStatus, save)) revertButton.MouseButton1Click:Connect(guard(editStatus, function() restoreRig() load() end)) startButton.MouseButton1Click:Connect(function() seek(0) end) endButton.MouseButton1Click:Connect(function() if edit then seek(edit.length) end end) prevButton.MouseButton1Click:Connect(function() if edit then seek(playhead - 1 / edit.fps) end end) nextButton.MouseButton1Click:Connect(function() if edit then seek(playhead + 1 / edit.fps) end end) -- Every action is a named function so the panel and the viewport bar can both call it. function togglePlay() if not edit then return end playing = not playing playButton.Text = playing and "Pause" or "Play" if R6.overlayDraw then R6.overlayDraw() end end playButton.MouseButton1Click:Connect(togglePlay) local function fromX(x) if not edit then return end seek((x - scrub.AbsolutePosition.X) / math.max(1, scrub.AbsoluteSize.X) * edit.length) end scrub.InputBegan:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then dragging = true fromX(i.Position.X) end end) scrub.InputChanged:Connect(function(i) if dragging and i.UserInputType == Enum.UserInputType.MouseMovement then fromX(i.Position.X) end end) scrub.InputEnded:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then dragging = false end end) function nearest(dir) if not (edit and selected) then return nil end local best for _, key in ipairs(edit.tracks[selected]) do if dir == 0 then if not best or math.abs(key.t - playhead) < math.abs(best.t - playhead) then best = key end elseif dir > 0 and key.t > playhead + 1e-4 then if not best or key.t < best.t then best = key end elseif dir < 0 and key.t < playhead - 1e-4 then if not best or key.t > best.t then best = key end end end return best end function keyChosen() if not (edit and selected) then error("Pick a part first.", 0) end local names = chosen() for _, name in ipairs(names) do keyAt(name, playhead) drawTicks(name) end refresh() History:SetWaypoint("Riggler: key") setStatus(editStatus, "Keyed " .. table.concat(names, ", "), THEME.mint) end keyButton.MouseButton1Click:Connect(guard(editStatus, keyChosen)) grabButton.MouseButton1Click:Connect(guard(editStatus, function() local taken = captureRig(nil) setStatus(editStatus, "Keyed from the rig: " .. table.concat(taken, ", "), THEME.mint) end)) function deleteHere() if not (edit and selected) then error("Pick a part first.", 0) end local keys = edit.tracks[selected] if #keys <= 1 then error("A part keeps at least one key.", 0) end for index, key in ipairs(keys) do if math.abs(key.t - playhead) < 1e-4 then table.remove(keys, index) drawTicks(selected) seek(playhead) return end end error("No key at the playhead. Use |< key to land on one.", 0) end dropKey.MouseButton1Click:Connect(guard(editStatus, deleteHere)) function gotoPrevKey() local key = nearest(-1) or nearest(0) if key then seek(key.t) end end function gotoNextKey() local key = nearest(1) or nearest(0) if key then seek(key.t) end end toKey.MouseButton1Click:Connect(guard(editStatus, gotoPrevKey)) nextKey.MouseButton1Click:Connect(guard(editStatus, gotoNextKey)) easeButton.MouseButton1Click:Connect(guard(editStatus, function() local key = nearest(0) if not key then error("Pick a part first.", 0) end key.ease = EASES[((table.find(EASES, key.ease) or 1) % #EASES) + 1] easeButton.Text = key.ease seek(playhead) end)) dirButton.MouseButton1Click:Connect(guard(editStatus, function() local key = nearest(0) if not key then error("Pick a part first.", 0) end key.dir = DIRS[((table.find(DIRS, key.dir) or 1) % #DIRS) + 1] dirButton.Text = key.dir seek(playhead) end)) local function readNumbers() if not (edit and selected) then return end local x = tonumber(turnX.Text) or 0 local y = tonumber(turnY.Text) or 0 local z = tonumber(turnZ.Text) or 0 local was = sampleTrack(edit.tracks[selected], playhead) setPose(selected, CFrame.new(was.Position) * CFrame.Angles(math.rad(x), math.rad(y), math.rad(z))) seek(playhead) History:SetWaypoint("Riggler: pose " .. selected) end for _, box in ipairs({ turnX, turnY, turnZ }) do box.FocusLost:Connect(function(enter) if enter then readNumbers() end end) end copyButton.MouseButton1Click:Connect(guard(editStatus, function() if not edit then error("Nothing is loaded.", 0) end clipboard = {} for _, name in ipairs(edit.parts) do clipboard[name] = sampleTrack(edit.tracks[name], playhead) end setStatus(editStatus, "Pose copied. Move the playhead and paste it.", THEME.mint) end)) pasteButton.MouseButton1Click:Connect(guard(editStatus, function() if not clipboard then error("Copy a pose first.", 0) end for name, cf in pairs(clipboard) do if edit.tracks[name] then setPose(name, cf) end end seek(playhead) History:SetWaypoint("Riggler: paste pose") end)) mirrorButton.MouseButton1Click:Connect(guard(editStatus, function() if not edit then error("Nothing is loaded.", 0) end -- Across the rig's own YZ plane: a rotation flips on two axes, a sideways offset on one. local swapped = {} for _, name in ipairs(edit.parts) do local other = name for side, twin in pairs(MIRROR) do if string.sub(name, 1, #side) == side then other = twin .. string.sub(name, #side + 1) end end local cf = sampleTrack(edit.tracks[other] and edit.tracks[other] or edit.tracks[name], playhead) local axis, angle = cf:ToAxisAngle() local s = math.sin(angle / 2) swapped[name] = CFrame.new(-cf.Position.X, cf.Position.Y, cf.Position.Z) * CFrame.new(0, 0, 0, axis.X * s, -axis.Y * s, -axis.Z * s, math.cos(angle / 2)) end for name, cf in pairs(swapped) do setPose(name, cf) end seek(playhead) History:SetWaypoint("Riggler: mirror pose") end)) -- Insert or remove a frame's worth of time at the playhead, shifting everything after it. function shiftTime(direction) if not edit then error("Nothing is loaded.", 0) end local step = direction / edit.fps for _, name in ipairs(edit.parts) do for _, key in ipairs(edit.tracks[name]) do if key.t > playhead + 1e-4 then key.t = math.max(0, key.t + step) end end table.sort(edit.tracks[name], function(a, b) return a.t < b.t end) end for _, key in ipairs(edit.root) do if key.t > playhead + 1e-4 then key.t = math.max(0, key.t + step) end end edit.length = math.max(1 / edit.fps, edit.length + step) for _, name in ipairs(edit.parts) do drawTicks(name) end seek(playhead) History:SetWaypoint("Riggler: " .. (direction > 0 and "insert" or "remove") .. " time") end function toggleSnap() snap = not snap snapButton.Text = "Snap · " .. (snap and "on" or "off") snapButton.BackgroundColor3 = (snap and STYLES.primary or STYLES.ghost)[1] if R6.overlayDraw then R6.overlayDraw() end end function toggleLoop() loopPlay = not loopPlay loopButton.Text = "Loop · " .. (loopPlay and "on" or "off") loopButton.BackgroundColor3 = (loopPlay and STYLES.primary or STYLES.ghost)[1] if R6.overlayDraw then R6.overlayDraw() end end function cycleSpeed() local at = table.find(SPEEDS, speed) or 3 speed = SPEEDS[(at % #SPEEDS) + 1] speedButton.Text = tostring(speed) .. "x" if R6.overlayDraw then R6.overlayDraw() end end function toggleRangePlay() playRange = not playRange rangePlayButton.Text = "Play range · " .. (playRange and "on" or "off") rangePlayButton.BackgroundColor3 = (playRange and STYLES.primary or STYLES.ghost)[1] if R6.overlayDraw then R6.overlayDraw() end end focusButton.MouseButton1Click:Connect(guard(editStatus, focusRig)) snapButton.MouseButton1Click:Connect(toggleSnap) loopButton.MouseButton1Click:Connect(toggleLoop) speedButton.MouseButton1Click:Connect(cycleSpeed) insertButton.MouseButton1Click:Connect(guard(editStatus, function() shiftTime(1) end)) removeButton.MouseButton1Click:Connect(guard(editStatus, function() shiftTime(-1) end)) rangePlayButton.MouseButton1Click:Connect(toggleRangePlay) function toggleAuto() autoKey = not autoKey autoButton.Text = "Auto-key · " .. (autoKey and "on" or "off") autoButton.BackgroundColor3 = (autoKey and STYLES.primary or STYLES.ghost)[1] if R6.overlayDraw then R6.overlayDraw() end end autoButton.MouseButton1Click:Connect(toggleAuto) resetButton.MouseButton1Click:Connect(guard(editStatus, function() if not (edit and selected) then error("Pick a part first.", 0) end for _, name in ipairs(chosen()) do setPose(name, CFrame.new(sampleTrack(edit.tracks[name], playhead).Position)) end seek(playhead) History:SetWaypoint("Riggler: reset pose") end)) function toggleOnion() onion = not onion onionButton.Text = "Onion · " .. (onion and "on" or "off") onionButton.BackgroundColor3 = (onion and STYLES.primary or STYLES.ghost)[1] showGhosts() if R6.overlayDraw then R6.overlayDraw() end end function togglePath() pathOn = not pathOn pathButton.Text = "Path · " .. (pathOn and "on" or "off") pathButton.BackgroundColor3 = (pathOn and STYLES.primary or STYLES.ghost)[1] showPath() if R6.overlayDraw then R6.overlayDraw() end end onionButton.MouseButton1Click:Connect(guard(editStatus, toggleOnion)) pathButton.MouseButton1Click:Connect(guard(editStatus, togglePath)) local function redrawAll() for _, name in ipairs(edit.parts) do drawTicks(name) end refresh() end markA.MouseButton1Click:Connect(guard(editStatus, function() rangeA = playhead redrawAll() end)) markB.MouseButton1Click:Connect(guard(editStatus, function() rangeB = playhead redrawAll() end)) clearRange.MouseButton1Click:Connect(guard(editStatus, function() rangeA, rangeB = nil, nil redrawAll() end)) -- Shifting and stretching work on the keys inside the range, on every selected part. local function overRange(move) if not (edit and rangeA and rangeB) then error("Mark a range with [ A and B ] first.", 0) end local lo, hi = math.min(rangeA, rangeB), math.max(rangeA, rangeB) for _, name in ipairs(chosen()) do for _, key in ipairs(edit.tracks[name]) do if key.t >= lo - 1e-4 and key.t <= hi + 1e-4 then key.t = math.clamp(move(key.t, lo, hi), 0, edit.length) end end table.sort(edit.tracks[name], function(a, b) return a.t < b.t end) end redrawAll() seek(playhead) History:SetWaypoint("Riggler: retime range") end shiftBack.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(t) return t - 1 / edit.fps end) end)) shiftOn.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(t) return t + 1 / edit.fps end) end)) squashButton.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(t, lo) return lo + (t - lo) * 0.9 end) end)) stretchButton.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(t, lo) return lo + (t - lo) * 1.1 end) end)) -- The viewport drives the panel, the way selecting a bone does in pose mode. -- The viewport drives the panel, and it can hand over several parts at once. Selection.SelectionChanged:Connect(function() if not edit then return end local set, first = {}, nil for _, item in ipairs(Selection:Get()) do if item:IsA("BasePart") and item.Parent == edit.rig and edit.tracks[item.Name] then set[item.Name] = true first = first or item.Name end end if not first then return end local changed = first ~= selected selected, picked = first, set for _, name in ipairs(edit.parts) do drawTicks(name) end refresh() if changed then showGizmo() showGhosts() showPath() end end) RunService.Heartbeat:Connect(function(dt) if not edit then return end if playing then local lo = (playRange and rangeA and rangeB) and math.min(rangeA, rangeB) or 0 local hi = (playRange and rangeA and rangeB) and math.max(rangeA, rangeB) or edit.length local t = playhead + dt * speed if t > hi then if loopPlay then t = lo else t = hi playing = false playButton.Text = "Play" end end seek(t, true) -- playback keeps its sub-frame remainder if zoom > 1 and (playhead < pan or playhead > pan + seen()) then pan = playhead - seen() * 0.5 holdPan() if R6.overlaySlide then R6.overlaySlide() end end return end -- Watch for a part the user has turned with Studio's own tools. Cheap, and only while -- the editor is open: this is what makes posing feel like pose mode rather than a form. if edit.remark then edit.remark = false markRig() -- the parts have settled now; this is our own pose return end watch = watch + dt if watch < 0.2 or dragging or dragFrom then return end watch = 0 local moved = nil for _, name in ipairs(edit.parts) do local part = edit.rig:FindFirstChild(name) local mark = edit.mark and edit.mark[name] -- Turned, or shifted: judged apart so float drift in one cannot trip the other. if part and mark then local _, angle = mark:ToObjectSpace(part.CFrame):ToAxisAngle() if math.deg(math.abs(angle)) > 0.4 or (mark.Position - part.CFrame.Position).Magnitude > 0.02 then moved = name break end end end if not moved then return end if autoKey then local ok, err = pcall(captureRig, nil) if ok then setStatus(editStatus, "Keyed " .. moved .. " at " .. string.format("%.2fs", playhead), THEME.mint) else setStatus(editStatus, note(err), THEME.red) end else setStatus(editStatus, moved .. " has moved. Key from rig to keep it.", THEME.amber) end end) ---------------------------------------------------------------- the viewport HUD -- Parented to CoreGui rather than a dock widget, so it floats over the 3D view beside the rig -- it is editing. Hidden until a clip is loaded, and put away with it. local screen = make("ScreenGui", { Parent = game:GetService("CoreGui"), Name = "RigglerEditorHUD", Enabled = false, ZIndexBehavior = Enum.ZIndexBehavior.Sibling }) screen.Archivable = false local bar = make("Frame", { Parent = screen, AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 1, -14), Size = UDim2.new(0, 1040, 0, 330), BackgroundColor3 = THEME.bg, BackgroundTransparency = 0.06 }, { corner(10), stroke() }) local function hudButton(label, x, y, width, kind) local style = STYLES[kind or "ghost"] return make("TextButton", { Parent = bar, Text = label, AutoButtonColor = true, Font = Enum.Font.GothamBold, TextSize = 12, BackgroundColor3 = style[1], TextColor3 = style[2], Position = UDim2.fromOffset(x, y), Size = UDim2.fromOffset(width, 24), }, { corner(6), stroke(style[3]) }) end local function hudText(x, y, width, size) return make("TextLabel", { Parent = bar, BackgroundTransparency = 1, Text = "", Font = Enum.Font.Code, TextSize = size or 12, TextColor3 = THEME.text, TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd, Position = UDim2.fromOffset(x, y), Size = UDim2.fromOffset(width, 20) }) end local hudStart = hudButton("|<", 12, 10, 32) local hudPrev = hudButton("<", 48, 10, 32) local hudPlay = hudButton("Play", 84, 10, 52, "primary") local hudNext = hudButton(">", 140, 10, 32) local hudEnd = hudButton(">|", 176, 10, 32) local hudLoop = hudButton("Loop", 212, 10, 50, "primary") local hudSpeed = hudButton("1x", 266, 10, 44) local hudSnap = hudButton("Snap", 314, 10, 52, "primary") local hudFocus = hudButton("Focus", 370, 10, 58) hud.zoomOut = hudButton("-", 902, 294, 30) hud.zoomIn = hudButton("+", 936, 294, 30) hud.zoomFit = hudButton("fit", 970, 294, 34) local hudClock = hudText(436, 12, 160) local hudOnion = hudButton("Onion", 600, 10, 56) local hudPath = hudButton("Path", 660, 10, 48) local hudAuto = hudButton("Auto-key", 712, 10, 76, "primary") local hudClose = hudButton("Close", 824, 10, 64) -- A numbered ruler, so the scrub reads as a timeline rather than a slider. local hudRuler = make("Frame", { Parent = bar, BackgroundTransparency = 1, Active = true, Position = UDim2.fromOffset(12, 42), Size = UDim2.new(1, -24, 0, 13) }) local hudScrub = make("Frame", { Parent = bar, BackgroundColor3 = THEME.field, Active = true, ClipsDescendants = true, -- zoomed in, most of the clip is off one side or the other Position = UDim2.fromOffset(12, 56), Size = UDim2.new(1, -24, 0, 24) }, { corner(6), stroke() }) local hudTicks = make("Frame", { Parent = hudScrub, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1) }) local hudHead = make("Frame", { Parent = hudScrub, BackgroundColor3 = THEME.mint, BorderSizePixel = 0, Size = UDim2.new(0, 2, 1, 0) }) local hudBand = make("Frame", { Parent = hudScrub, BackgroundColor3 = THEME.amber, BackgroundTransparency = 0.75, BorderSizePixel = 0, Visible = false, Size = UDim2.new(0, 0, 1, 0) }) -- One lane per part, keys as diamonds. This is the thing that was too small to use. local LANE = 22 local hudSheet = make("ScrollingFrame", { Parent = bar, BackgroundColor3 = THEME.field, BorderSizePixel = 0, Position = UDim2.fromOffset(12, 88), Size = UDim2.new(1, -24, 0, 132), CanvasSize = UDim2.new(), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 5, ScrollBarImageColor3 = THEME.line }, { corner(6) }) local hudLanes = {} local hudPick = hudText(12, 232, 190) local hudKey = hudButton("Key", 206, 230, 42, "primary") local hudDrop = hudButton("Delete", 252, 230, 54, "danger") local hudBack = hudButton("|< key", 310, 230, 54) local hudFwd = hudButton("key >|", 368, 230, 54) local hudSave = hudButton("Save", 426, 230, 48, "primary") local hudNote = hudText(478, 232, 550, 11) -- Everything the tab used to carry, on the table declared at the top so the draw functions -- above can write to it. -- Horizontal scroll, in the ten pixels under the sheet. The grip takes as much of the bar as -- the window takes of the clip, so it shows how much you are looking at as well as where. hud.panBar = make("Frame", { Parent = bar, BackgroundColor3 = THEME.field, Active = true, Visible = false, Position = UDim2.fromOffset(12, 222), Size = UDim2.new(1, -24, 0, 9) }, { corner(4) }) hud.panGrip = make("Frame", { Parent = hud.panBar, BackgroundColor3 = THEME.line, BorderSizePixel = 0, Size = UDim2.fromScale(1, 1) }, { corner(4) }) hud.marks, hud.ruler = {}, {} hud.turnX = make("TextBox", { Parent = bar, Text = "0", ClearTextOnFocus = false, BackgroundColor3 = THEME.field, TextColor3 = THEME.text, Font = Enum.Font.Code, TextSize = 12, Position = UDim2.fromOffset(12, 262), Size = UDim2.fromOffset(58, 24) }, { corner(5), stroke() }) hud.turnY = make("TextBox", { Parent = bar, Text = "0", ClearTextOnFocus = false, BackgroundColor3 = THEME.field, TextColor3 = THEME.text, Font = Enum.Font.Code, TextSize = 12, Position = UDim2.fromOffset(74, 262), Size = UDim2.fromOffset(58, 24) }, { corner(5), stroke() }) hud.turnZ = make("TextBox", { Parent = bar, Text = "0", ClearTextOnFocus = false, BackgroundColor3 = THEME.field, TextColor3 = THEME.text, Font = Enum.Font.Code, TextSize = 12, Position = UDim2.fromOffset(136, 262), Size = UDim2.fromOffset(58, 24) }, { corner(5), stroke() }) hud.grab = hudButton("Key from rig", 200, 262, 88) hud.reset = hudButton("Reset", 292, 262, 52) hud.ease = hudButton("Linear", 348, 262, 62) hud.dir = hudButton("InOut", 414, 262, 58) hud.copy = hudButton("Copy", 476, 262, 50) hud.paste = hudButton("Paste", 530, 262, 52) hud.mirror = hudButton("Mirror", 586, 262, 54) hud.markA = hudButton("[ A", 644, 262, 40) hud.markB = hudButton("B ]", 688, 262, 40) hud.clearRange = hudButton("clear", 732, 262, 46) hud.shiftBack = hudButton("<<", 782, 262, 36) hud.shiftOn = hudButton(">>", 822, 262, 36) hud.squash = hudButton("squash", 862, 262, 56) hud.stretch = hudButton("stretch", 922, 262, 56) hud.rangePlay = hudButton("range", 982, 262, 46) hud.insert = hudButton("+frame", 12, 294, 60) hud.remove = hudButton("-frame", 76, 294, 60) hud.revert = hudButton("Revert", 140, 294, 58) hud.curve = make("Frame", { Parent = bar, BackgroundColor3 = THEME.field, ClipsDescendants = true, Position = UDim2.fromOffset(204, 292), Size = UDim2.fromOffset(824, 28) }, { corner(5), stroke() }) hud.curveInk = make("Frame", { Parent = hud.curve, BackgroundTransparency = 1, Size = UDim2.fromScale(1, 1) }) hud.curveHead = make("Frame", { Parent = hud.curve, BackgroundColor3 = THEME.mint, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0) }) -- Redrawn whenever the editor moves, from the same state the panel reads. -- What moves every frame: the clock, the playheads, and the key under them. function R6.overlayLight() if not edit then return end local across = edit.length > 0 and playhead / edit.length or 0 hudClock.Text = string.format("%05.2f / %05.2f f%d", playhead, edit.length, math.floor(playhead * edit.fps + 0.5)) hudHead.Position = UDim2.new(atX(playhead), -1, 0, 0) hudPlay.Text = playing and "Pause" or "Play" hudNote.Text = editStatus.Text hudNote.TextColor3 = editStatus.TextColor3 for _, lane in pairs(hudLanes) do lane.head.Position = UDim2.new(atX(playhead), -1, 0, 0) end end function R6.overlayDraw() if not edit then return end R6.overlayLight() hudPick.Text = selected and (selected .. " " .. #edit.tracks[selected] .. " keys") or "no part selected" hudOnion.BackgroundColor3 = (onion and STYLES.primary or STYLES.ghost)[1] hudPath.BackgroundColor3 = (pathOn and STYLES.primary or STYLES.ghost)[1] hudAuto.BackgroundColor3 = (autoKey and STYLES.primary or STYLES.ghost)[1] hudSpeed.Text = tostring(speed) .. "x" hudLoop.BackgroundColor3 = (loopPlay and STYLES.primary or STYLES.ghost)[1] hudSnap.BackgroundColor3 = (snap and STYLES.primary or STYLES.ghost)[1] if rangeA and rangeB and edit.length > 0 then local lo, hi = math.min(rangeA, rangeB), math.max(rangeA, rangeB) hudBand.Visible = true hudBand.Position = UDim2.new(atX(lo), 0, 0, 0) hudBand.Size = UDim2.new(atX(hi) - atX(lo), 0, 1, 0) else hudBand.Visible = false end hudRuler:ClearAllChildren() hud.ruler = {} local marks = math.clamp(math.floor(seen() * edit.fps / 10), 4, 12) for step = 0, marks do local at = step / marks -- Held on to: panning changes what a mark reads, not where it sits. hud.ruler[#hud.ruler + 1] = { make("TextLabel", { Parent = hudRuler, BackgroundTransparency = 1, Text = tostring(math.floor(timeAt(at) * edit.fps + 0.5)), Font = Enum.Font.Code, TextSize = 10, TextColor3 = THEME.muted, AnchorPoint = Vector2.new(0.5, 0), Position = UDim2.new(at, 0, 0, 0), Size = UDim2.fromOffset(34, 12) }), at } end hudTicks:ClearAllChildren() hud.marks = {} if selected then for _, key in ipairs(edit.tracks[selected]) do hud.marks[#hud.marks + 1] = { make("Frame", { Parent = hudTicks, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0), BackgroundColor3 = inRange(key.t) and THEME.amber or THEME.mint, Position = UDim2.new(atX(key.t), 0, 0, 3), Size = UDim2.new(0, 5, 1, -6) }, { corner(2) }), key } end end hudSheet:ClearAllChildren() hudLanes = {} make("UIListLayout", { Parent = hudSheet, Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }) for index, name in ipairs(edit.parts) do local lit = (selected == name) local lane = make("TextButton", { Parent = hudSheet, Text = "", AutoButtonColor = false, LayoutOrder = index, BackgroundColor3 = lit and THEME.panel2 or THEME.panel, ClipsDescendants = true, Size = UDim2.new(1, -6, 0, LANE) }, { corner(5) }) make("TextLabel", { Parent = lane, BackgroundTransparency = 1, Text = name, Font = lit and Enum.Font.GothamBold or Enum.Font.Gotham, TextSize = 12, TextColor3 = lit and THEME.mint or THEME.text, TextXAlignment = Enum.TextXAlignment.Left, Position = UDim2.fromOffset(10, 0), Size = UDim2.new(0, 124, 1, 0) }) local strip = make("Frame", { Parent = lane, BackgroundTransparency = 1, Position = UDim2.new(0, 138, 0, 0), Size = UDim2.new(1, -148, 1, 0) }) local head = make("Frame", { Parent = strip, BackgroundColor3 = THEME.mint, BackgroundTransparency = 0.45, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0) }) local dots = {} for _, key in ipairs(edit.tracks[name]) do local here = math.abs(key.t - playhead) < (0.5 / edit.fps) local diamond = make("TextButton", { Parent = strip, Text = "", AutoButtonColor = false, Active = true, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0.5), Rotation = 45, BackgroundColor3 = inRange(key.t) and THEME.amber or (lit and THEME.mint or THEME.muted), BackgroundTransparency = here and 0 or 0.25, Position = UDim2.new(atX(key.t), 0, 0.5, 0), Size = UDim2.fromOffset(11, 11) }, { corner(2) }) dots[#dots + 1] = { diamond, key } diamond.MouseButton1Down:Connect(function() dragKey = { name = name, key = key } if selected ~= name then selectPart(name) end seek(key.t) end) end lane.InputChanged:Connect(function(i) if dragKey and dragKey.name == name and i.UserInputType == Enum.UserInputType.MouseMovement then local across = (i.Position.X - strip.AbsolutePosition.X) / math.max(1, strip.AbsoluteSize.X) dragKey.key.t = math.clamp(timeAt(across), 0, edit.length) table.sort(edit.tracks[name], function(a, b) return a.t < b.t end) R6.overlayDraw() end end) lane.InputEnded:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 and dragKey then dragKey = nil drawTicks(name) seek(playhead) History:SetWaypoint("Riggler: retime key") end end) lane.MouseButton1Click:Connect(function() selectPart(name) local part = edit.rig:FindFirstChild(name) if part then Selection:Set({ part }) end end) hudLanes[name] = { lane = lane, head = head, dots = dots } end hud.gripDraw() end -- Moving the window: positions only, no rebuild. Everything above was kept with the time it -- stands for, so this is the difference between a smooth scroll and a stutter. It hangs off -- R6 rather than off `hud` because the playback loop is compiled before `hud` exists. function R6.overlaySlide() if not edit then return end R6.overlayLight() for _, mark in ipairs(hud.marks) do mark[1].Position = UDim2.new(atX(mark[2].t), 0, 0, 3) end for _, lane in pairs(hudLanes) do for _, dot in ipairs(lane.dots or {}) do dot[1].Position = UDim2.new(atX(dot[2].t), 0, 0.5, 0) end end for _, label in ipairs(hud.ruler) do label[1].Text = tostring(math.floor(timeAt(label[2]) * edit.fps + 0.5)) end if rangeA and rangeB and edit.length > 0 then local lo, hi = math.min(rangeA, rangeB), math.max(rangeA, rangeB) hudBand.Position = UDim2.new(atX(lo), 0, 0, 0) hudBand.Size = UDim2.new(atX(hi) - atX(lo), 0, 1, 0) end hud.gripDraw() end function hud.gripDraw() local span = edit and edit.length or 0 local share = span > 0 and math.clamp(seen() / span, 0.03, 1) or 1 hud.panBar.Visible = share < 1 -- nothing to scroll at fit hud.panGrip.Size = UDim2.new(share, 0, 1, 0) hud.panGrip.Position = UDim2.new(span > 0 and math.clamp(pan / span, 0, 1 - share) or 0, 0, 0, 0) end -- One notch moves the window by a share of what it shows, so it feels the same at any zoom. function hud.nudge(notches) if not edit then return end pan = pan + notches * seen() * 0.18 holdPan() R6.overlaySlide() end function hud.panTo(x) if not edit then return end local across = (x - hud.panBar.AbsolutePosition.X) / math.max(1, hud.panBar.AbsoluteSize.X) pan = across * edit.length - seen() * 0.5 -- what you grabbed becomes the middle holdPan() R6.overlaySlide() end function R6.overlaySet(on) screen.Enabled = on and true or false if on then R6.overlayDraw() end end local function hudSeekFrom(x) if not edit then return end seek(timeAt((x - hudScrub.AbsolutePosition.X) / math.max(1, hudScrub.AbsoluteSize.X))) end hudScrub.InputBegan:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then dragging = true hudSeekFrom(i.Position.X) end end) hudScrub.InputChanged:Connect(function(i) if dragging and i.UserInputType == Enum.UserInputType.MouseMovement then hudSeekFrom(i.Position.X) end end) hudScrub.InputEnded:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then dragging = false end end) hudStart.MouseButton1Click:Connect(function() seek(0) end) hudEnd.MouseButton1Click:Connect(function() if edit then seek(edit.length) end end) hudPrev.MouseButton1Click:Connect(function() if edit then seek(playhead - 1 / edit.fps) end end) hudNext.MouseButton1Click:Connect(function() if edit then seek(playhead + 1 / edit.fps) end end) hudPlay.MouseButton1Click:Connect(togglePlay) hudOnion.MouseButton1Click:Connect(guard(editStatus, toggleOnion)) hudPath.MouseButton1Click:Connect(guard(editStatus, togglePath)) hudAuto.MouseButton1Click:Connect(toggleAuto) for _, box in ipairs({ hud.turnX, hud.turnY, hud.turnZ }) do box.FocusLost:Connect(function(enter) if not (enter and edit and selected) then return end local was = sampleTrack(edit.tracks[selected], playhead) setPose(selected, CFrame.new(was.Position) * CFrame.Angles( math.rad(tonumber(hud.turnX.Text) or 0), math.rad(tonumber(hud.turnY.Text) or 0), math.rad(tonumber(hud.turnZ.Text) or 0))) seek(playhead) R6.overlayDraw() History:SetWaypoint("Riggler: pose " .. selected) end) end hud.grab.MouseButton1Click:Connect(guard(editStatus, function() local taken = captureRig(nil) setStatus(editStatus, "Keyed from the rig: " .. table.concat(taken, ", "), THEME.mint) R6.overlayDraw() end)) hud.reset.MouseButton1Click:Connect(guard(editStatus, function() if not (edit and selected) then error("Pick a part first.", 0) end for _, name in ipairs(chosen()) do setPose(name, CFrame.new(sampleTrack(edit.tracks[name], playhead).Position)) end seek(playhead) R6.overlayDraw() History:SetWaypoint("Riggler: reset pose") end)) hud.copy.MouseButton1Click:Connect(guard(editStatus, function() if not edit then error("Nothing is loaded.", 0) end clipboard = {} for _, name in ipairs(edit.parts) do clipboard[name] = sampleTrack(edit.tracks[name], playhead) end setStatus(editStatus, "Pose copied. Move the playhead and paste it.", THEME.mint) R6.overlayLight() end)) hud.paste.MouseButton1Click:Connect(guard(editStatus, function() if not clipboard then error("Copy a pose first.", 0) end for name, cf in pairs(clipboard) do if edit.tracks[name] then setPose(name, cf) end end seek(playhead) R6.overlayDraw() History:SetWaypoint("Riggler: paste pose") end)) hud.mirror.MouseButton1Click:Connect(guard(editStatus, function() if not edit then error("Nothing is loaded.", 0) end local swapped = {} for _, name in ipairs(edit.parts) do local other = name for side, twin in pairs(MIRROR) do if string.sub(name, 1, #side) == side then other = twin .. string.sub(name, #side + 1) end end local cf = sampleTrack(edit.tracks[other] or edit.tracks[name], playhead) local axis, angle = cf:ToAxisAngle() local s = math.sin(angle / 2) swapped[name] = CFrame.new(-cf.Position.X, cf.Position.Y, cf.Position.Z) * CFrame.new(0, 0, 0, axis.X * s, -axis.Y * s, -axis.Z * s, math.cos(angle / 2)) end for name, cf in pairs(swapped) do setPose(name, cf) end seek(playhead) R6.overlayDraw() History:SetWaypoint("Riggler: mirror pose") end)) hud.markA.MouseButton1Click:Connect(guard(editStatus, function() rangeA = playhead redrawAll() R6.overlayDraw() end)) hud.markB.MouseButton1Click:Connect(guard(editStatus, function() rangeB = playhead redrawAll() R6.overlayDraw() end)) hud.clearRange.MouseButton1Click:Connect(guard(editStatus, function() rangeA, rangeB = nil, nil redrawAll() R6.overlayDraw() end)) hud.shiftBack.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(at) return at - 1 / edit.fps end) R6.overlayDraw() end)) hud.shiftOn.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(at) return at + 1 / edit.fps end) R6.overlayDraw() end)) hud.squash.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(at, lo) return lo + (at - lo) * 0.9 end) R6.overlayDraw() end)) hud.stretch.MouseButton1Click:Connect(guard(editStatus, function() overRange(function(at, lo) return lo + (at - lo) * 1.1 end) R6.overlayDraw() end)) hud.rangePlay.MouseButton1Click:Connect(function() toggleRangePlay() hud.rangePlay.BackgroundColor3 = (playRange and STYLES.primary or STYLES.ghost)[1] end) hud.insert.MouseButton1Click:Connect(guard(editStatus, function() shiftTime(1) R6.overlayDraw() end)) hud.remove.MouseButton1Click:Connect(guard(editStatus, function() shiftTime(-1) R6.overlayDraw() end)) hud.revert.MouseButton1Click:Connect(guard(editStatus, function() restoreRig() load() end)) hud.ease.MouseButton1Click:Connect(guard(editStatus, function() local key = nearest(0) if not key then error("Pick a part first.", 0) end key.ease = EASES[((table.find(EASES, key.ease) or 1) % #EASES) + 1] easeButton.Text, hud.ease.Text = key.ease, key.ease seek(playhead) R6.overlayDraw() end)) hud.dir.MouseButton1Click:Connect(guard(editStatus, function() local key = nearest(0) if not key then error("Pick a part first.", 0) end key.dir = DIRS[((table.find(DIRS, key.dir) or 1) % #DIRS) + 1] dirButton.Text, hud.dir.Text = key.dir, key.dir seek(playhead) R6.overlayDraw() end)) -- Zoom about the playhead, so what you are looking at stays under the cursor. local function setZoom(next) if not edit then return end local was = playhead zoom = math.clamp(next, 1, 40) pan = was - seen() * 0.5 holdPan() R6.overlayDraw() end hud.zoomIn.MouseButton1Click:Connect(function() setZoom(zoom * 1.5) end) hud.zoomOut.MouseButton1Click:Connect(function() setZoom(zoom / 1.5) end) hud.zoomFit.MouseButton1Click:Connect(function() zoom, pan = 1, 0 if edit then R6.overlayDraw() end end) -- Over the sheet the plain wheel belongs to the part list, so scrolling the clip there is -- shift. Over the ruler and the scrub there is nothing to scroll vertically, so it is plain. hudSheet.InputChanged:Connect(function(i) if i.UserInputType ~= Enum.UserInputType.MouseWheel or not edit then return end if i:IsModifierKeyDown(Enum.ModifierKey.Ctrl) then setZoom(zoom * (i.Position.Z > 0 and 1.25 or 0.8)) elseif i:IsModifierKeyDown(Enum.ModifierKey.Shift) then hud.nudge(i.Position.Z > 0 and -1 or 1) end end) hudScrub.InputChanged:Connect(function(i) if i.UserInputType ~= Enum.UserInputType.MouseWheel or not edit then return end if i:IsModifierKeyDown(Enum.ModifierKey.Ctrl) then setZoom(zoom * (i.Position.Z > 0 and 1.25 or 0.8)) else hud.nudge(i.Position.Z > 0 and -1 or 1) end end) hudRuler.InputChanged:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseWheel and edit then hud.nudge(i.Position.Z > 0 and -1 or 1) end end) hud.panBar.InputBegan:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then hud.dragging = true hud.panTo(i.Position.X) end end) hud.panBar.InputChanged:Connect(function(i) if hud.dragging and i.UserInputType == Enum.UserInputType.MouseMovement then hud.panTo(i.Position.X) end end) hud.panBar.InputEnded:Connect(function(i) if i.UserInputType == Enum.UserInputType.MouseButton1 then hud.dragging = false end end) hud.panBar.MouseLeave:Connect(function() hud.dragging = false end) hudLoop.MouseButton1Click:Connect(toggleLoop) hudSnap.MouseButton1Click:Connect(toggleSnap) hudSpeed.MouseButton1Click:Connect(cycleSpeed) hudFocus.MouseButton1Click:Connect(guard(editStatus, focusRig)) hudClose.MouseButton1Click:Connect(guard(editStatus, unload)) hudSave.MouseButton1Click:Connect(guard(editStatus, save)) hudKey.MouseButton1Click:Connect(guard(editStatus, keyChosen)) hudDrop.MouseButton1Click:Connect(guard(editStatus, deleteHere)) hudBack.MouseButton1Click:Connect(guard(editStatus, gotoPrevKey)) hudFwd.MouseButton1Click:Connect(guard(editStatus, gotoNextKey)) -- Real Studio shortcuts: they appear under File > Advanced > Customize Shortcuts, so the keys -- are yours to choose rather than ours to squat on. local function shortcut(id, title, hint, fn) local ok, action = pcall(function() return plugin:CreatePluginAction("Riggler_" .. id, title, hint, "", true) end) if ok and action then action.Triggered:Connect(function() local fine, err = pcall(fn) if not fine then setStatus(editStatus, note(err), THEME.red) end end) end end shortcut("Play", "Riggler: play or pause", "Play or pause the clip being edited", function() togglePlay() end) shortcut("NextFrame", "Riggler: next frame", "Step one frame forward", function() if edit then seek(playhead + 1 / edit.fps) end end) shortcut("PrevFrame", "Riggler: previous frame", "Step one frame back", function() if edit then seek(playhead - 1 / edit.fps) end end) shortcut("NextKey", "Riggler: next key", "Jump to the next key on the selected part", gotoNextKey) shortcut("PrevKey", "Riggler: previous key", "Jump to the previous key", gotoPrevKey) shortcut("Key", "Riggler: key the selected parts", "Write a key at the playhead", keyChosen) shortcut("Focus", "Riggler: focus the rig", "Point the camera at the rig being edited", focusRig) R6.closeEditor = unload end -- If Studio closed or the place was saved while the editor held a rig, those joints kept the -- posed C0 -- a rig that comes back deformed with nothing to say why. Each joint carries its real -- bind on an attribute while the editor holds it, so anything still wearing one is put back here. local function unposeLeftovers() local fixed, rigs = 0, {} for _, item in ipairs(workspace:GetDescendants()) do if item:IsA("Motor6D") then local bind = item:GetAttribute("RigglerBind") if typeof(bind) == "CFrame" then item.C0 = bind item:SetAttribute("RigglerBind", nil) fixed = fixed + 1 local model = item:FindFirstAncestorOfClass("Model") if model then rigs[model.Name] = true end end end end for _, name in ipairs({ "RigglerOnion", "RigglerPath" }) do local leftover = workspace:FindFirstChild(name, true) while leftover do leftover:Destroy() leftover = workspace:FindFirstChild(name, true) end end if fixed > 0 then local named = {} for name in pairs(rigs) do table.insert(named, name) end warn(string.format("[Riggler] put %d joint%s back to their bind on %s -- the editor was open " .. "when this place was last saved.", fixed, fixed == 1 and "" or "s", table.concat(named, ", "))) end end pcall(unposeLeftovers) R6.buildEditor(page("Edit")) -- And let go of the rig if Studio takes the plugin away mid-edit. plugin.Unloading:Connect(function() pcall(function() if R6.closeEditor then R6.closeEditor() end end) end) print(string.format("[Riggler] ready %s -- text, photo and video to R15 and R6. Server: %s", VERSION, settings.server))