Unflat--[[ Ünflat (lowpolycreator) — Roblox Studio plugin Main entry point. Creates the plugin button and wires the UI. ]] local Selection = game:GetService("Selection") local Importer = require(script.Importer) local Classic = require(script.Classic) local UI = require(script.UI) -- Toolbar icon: the Apricot decagon (the app icon, -- ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png). -- Roblox toolbar buttons only take UPLOADED image assets — once Nicole -- uploads it (free) this becomes "rbxassetid://<id>". Empty = no icon, -- and never "rbxassetid://0", which warns on every Studio start. local ICON = "rbxassetid://101501819626116" local toolbar = plugin:CreateToolbar("Ünflat") local button = toolbar:CreateButton( "Import Model", "Open the Ünflat panel", ICON ) local ui = UI.new(plugin) local importer = Importer.new(plugin) button.Click:Connect(function() ui:toggle() end) ui.onImportRequested.Event:Connect(function(dataFile) local ok, err = importer:importModel(dataFile) if ok then ui:showSuccess("Built! It's selected in the Explorer — walk around it.") else ui:showError(err or "Import failed — check the Output window.") end end) ui.onClassicRequested.Event:Connect(function() local ok, msg = Classic.dress() if ok then ui:showSuccess(msg) elseif msg then ui:showError(msg) end end) ui.onPublishRequested.Event:Connect(function() if #Selection:Get() == 0 then ui:showError("Select what you want to publish first (import selects it for you).") return end local ok, err = pcall(function() plugin:SaveSelectedToRoblox() end) if not ok then ui:showError("Couldn't open the publish dialog: " .. tostring(err)) end end) ui.onCloseRequested.Event:Connect(function() ui:hide() end) Importer--[[ Importer.lua — reads a .lowpoly data file and builds the model in Studio. v2 files carry the full geometry and the paint atlas: the plugin builds real textured MeshParts with EditableMesh + EditableImage — no bulk import, no asset IDs, no placeholders. Accessories arrive wrapped in an Accessory with the right attachment point; tools arrive as a Tool with a Handle; animated models get Motor6D joints + animation scripts. v1 files (the old asset-ID lookup flow) still import through the legacy path at the bottom. ]] local HttpService = game:GetService("HttpService") local AssetService = game:GetService("AssetService") local Selection = game:GetService("Selection") local Importer = {} Importer.__index = Importer -- Which attachment point each accessory type snaps to on an avatar local ACCESSORY_ATTACHMENTS = { hat = "HatAttachment", hair = "HairAttachment", faceAccessory = "FaceFrontAttachment", neckAccessory = "NeckAttachment", frontAccessory = "BodyFrontAttachment", backAccessory = "BodyBackAttachment", waistAccessory = "WaistCenterAttachment", shoulderPet = "RightCollarAttachment", } -- Shared base64 → buffer decoder, inlined into generated runtime -- scripts (the plugin isn't there when the game runs). local B64_SOURCE = [==[ local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local REV = {} for i = 1, #B64 do REV[string.byte(B64, i)] = i - 1 end local function decode(s) s = string.gsub(s, "[^%w%+%/%=]", "") local n = #s local pad = 0 if string.sub(s, -2) == "==" then pad = 2 elseif string.sub(s, -1) == "=" then pad = 1 end local outLen = math.floor(n / 4) * 3 - pad local out = buffer.create(outLen) local oi = 0 for i = 1, n, 4 do local a, b, c, d = string.byte(s, i, i + 3) local v = (REV[a] or 0) * 262144 + (REV[b] or 0) * 4096 + (REV[c] or 0) * 64 + (REV[d] or 0) if oi < outLen then buffer.writeu8(out, oi, math.floor(v / 65536) % 256); oi += 1 end if oi < outLen then buffer.writeu8(out, oi, math.floor(v / 256) % 256); oi += 1 end if oi < outLen then buffer.writeu8(out, oi, v % 256); oi += 1 end end return out end ]==] -- S-1 runtime skin swapper (ROBLOX_EXPORT2): lives on the imported -- model, watches the Studio-visible lowpoly_skin attribute, rebuilds -- the EditableImage from the stored pixels on demand — so the swap -- still works after the place is saved and reopened. local SKIN_SWAP_SOURCE = [==[ local AssetService = game:GetService("AssetService") local container = script.Parent local folder = container:WaitForChild("Skins") local size = folder:GetAttribute("size") ]==] .. B64_SOURCE .. [==[ local cache = {} local function apply(name) local sv = folder:FindFirstChild(name) if not sv then return end local img = cache[name] if not img then local ok, imageOrErr = pcall(function() local image = AssetService:CreateEditableImage({ Size = Vector2.new(size, size) }) image:WritePixelsBuffer(Vector2.zero, Vector2.new(size, size), decode(sv.Value)) return image end) if not ok then return end img = imageOrErr cache[name] = img end for _, p in ipairs(container:GetDescendants()) do if p:IsA("MeshPart") then p.TextureContent = Content.fromObject(img) end end container:SetAttribute("lowpoly_flipbook", false) end container:GetAttributeChangedSignal("lowpoly_skin"):Connect(function() apply(container:GetAttribute("lowpoly_skin")) end) ]==] -- S-2 runtime flipbook player: cycles the stored frames at the app's -- two-frames-a-second grid. The lowpoly_flipbook attribute pauses it -- (and picking a skin turns it off). local FLIPBOOK_PLAY_SOURCE = [==[ local AssetService = game:GetService("AssetService") local container = script.Parent local folder = container:WaitForChild("Flipbook") local size = folder:GetAttribute("size") ]==] .. B64_SOURCE .. [==[ local frames = {} local function frameImage(i) local img = frames[i] if img then return img end local sv = folder:FindFirstChild(tostring(i)) if not sv then return nil end local ok, imageOrErr = pcall(function() local image = AssetService:CreateEditableImage({ Size = Vector2.new(size, size) }) image:WritePixelsBuffer(Vector2.zero, Vector2.new(size, size), decode(sv.Value)) return image end) if not ok then return nil end frames[i] = imageOrErr return imageOrErr end local count = 0 while folder:FindFirstChild(tostring(count + 1)) do count += 1 end local i = 0 while true do if container:GetAttribute("lowpoly_flipbook") and count >= 2 then i = i % count + 1 local img = frameImage(i) if img then for _, p in ipairs(container:GetDescendants()) do if p:IsA("MeshPart") then p.TextureContent = Content.fromObject(img) end end end end task.wait(0.5) end ]==] -- S-4 runtime outfit swapper: watches the Studio-visible -- lowpoly_outfit attribute, shows the picked outfit's clothes and -- hides the rest (transparency, never destroyed). The looks table is -- baked in at import; parts are found by their lowpoly_id attribute. local OUTFIT_SWAP_PREFIX = [==[ local container = script.Parent local looks = ]==] local OUTFIT_SWAP_SUFFIX = [==[ local parts = {} for _, p in ipairs(container:GetDescendants()) do if p:IsA("BasePart") and p:GetAttribute("lowpoly_id") then parts[p:GetAttribute("lowpoly_id")] = p end end local function apply(name) local look = nil for _, lk in ipairs(looks) do if lk.name == name then look = lk break end end if not look then return end for _, id in ipairs(wornIds) do local part = parts[id] if part then local hide = not look.worn[id] part.Transparency = hide and 1 or 0 part.CastShadow = not hide part.CanTouch = not hide end end if look.skin then container:SetAttribute("lowpoly_skin", look.skin) end end container:GetAttributeChangedSignal("lowpoly_outfit"):Connect(function() apply(container:GetAttribute("lowpoly_outfit")) end) apply(container:GetAttribute("lowpoly_outfit")) ]==] -- S-5 runtime pose swapper: watches the Studio-visible lowpoly_pose -- attribute and TWEENS every posable joint to the named pose (0.65s -- on the app's 0.25s step grid, CFrame:Lerp does the turning). Parts -- with their own move chains are never touched — the app's own rule. local POSE_SWAP_PREFIX = [==[ local container = script.Parent local poses = ]==] local POSE_SWAP_SUFFIX = [==[ local joints = {} for _, j in ipairs(container:GetDescendants()) do if j:IsA("Motor6D") and j.Part1 and j.Part1:GetAttribute("lowpoly_id") then local id = j.Part1:GetAttribute("lowpoly_id") if not chained[id] then joints[id] = j end end end local function poseCF(off) if not off then return CFrame.new() end local cf = CFrame.new(0, off.lift or 0, 0) cf = cf * CFrame.new(0, 0, off.slide or 0) cf = cf * CFrame.Angles(0, math.rad(off.yaw or 0), 0) cf = cf * CFrame.Angles(math.rad(off.swing or 0), 0, 0) return cf end local seq = 0 local function strike(name) seq += 1 local my = seq local pose = nil for _, np in ipairs(poses) do if np.name == name then pose = np break end end local from = {} local to = {} for id, j in pairs(joints) do from[id] = j.C1 to[id] = poseCF(pose and pose.parts[id] or nil) end task.spawn(function() local dur = 0.65 local step = 0.25 local t = 0 while t < dur do task.wait(step) if seq ~= my then return end -- a newer strike took over t = math.min(dur, t + step) local k = t / dur k = k * k * (3 - 2 * k) -- easeInOutCubic for id, j in pairs(joints) do j.C1 = from[id]:Lerp(to[id], k) end end end) end container:GetAttributeChangedSignal("lowpoly_pose"):Connect(function() strike(container:GetAttribute("lowpoly_pose") or "") end) ]==] -- S-5 walk-up pose cycling on game props: each press advances to the -- next saved pose, then rest, then round again (one pose = a plain -- rest↔pose toggle). The prompt only writes the attribute — the -- swapper (and the plugin at edit time) does the striking. local POSE_CYCLE_PREFIX = [==[ local prompt = script.Parent local container = prompt:FindFirstAncestorOfClass("Model") local names = ]==] local POSE_CYCLE_SUFFIX = [==[ prompt.Triggered:Connect(function() if not container then return end local cur = container:GetAttribute("lowpoly_pose") or "" if #names == 1 then container:SetAttribute("lowpoly_pose", cur == names[1] and "" or names[1]) return end local idx = 0 for i, n in ipairs(names) do if n == cur then idx = i break end end local nextName if idx == #names then nextName = "" else nextName = names[idx + 1] end container:SetAttribute("lowpoly_pose", nextName) end) ]==] -- R-X2 walk-up trigger (RULED YES Aug 19): the prompt flips the -- model's lowpoly_playing attribute; the joint scripts watch it. local WALK_UP_SOURCE = [==[ local prompt = script.Parent local container = prompt:FindFirstAncestorOfClass("Model") if container then prompt.Triggered:Connect(function() container:SetAttribute("lowpoly_playing", not container:GetAttribute("lowpoly_playing")) end) end ]==] function Importer.new(plugin) local self = setmetatable({}, Importer) self.plugin = plugin return self end -------------------------------------------------------------------------- -- v2: build everything from embedded geometry -------------------------------------------------------------------------- local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local B64_REV = nil --- Decode a base64 string into a buffer. function Importer:decodeBase64(s) if not B64_REV then B64_REV = {} for i = 1, #B64_CHARS do B64_REV[string.byte(B64_CHARS, i)] = i - 1 end end s = string.gsub(s, "[^%w%+%/%=]", "") local n = #s local pad = 0 if string.sub(s, -2) == "==" then pad = 2 elseif string.sub(s, -1) == "=" then pad = 1 end local outLen = math.floor(n / 4) * 3 - pad local out = buffer.create(outLen) local oi = 0 for i = 1, n, 4 do local a, b, c, d = string.byte(s, i, i + 3) local v = (B64_REV[a] or 0) * 262144 + (B64_REV[b] or 0) * 4096 + (B64_REV[c] or 0) * 64 + (B64_REV[d] or 0) if oi < outLen then buffer.writeu8(out, oi, math.floor(v / 65536) % 256); oi += 1 end if oi < outLen then buffer.writeu8(out, oi, math.floor(v / 256) % 256); oi += 1 end if oi < outLen then buffer.writeu8(out, oi, v % 256); oi += 1 end end return out end --- Build an EditableImage from the embedded RGBA atlas. function Importer:buildEditableImage(tex) local raw = self:decodeBase64(tex.rgba) local size = tex.size local image = AssetService:CreateEditableImage({ Size = Vector2.new(size, size) }) image:WritePixelsBuffer(Vector2.zero, Vector2.new(size, size), raw) return image end --- Build one textured MeshPart from a v2 geometry part. --- shadeMode: "flat" gives every face its own normal ID (a crease at --- every facet edge); "smooth" keeps EditableMesh's default, which --- reuses adjacent normal IDs and averages. AddTriangle alone ALWAYS --- smooths — the per-face AddNormal is what creates the crease — so a --- missing shadeMode means "flat": low poly is the app's identity. function Importer:buildMeshPartV2(gpart, editableImage, shadeMode) local mesh = AssetService:CreateEditableMesh() local pos = gpart.positions or {} local uvs = gpart.uvs or {} local triCount = math.floor(#pos / 9) local flat = shadeMode ~= "smooth" for t = 0, triCount - 1 do local p = t * 9 local u = t * 6 local v1 = mesh:AddVertex(Vector3.new(pos[p + 1], pos[p + 2], pos[p + 3])) local v2 = mesh:AddVertex(Vector3.new(pos[p + 4], pos[p + 5], pos[p + 6])) local v3 = mesh:AddVertex(Vector3.new(pos[p + 7], pos[p + 8], pos[p + 9])) local face = mesh:AddTriangle(v1, v2, v3) if flat then local n = mesh:AddNormal() -- auto-computed for this face mesh:SetFaceNormals(face, { n, n, n }) end local uv1 = mesh:AddUV(Vector2.new(uvs[u + 1] or 0, uvs[u + 2] or 0)) local uv2 = mesh:AddUV(Vector2.new(uvs[u + 3] or 0, uvs[u + 4] or 0)) local uv3 = mesh:AddUV(Vector2.new(uvs[u + 5] or 0, uvs[u + 6] or 0)) mesh:SetFaceUVs(face, { uv1, uv2, uv3 }) end local part = AssetService:CreateMeshPartAsync(Content.fromObject(mesh)) part.Name = gpart.displayName or gpart.name or "Part" -- S-4: display names can collide — the app's part id rides as an -- attribute so runtime scripts (outfit swap) find parts reliably. part:SetAttribute("lowpoly_id", gpart.id) part.Anchored = true if editableImage then part.TextureContent = Content.fromObject(editableImage) end local o = gpart.origin or { x = 0, y = 0, z = 0 } part.CFrame = CFrame.new(o.x or 0, o.y or 0, o.z or 0) return part end --- Wrap the imported parts for their item type: --- accessory types → Accessory + Handle + named Attachment --- tool → Tool + Handle · everything else → plain Model function Importer:wrapForItemType(model, data, hasJoints) local itemType = data.options and data.options.itemTypeId or "gameItem" local modelName = data.modelName or "unflat_model" local parts = {} for _, child in ipairs(model:GetChildren()) do if child:IsA("BasePart") then table.insert(parts, child) end end -- Jointed/animated models keep their Model shell — Motor6Ds hold the -- parts together and welding would fight them local attachmentName = ACCESSORY_ATTACHMENTS[itemType] if attachmentName and #parts >= 1 and not hasJoints then local accessory = Instance.new("Accessory") accessory.Name = modelName local handle = model.PrimaryPart or parts[1] handle.Name = "Handle" handle.Anchored = false handle.Parent = accessory for _, p in ipairs(parts) do if p ~= handle then p.Anchored = false local weld = Instance.new("WeldConstraint") weld.Part0 = handle weld.Part1 = p weld.Parent = handle p.Parent = accessory end end local att = Instance.new("Attachment") att.Name = attachmentName att.Parent = handle model:Destroy() accessory.Parent = workspace return accessory end if itemType == "tool" and #parts >= 1 and not hasJoints then local tool = Instance.new("Tool") tool.Name = modelName local handle = model.PrimaryPart or parts[1] handle.Name = "Handle" handle.Anchored = false handle.Parent = tool for _, p in ipairs(parts) do if p ~= handle then p.Anchored = false local weld = Instance.new("WeldConstraint") weld.Part0 = handle weld.Part1 = p weld.Parent = handle p.Parent = tool end end model:Destroy() tool.Parent = workspace return tool end model.Parent = workspace return model end --- Import a v2 data file: geometry + texture + joints + wrapping. function Importer:importV2(data) local geo = data.geometry local editableImage = nil if geo.texture and (geo.texture.size or 0) > 0 and geo.texture.rgba ~= "" then local ok, imageOrErr = pcall(function() return self:buildEditableImage(geo.texture) end) if ok then editableImage = imageOrErr else warn("lowpolycreator: texture skipped — " .. tostring(imageOrErr)) end end local model = Instance.new("Model") model.Name = data.modelName or "unflat_model" local partMap = {} local firstPart = nil for _, gpart in ipairs(geo.parts or {}) do local ok, partOrErr = pcall(function() return self:buildMeshPartV2(gpart, editableImage, geo.shadeMode) end) if ok and partOrErr then partOrErr.Parent = model partMap[gpart.id] = partOrErr if not firstPart then firstPart = partOrErr end else warn(("lowpolycreator: part %s failed — %s"):format( tostring(gpart.name), tostring(partOrErr))) end end if not firstPart then return nil, "No parts could be built — is this file from an up-to-date export?" end model.PrimaryPart = firstPart -- Motor6D joints + animation scripts ride the v1 part records local hasJoints = false if data.options and data.options.includeAnimations then for _, partData in ipairs(data.parts or {}) do if partData.attachedTo then hasJoints = true break end end self:createJoints(model, partMap, data.parts or {}) self:createAnimations(model, partMap, data.parts or {}) -- Jointed parts must be free to move; roots stay anchored for _, part in pairs(partMap) do if self:findJointForPart(part) then part.Anchored = false end end end local container = self:wrapForItemType(model, data, hasJoints) -- ROBLOX_EXPORT2: the extras ride AFTER wrapping — accessories -- reparent their parts, so the folders/scripts must land on the -- final container, whatever class it is. local itemType = data.options and data.options.itemTypeId or "gameItem" if itemType == "gameItem" then self:installWalkUpPrompt(container, data) end self:installSkins(container, data) self:installFlipbook(container, data) -- S-4: outfits AFTER skins — the initial dress may pin a skin, -- and the skin watcher must already be listening. self:installMoveSets(container, data) self:installLooks(container, data, partMap) -- S-5: named poses last — joints exist, holds are seated. self:installPoses(container, data, partMap) return container end -------------------------------------------------------------------------- -- Shared entry point -------------------------------------------------------------------------- --- Import a model from a .lowpoly JSON string. Returns ok, errorMessage. function Importer:importModel(jsonString) local ok, data = pcall(function() return HttpService:JSONDecode(jsonString) end) if not ok or not data then return false, "Couldn't read that file — is it a .lowpoly export?" end if data.app ~= "lowpolycreator" then return false, "Not a valid .lowpoly file." end -- v2: self-contained geometry — build everything for real if data.geometry and (data.version or 1) >= 2 then local okBuild, resultOrErr, err = pcall(function() return self:importV2(data) end) if not okBuild then warn("lowpolycreator: import failed — " .. tostring(resultOrErr)) return false, "Import failed: " .. tostring(resultOrErr) end if not resultOrErr then return false, err or "Import failed." end Selection:Set({ resultOrErr }) print(("lowpolycreator: ✓ Built %s (%d parts)"):format( data.modelName or "model", #(data.geometry.parts or {}))) return true end -- v1 legacy path: parts reference pre-uploaded assets by name local model = Instance.new("Model") model.Name = data.modelName or "lowpoly_model" model.Parent = workspace local partMap = {} for _, partData in ipairs(data.parts or {}) do local part = self:createMeshPart(partData, data) if part then part.Parent = model partMap[partData.id] = part end end if data.options and data.options.includeAnimations then self:createJoints(model, partMap, data.parts or {}) self:createAnimations(model, partMap, data.parts or {}) end if data.options and data.options.includeOutfits and #(data.outfits or {}) > 0 then self:createOutfitSwapper(model, data.outfits) end if data.options and data.options.includeFlipbook and (data.flipbookFrameCount or 0) > 1 then self:createFlipbookPlayer(model, data.flipbookFrameCount) end Selection:Set({ model }) print(("lowpolycreator: ✓ Imported %s with %d parts"):format( data.modelName or "model", #(data.parts or {}))) return true end -------------------------------------------------------------------------- -- v1 legacy helpers (asset-ID lookup) -------------------------------------------------------------------------- --- Find an asset ID by name in the Workspace. function Importer:findAssetId(name, assetType) for _, obj in workspace:GetDescendants() do if obj.Name == name then if assetType == "mesh" and obj:IsA("MeshPart") then return obj.MeshId elseif assetType == "texture" and (obj:IsA("Texture") or obj:IsA("Decal")) then return obj.Texture end end end return nil end --- Create a single MeshPart from v1 part data (asset lookup or box). function Importer:createMeshPart(partData, data) local part = Instance.new("MeshPart") part.Name = partData.displayName or partData.name local meshId = self:findAssetId(partData.name, "mesh") if meshId then part.MeshId = meshId else part.Size = Vector3.new( partData.size.x or 1, partData.size.y or 1, partData.size.z or 1 ) end part.CFrame = CFrame.new( partData.position.x or 0, partData.position.y or 0, partData.position.z or 0 ) local textureId = self:findAssetId("default", "texture") if textureId then if data.options and data.options.includeMaterials then local sa = Instance.new("SurfaceAppearance") sa.ColorMap = textureId sa.NormalMap = self:findAssetId("normal", "texture") or "" sa.RoughnessMap = self:findAssetId("roughness", "texture") or "" sa.MetalnessMap = self:findAssetId("metalness", "texture") or "" sa.Parent = part else part.TextureID = textureId end end return part end -------------------------------------------------------------------------- -- Joints + animations (shared by v1 and v2) -------------------------------------------------------------------------- --- Create Motor6D joints from attach relationships. function Importer:createJoints(model, partMap, parts) for _, partData in ipairs(parts) do if partData.attachedTo and partMap[partData.id] and partMap[partData.attachedTo] then local parent = partMap[partData.attachedTo] local child = partMap[partData.id] local joint = Instance.new("Motor6D") joint.Part0 = parent joint.Part1 = child -- C0: offset from parent origin to the joint anchor if partData.boneAnchor then joint.C0 = CFrame.new( partData.boneAnchor.x - parent.Position.X, partData.boneAnchor.y - parent.Position.Y, partData.boneAnchor.z - parent.Position.Z ) else joint.C0 = CFrame.new(0, 0, 0) end joint.Parent = parent end end end --- Create pose-chain animation scripts. Real Scripts, not --- ModuleScripts — a ModuleScript only runs when something requires --- it, so the old scripts never played in a game. Each honors its --- trigger: loop plays forever, once plays a single round, toggle --- waits on the model's lowpoly_playing attribute (the walk-up --- prompt flips it, R-X2). function Importer:createAnimations(model, partMap, parts) for _, partData in ipairs(parts) do if not partData.animation or not partMap[partData.id] then continue end local part = partMap[partData.id] local joint = self:findJointForPart(part) if not joint then continue end local anim = partData.animation local poses = anim.poses if #poses == 0 then continue end part:SetAttribute("lowpoly_speed", anim.speed or "normal") -- a tuned or baked leg time (seconds per pose); absent = the speed word if anim.legS then part:SetAttribute("lowpoly_legS", anim.legS) end part:SetAttribute("lowpoly_trigger", anim.trigger or "loop") part:SetAttribute("lowpoly_turnAxis", anim.turnAxis or "y") -- §8 stance (S-5): seat the joint NOW (edit time runs no -- scripts) and flag the part so the runtime script stands too if anim.hold then part:SetAttribute("lowpoly_hold", true) joint.C1 = self:poseToCFrame(poses[1]) end local script_ = Instance.new("Script") script_.Name = part.Name .. "_animation" -- MOVE SETS: every set's poses ride the script; the container's -- lowpoly_moveset attribute picks which one plays (flip it in -- Properties or through SetMoveSet). Absent = the exported one. local setPoses = "nil" if partData.moveSetAnimations then local rows = {} for setId, a in pairs(partData.moveSetAnimations) do table.insert(rows, string.format("[%q] = %s", setId, self:posesToLuaTable(a.poses or {}))) end setPoses = "{ " .. table.concat(rows, ", ") .. " }" end local source = [==[ local joint = script.Parent local part = joint.Part1 local baseC1 = joint.C1 local poses = ]==] .. self:posesToLuaTable(poses) .. [==[ -- MOVE SETS: poses per set id; the container's lowpoly_moveset names the -- set (its id) that plays. Missing from a set = this part holds still. local setPoses = ]==] .. setPoses .. [==[ local speedMap = { slow = 1.1, normal = 0.65, fast = 0.4 } local legS = (part and part:GetAttribute("lowpoly_legS")) or speedMap[part and part:GetAttribute("lowpoly_speed")] or 0.65 if legS < 0.05 then legS = 0.05 end local stepS = 0.25 local trigger = (part and part:GetAttribute("lowpoly_trigger")) or "loop" local model = joint:FindFirstAncestorOfClass("Model") or joint:FindFirstAncestorOfClass("Accessory") or joint:FindFirstAncestorOfClass("Tool") -- §8 stance (S-5): a held pose STANDS — seat once from rest and stop. -- Absolute (identity base), so a re-run never stacks on a seated joint. if part and part:GetAttribute("lowpoly_hold") then local p = poses[1] local cf = CFrame.new(0, p.lift, 0) cf = cf * CFrame.new(0, 0, p.slide) cf = cf * CFrame.Angles(0, math.rad(p.yaw), 0) cf = cf * CFrame.Angles(math.rad(p.swing), 0, 0) joint.C1 = cf return end local function currentPoses() if setPoses and model then local want = model:GetAttribute("lowpoly_moveset") if want ~= nil and want ~= "" then return setPoses[want] or {} end end return poses end local function playOnce() local poses = currentPoses() if #poses == 0 then joint.C1 = baseC1 task.wait(stepS) return end for i, pose in ipairs(poses) do local nextPose = poses[i % #poses + 1] local startTime = tick() while tick() - startTime < legS do local t = (tick() - startTime) / legS t = t * t * (3 - 2 * t) -- easeInOutCubic local lift = pose.lift + (nextPose.lift - pose.lift) * t local slide = pose.slide + (nextPose.slide - pose.slide) * t -- shortest arc, so a baked Spin (0 → 315 → 0) keeps turning forward local dyaw = ((nextPose.yaw - pose.yaw + 540) % 360) - 180 local yaw = pose.yaw + dyaw * t local swing = pose.swing + (nextPose.swing - pose.swing) * t local cf = baseC1 cf = cf * CFrame.new(0, lift, 0) cf = cf * CFrame.new(0, 0, slide) cf = cf * CFrame.Angles(0, math.rad(yaw), 0) cf = cf * CFrame.Angles(math.rad(swing), 0, 0) joint.C1 = cf task.wait(math.min(stepS, legS)) end end end if trigger == "once" then playOnce() joint.C1 = baseC1 elseif trigger == "toggle" and model then local running = false local function watch() if model:GetAttribute("lowpoly_playing") and not running then running = true task.spawn(function() while model:GetAttribute("lowpoly_playing") do playOnce() end joint.C1 = baseC1 running = false end) end end model:GetAttributeChangedSignal("lowpoly_playing"):Connect(watch) watch() else while true do playOnce() end end ]==] script_.Source = source script_.Parent = joint end end --- R-X2 (RULED YES Aug 19): a game prop with a toggle move gets a --- walk-up prompt — one ProximityPrompt per prop, flipping the --- lowpoly_playing attribute every toggle move watches. function Importer:installWalkUpPrompt(container, data) if not (data.options and data.options.includeAnimations) then return end local hasToggle = false for _, partData in ipairs(data.parts or {}) do if partData.animation and partData.animation.trigger == "toggle" then hasToggle = true break end end if not hasToggle then return end local host = container.PrimaryPart if not host then for _, child in ipairs(container:GetDescendants()) do if child:IsA("BasePart") then host = child break end end end if not host then return end container:SetAttribute("lowpoly_playing", false) local prompt = Instance.new("ProximityPrompt") prompt.Name = "WalkUpPlay" prompt.ActionText = "Play" prompt.ObjectText = container.Name prompt.RequiresLineOfSight = false prompt.MaxActivationDistance = 12 prompt.Parent = host local ps = Instance.new("Script") ps.Name = "WalkUpToggle" ps.Source = WALK_UP_SOURCE ps.Parent = prompt end --- S-1: install the skin swapper — the baked atlases land as --- StringValues (they survive a save), a SetSkin module serves --- scripts, the SkinSwap Script serves play mode, and a plugin-side --- watcher swaps at EDIT time so flipping the lowpoly_skin attribute --- in Properties shows the skin without pressing Play. function Importer:installSkins(container, data) local skins = data.skins if not skins or #skins < 2 then return end local size = data.geometry and data.geometry.texture and data.geometry.texture.size or 0 if size == 0 then return end local folder = Instance.new("Folder") folder.Name = "Skins" folder:SetAttribute("size", size) local activeName = skins[1].name for _, skin in ipairs(skins) do local sv = Instance.new("StringValue") sv.Name = skin.name sv.Value = skin.rgba sv.Parent = folder if skin.active then activeName = skin.name end end folder.Parent = container container:SetAttribute("lowpoly_skin", activeName) local names = {} for _, skin in ipairs(skins) do table.insert(names, string.format("%q", skin.name)) end local module = Instance.new("ModuleScript") module.Name = "SetSkin" module.Source = "-- require(this).SetSkin(name) — skins: " .. table.concat(names, ", ") .. [==[ local container = script.Parent local M = {} M.names = { ]==] .. table.concat(names, ", ") .. [==[ } function M.SetSkin(name) container:SetAttribute("lowpoly_skin", name) end return M ]==] module.Parent = container local worker = Instance.new("Script") worker.Name = "SkinSwap" worker.Source = SKIN_SWAP_SOURCE worker.Parent = container -- Edit-time swap (plugin session only; play mode uses SkinSwap) local cache = {} container:GetAttributeChangedSignal("lowpoly_skin"):Connect(function() local name = container:GetAttribute("lowpoly_skin") local sv = folder:FindFirstChild(name) if not sv then return end local img = cache[name] if not img then local ok, imageOrErr = pcall(function() local image = AssetService:CreateEditableImage({ Size = Vector2.new(size, size) }) image:WritePixelsBuffer(Vector2.zero, Vector2.new(size, size), self:decodeBase64(sv.Value)) return image end) if not ok then return end img = imageOrErr cache[name] = img end for _, p in ipairs(container:GetDescendants()) do if p:IsA("MeshPart") then p.TextureContent = Content.fromObject(img) end end container:SetAttribute("lowpoly_flipbook", false) end) end --- S-2: install the flipbook player — frames as StringValues plus a --- runtime Script cycling them twice a second. The lowpoly_flipbook --- attribute pauses it. function Importer:installFlipbook(container, data) local flipbook = data.flipbook if not flipbook or #flipbook < 2 then return end local size = data.geometry and data.geometry.texture and data.geometry.texture.size or 0 if size == 0 then return end local folder = Instance.new("Folder") folder.Name = "Flipbook" folder:SetAttribute("size", size) for i, frame in ipairs(flipbook) do local sv = Instance.new("StringValue") sv.Name = tostring(i) sv.Value = frame.rgba sv.Parent = folder end folder.Parent = container container:SetAttribute("lowpoly_flipbook", true) local player = Instance.new("Script") player.Name = "FlipbookPlay" player.Source = FLIPBOOK_PLAY_SOURCE player.Parent = container end --- S-5: a stance offset → the joint's C1, from rest (identity base). --- The same composition order as the animation legs. function Importer:poseToCFrame(p) local cf = CFrame.new(0, p.lift or 0, 0) cf = cf * CFrame.new(0, 0, p.slide or 0) cf = cf * CFrame.Angles(0, math.rad(p.yaw or 0), 0) cf = cf * CFrame.Angles(math.rad(p.swing or 0), 0, 0) return cf end --- S-4: the looks data as a Lua table literal for the generated script. function Importer:looksToLuaTable(looks) local rows = {} for _, lk in ipairs(looks) do local worn = {} for _, id in ipairs(lk.wornIds or {}) do table.insert(worn, string.format("[%q] = true", id)) end table.insert(rows, string.format( "{ name = %q, skin = %s, worn = { %s } }", lk.name, lk.skin and string.format("%q", lk.skin) or "nil", table.concat(worn, ", "))) end return "{\n " .. table.concat(rows, ",\n ") .. "\n}" end --- S-4: install the outfit picker — outfits are named sets of clothes --- parts (the file carries hidden ones too). Parts outside the picked --- outfit turn invisible, never destroyed, so swapping always works; --- an outfit's pinned skin rides the pick through the skin machinery. --- Mirrors installSkins: SetOutfit module + lowpoly_outfit attribute --- + a runtime OutfitSwap Script + an edit-time plugin watcher. --- MOVE SETS: the fig's named sets (Idle, Walk, Run). The container --- carries lowpoly_moveset = the active set's ID; every animation script --- reads it each loop, so flipping it in Properties (or SetMoveSet) --- switches what plays. Edit time plays nothing, as ever. function Importer:installMoveSets(container, data) local sets = data.moveSets if not sets or #sets < 2 then return end local activeId = sets[1].id local names = {} for _, ms in ipairs(sets) do if ms.active then activeId = ms.id end table.insert(names, string.format("{ id = %q, name = %q }", ms.id, ms.name)) end container:SetAttribute("lowpoly_moveset", activeId) local module = Instance.new("ModuleScript") module.Name = "SetMoveSet" module.Source = "-- require(this).SetMoveSet(name) — sets: " .. table.concat(names, ", ") .. [==[ local container = script.Parent local M = {} M.sets = { ]==] .. table.concat(names, ", ") .. [==[ } function M.SetMoveSet(name) for _, s in ipairs(M.sets) do if s.name == name or s.id == name then container:SetAttribute("lowpoly_moveset", s.id) return true end end return false end return M ]==] module.Parent = container end function Importer:installLooks(container, data, partMap) local looks = data.looks if not looks or #looks < 2 then return end local wornIds = data.wornPartIds or {} if #wornIds == 0 then return end local activeName = looks[1].name for _, lk in ipairs(looks) do if lk.active then activeName = lk.name end end container:SetAttribute("lowpoly_outfit", activeName) local function applyLook(name) local look = nil for _, lk in ipairs(looks) do if lk.name == name then look = lk break end end if not look then return end local shown = {} for _, id in ipairs(look.wornIds or {}) do shown[id] = true end for _, id in ipairs(wornIds) do local part = partMap[id] if part then local hide = not shown[id] part.Transparency = hide and 1 or 0 part.CastShadow = not hide part.CanTouch = not hide end end if look.skin then container:SetAttribute("lowpoly_skin", look.skin) end end -- The initial dress: the file arrives with every clothes part -- visible — hide everything outside the active outfit right now -- (edit time, no scripts run; the plugin does it directly). applyLook(activeName) -- Edit-time swap while the plugin is alive: flip lowpoly_outfit in -- Properties and see it, no play needed container:GetAttributeChangedSignal("lowpoly_outfit"):Connect(function() applyLook(container:GetAttribute("lowpoly_outfit")) end) local names = {} for _, lk in ipairs(looks) do table.insert(names, string.format("%q", lk.name)) end local module = Instance.new("ModuleScript") module.Name = "SetOutfit" module.Source = "-- require(this).SetOutfit(name) — outfits: " .. table.concat(names, ", ") .. [==[ local container = script.Parent local M = {} M.names = { ]==] .. table.concat(names, ", ") .. [==[ } function M.SetOutfit(name) container:SetAttribute("lowpoly_outfit", name) end return M ]==] module.Parent = container local worker = Instance.new("Script") worker.Name = "OutfitSwap" worker.Source = OUTFIT_SWAP_PREFIX .. self:looksToLuaTable(looks) .. "\nlocal wornIds = { " .. table.concat( (function() local q = {} for _, id in ipairs(wornIds) do table.insert(q, string.format("%q", id)) end return q end)(), ", ") .. " }\n" .. OUTFIT_SWAP_SUFFIX worker.Parent = container end --- S-5: the named-poses data as a Lua table literal. function Importer:posesToLuaTableS5(namedPoses) local rows = {} for _, np in ipairs(namedPoses) do local parts = {} for id, off in pairs(np.parts or {}) do table.insert(parts, string.format( "[%q] = { lift = %.4f, slide = %.4f, yaw = %.4f, swing = %.4f }", id, off.lift or 0, off.slide or 0, off.yaw or 0, off.swing or 0)) end table.insert(rows, string.format( "{ name = %q, parts = { %s } }", np.name, table.concat(parts, ", "))) end return "{\n " .. table.concat(rows, ",\n ") .. "\n}" end --- S-5 (the NAMED POSES bridge): saved poses ride the file — a --- SetPose module + the lowpoly_pose attribute strike them, and a --- game prop gets a walk-up prompt that advances through them. --- Only jointed parts can pose (same reach as part moves); parts --- with their own move chains keep them. function Importer:installPoses(container, data, partMap) local namedPoses = data.namedPoses if not namedPoses or #namedPoses < 1 then return end -- parts with move chains — the pose system never touches them local chained = {} for _, pd in ipairs(data.parts or {}) do if pd.animation and not pd.animation.hold and #(pd.animation.poses or {}) > 0 then chained[pd.id] = true end end local activeName = "" for _, np in ipairs(namedPoses) do if np.active then activeName = np.name end end container:SetAttribute("lowpoly_pose", activeName) -- Edit-time strike (plugin session): snap, no tween. The initial -- arrangement is already seated by the hold fix — only strike now -- when an active pose names it (idempotent either way). local function applyPose(name) local pose = nil for _, np in ipairs(namedPoses) do if np.name == name then pose = np break end end for id, part in pairs(partMap) do if not chained[id] then local j = self:findJointForPart(part) if j then local off = pose and pose.parts and pose.parts[id] or nil j.C1 = off and self:poseToCFrame(off) or CFrame.new() end end end end if activeName ~= "" then applyPose(activeName) end container:GetAttributeChangedSignal("lowpoly_pose"):Connect(function() applyPose(container:GetAttribute("lowpoly_pose") or "") end) local names = {} for _, np in ipairs(namedPoses) do table.insert(names, string.format("%q", np.name)) end local module = Instance.new("ModuleScript") module.Name = "SetPose" module.Source = "-- require(this).SetPose(name) — poses: " .. table.concat(names, ", ") .. [==[ local container = script.Parent local M = {} M.names = { ]==] .. table.concat(names, ", ") .. [==[ } function M.SetPose(name) container:SetAttribute("lowpoly_pose", name) end function M.Rest() container:SetAttribute("lowpoly_pose", "") end return M ]==] module.Parent = container local worker = Instance.new("Script") worker.Name = "PoseSwap" worker.Source = POSE_SWAP_PREFIX .. self:posesToLuaTableS5(namedPoses) .. "\nlocal chained = { " .. table.concat( (function() local q = {} for id in pairs(chained) do table.insert(q, string.format("[%q] = true", id)) end return q end)(), ", ") .. " }\n" .. POSE_SWAP_SUFFIX worker.Parent = container -- The walk-up pose cycler — game props only, like R-X2's prompt. -- If the play prompt is there too, both show (AlwaysShow) and the -- pose prompt moves to R so one press never means two things. local itemType = data.options and data.options.itemTypeId or "gameItem" if itemType ~= "gameItem" then return end local host = container.PrimaryPart if not host then for _, child in ipairs(container:GetDescendants()) do if child:IsA("BasePart") then host = child break end end end if not host then return end local prompt = Instance.new("ProximityPrompt") prompt.Name = "PosePrompt" prompt.ActionText = "Pose" prompt.ObjectText = container.Name prompt.RequiresLineOfSight = false prompt.MaxActivationDistance = 12 local playPrompt = host:FindFirstChild("WalkUpPlay") if playPrompt and playPrompt:IsA("ProximityPrompt") then playPrompt.Exclusivity = Enum.ProximityPromptExclusivity.AlwaysShow prompt.Exclusivity = Enum.ProximityPromptExclusivity.AlwaysShow prompt.KeyboardKeyCode = Enum.KeyCode.R end prompt.Parent = host local ps = Instance.new("Script") ps.Name = "PoseCycle" ps.Source = POSE_CYCLE_PREFIX .. "{ " .. table.concat(names, ", ") .. " }\n" .. POSE_CYCLE_SUFFIX ps.Parent = prompt end --- Convert pose data to a Lua table string. function Importer:posesToLuaTable(poses) local entries = {} for _, p in ipairs(poses) do table.insert(entries, string.format( "{ lift = %.4f, slide = %.4f, yaw = %.4f, swing = %.4f, scaleMul = %.4f }", p.lift or 0, p.slide or 0, p.yaw or 0, p.swing or 0, p.scaleMul or 1 )) end return "{\n " .. table.concat(entries, ",\n ") .. "\n}" end --- Find the Motor6D joint driving a part. function Importer:findJointForPart(part) for _, obj in part:GetDescendants() do if obj:IsA("Motor6D") and obj.Part1 == part then return obj end end if part.Parent then for _, obj in part.Parent:GetDescendants() do if obj:IsA("Motor6D") and obj.Part1 == part then return obj end end end return nil end --- Create an outfit swap script (v1 only — needs uploaded texture assets). function Importer:createOutfitSwapper(model, outfits) local script = Instance.new("Script") script.Name = "OutfitSwapper" local outfitList = {} for _, name in ipairs(outfits) do table.insert(outfitList, string.format("%q", name)) end script.Source = [[ local model = script.Parent local outfits = { ]] .. table.concat(outfitList, ", ") .. [[ } function SetOutfit(name) for _, p in model:GetDescendants() do if p:IsA("MeshPart") then local sa = p:FindFirstChildOfClass("SurfaceAppearance") if sa then sa.ColorMap = "rbxassetid://" .. name else p.TextureID = "rbxassetid://" .. name end end end end ]] script.Parent = model end --- Create a flip-book texture animation script (v1 only). function Importer:createFlipbookPlayer(model, frameCount) local script = Instance.new("Script") script.Name = "FlipbookPlayer" script.Source = [[ local model = script.Parent local frameCount = ]] .. tostring(frameCount) .. [[ local playing = false function PlayFlipbook() if playing then return end playing = true task.spawn(function() local i = 0 while playing do for _, p in model:GetDescendants() do if p:IsA("MeshPart") then local sa = p:FindFirstChildOfClass("SurfaceAppearance") local tex = "rbxassetid://frame_" .. i if sa then sa.ColorMap = tex else p.TextureID = tex end end end i = (i + 1) % frameCount task.wait(0.5) end end) end function StopFlipbook() playing = false end ]] script.Parent = model end return Importer Classic--[[ Classic.lua — classic clothing lane (RC-5). Takes a PNG exported by Ünflat's Roblox clothing blanks and puts it on a body with zero uploads: StudioService's File:GetTemporaryId() mints a session-only content id, which Shirt/Pants/ShirtGraphic accept happily. The preview lives as long as the Studio session; keeping it for real means uploading the PNG as an image (Asset Manager) and re-pointing the property at the uploaded id — the walkthroughs cover that. Rig policy: dress the SELECTED rig if one is selected (any Model with a Humanoid), otherwise spawn a fresh R6 block rig — classic clothing was born blocky, so that is the honest preview body. ]] local Players = game:GetService("Players") local Selection = game:GetService("Selection") local StudioService = game:GetService("StudioService") local Classic = {} -- Ünflat exports are named unflat-roblox-<kind>.png — read the kind back. local function kindFromName(name) local n = string.lower(name or "") if string.find(n, "pants", 1, true) then return "pants" end if string.find(n, "tshirt", 1, true) or string.find(n, "t-shirt", 1, true) then return "tshirt" end return "shirt" end local function selectedRig() for _, inst in ipairs(Selection:Get()) do if inst:IsA("Model") and inst:FindFirstChildOfClass("Humanoid") then return inst end end return nil end local function spawnBlockRig() local desc = Instance.new("HumanoidDescription") local rig = Players:CreateHumanoidModelFromDescription(desc, Enum.HumanoidRigType.R6) rig.Name = "Ünflat try-on rig" rig.Parent = workspace -- Land it in front of the camera so the payoff is on screen local cam = workspace.CurrentCamera if cam then local look = cam.CFrame.LookVector local spot = cam.CFrame.Position + look * 12 rig:PivotTo(CFrame.new(spot.X, rig:GetPivot().Position.Y, spot.Z)) end return rig end local CLOTH = { shirt = { class = "Shirt", prop = "ShirtTemplate", label = "shirt" }, pants = { class = "Pants", prop = "PantsTemplate", label = "pants" }, tshirt = { class = "ShirtGraphic", prop = "Graphic", label = "t-shirt" }, } --[[ Prompt for a PNG and dress a rig with it. Returns (okBool, messageString). ]] function Classic.dress() local okPrompt, file = pcall(function() return StudioService:PromptImportFile({ "png", "jpg", "jpeg" }) end) if not okPrompt then return false, "File picker unavailable in this Studio version." end if not file then return false, nil -- user cancelled; stay quiet end local okTemp, tempId = pcall(function() return file:GetTemporaryId() end) if not okTemp or not tempId or tempId == "" then return false, "Couldn't stage the picture — import it via the Asset Manager instead." end local kind = kindFromName(file.Name) local spec = CLOTH[kind] local rig = selectedRig() if not rig then local okRig, rigOrErr = pcall(spawnBlockRig) if not okRig then return false, "Couldn't spawn a rig — insert one (Avatar → Character → Block Avatar), select it, and try again." end rig = rigOrErr end -- Replace any clothing of the same class so re-imports swap cleanly local old = rig:FindFirstChildOfClass(spec.class) if old then old:Destroy() end local cloth = Instance.new(spec.class) cloth[spec.prop] = tempId cloth.Parent = rig Selection:Set({ rig }) return true, "Your " .. spec.label .. " is on! Preview only: to keep it, upload the PNG in the Asset Manager and paste its id into " .. spec.prop .. "." end return Classic UI--[[ UI.lua — plugin GUI for the Ünflat (lowpolycreator) plugin. A cream dock widget in the app's palette: · 👕 Dress a rig — the classic clothing lane (Apricot) · 📦 Build a model — pick the .lowpoly from Share & Export (Apricot); the importer builds textured MeshParts and wraps accessories/tools (returned with RA-1 — the app emits .lowpoly v2 again) · ⬆ Save to Roblox — Roblox's own publish dialog (Leaf = confirm), fronted by the pre-upload checklist: thumbnail-before-upload is immutable, the missing accessory dropdown is the ID-verification gate (not a bug), upload ≠ selling, AFT owns attachments · Close (Coral = dismiss) Fonts: the app's Sniglet/Kiwi Maru aren't in Studio's font set — closest stand-ins are FredokaOne (rounded display) and Nunito (soft body). ]] local HttpService = game:GetService("HttpService") local StudioService = game:GetService("StudioService") local UI = {} UI.__index = UI -- Ünflat palette (src/styles.css tokens) local CREAM = Color3.fromRGB(255, 245, 228) -- --cream local CREAM_DARK = Color3.fromRGB(245, 223, 192) -- --cream-dark local APRICOT = Color3.fromRGB(255, 180, 119) -- --apricot local APRICOT_DARK = Color3.fromRGB(212, 136, 74)-- --apricot-dark local LEAF = Color3.fromRGB(140, 212, 140) -- --leaf local CORAL = Color3.fromRGB(255, 139, 126) -- --coral local INK = Color3.fromRGB(74, 55, 40) -- --ink local HEADER_FONT = Enum.Font.FredokaOne -- Sniglet stand-in local BODY_FONT = Enum.Font.Nunito -- Kiwi Maru stand-in function UI.new(plugin) local self = setmetatable({}, UI) self.plugin = plugin self.widget = nil self.visible = false self.onImportRequested = Instance.new("BindableEvent") self.onClassicRequested = Instance.new("BindableEvent") self.onPublishRequested = Instance.new("BindableEvent") self.onCloseRequested = Instance.new("BindableEvent") return self end function UI:toggle() if self.visible then self:hide() else self:show() end end local function makeButton(text, bg, order, parent) local btn = Instance.new("TextButton") btn.Size = UDim2.new(1, 0, 0, 36) btn.BackgroundColor3 = bg btn.TextColor3 = INK btn.TextSize = 15 btn.Font = HEADER_FONT btn.Text = text btn.LayoutOrder = order btn.Parent = parent local corner = Instance.new("UICorner") corner.CornerRadius = UDim.new(0, 8) corner.Parent = btn return btn end local function makeHint(text, order, parent) local label = Instance.new("TextLabel") label.Size = UDim2.new(1, 0, 0, 34) label.BackgroundTransparency = 1 label.Text = text label.TextColor3 = INK label.TextTransparency = 0.25 label.TextSize = 12 label.Font = BODY_FONT label.TextWrapped = true label.TextXAlignment = Enum.TextXAlignment.Left label.LayoutOrder = order label.Parent = parent return label end function UI:show() if self.widget then self.widget.Enabled = true self.visible = true return end local widget = self.plugin:CreateDockWidgetPluginGui( "lowpolycreator_importer", DockWidgetPluginGuiInfo.new( Enum.InitialDockState.Right, false, -- not enabled by default false, -- no override enabled state 280, -- default width 580, -- default height (three lanes + pre-upload checklist) 240, -- min width 440 -- min height ) ) widget.Title = "Ünflat" self.widget = widget self.visible = true -- ScrollingFrame, not Frame: Studio REMEMBERS a dock's size per user, -- so anyone who docked the old 360px panel keeps 360px — content -- below the fold (the checklist!) just clipped invisibly. Scrolling -- makes every lane reachable at any dock height. local frame = Instance.new("ScrollingFrame") frame.Size = UDim2.new(1, 0, 1, 0) frame.CanvasSize = UDim2.new(0, 0, 0, 0) frame.AutomaticCanvasSize = Enum.AutomaticSize.Y frame.ScrollBarThickness = 6 frame.ScrollBarImageColor3 = CREAM_DARK frame.BackgroundColor3 = CREAM frame.BorderSizePixel = 0 frame.Parent = widget local layout = Instance.new("UIListLayout") layout.Padding = UDim.new(0, 8) layout.HorizontalAlignment = Enum.HorizontalAlignment.Center layout.VerticalAlignment = Enum.VerticalAlignment.Top layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Parent = frame local padding = Instance.new("UIPadding") padding.PaddingTop = UDim.new(0, 12) padding.PaddingLeft = UDim.new(0, 12) padding.PaddingRight = UDim.new(0, 12) padding.Parent = frame local title = Instance.new("TextLabel") title.Size = UDim2.new(1, 0, 0, 30) title.BackgroundTransparency = 1 title.Text = "Ünflat" title.TextColor3 = APRICOT_DARK title.TextSize = 22 title.Font = HEADER_FONT title.LayoutOrder = 1 title.Parent = frame -- Classic clothing lane: PNG → dressed rig, zero uploads makeHint("Made clothes? Pick your Roblox clothing PNG and see it on a body.", 2, frame) local clothBtn = makeButton("👕 Dress a rig…", APRICOT, 3, frame) local status = Instance.new("TextLabel") status.Size = UDim2.new(1, 0, 0, 56) status.BackgroundTransparency = 1 status.Text = "" status.TextColor3 = APRICOT_DARK status.TextSize = 12 status.Font = BODY_FONT status.TextWrapped = true status.LayoutOrder = 4 status.Parent = frame self.statusLabel = status local divider = Instance.new("Frame") divider.Size = UDim2.new(1, 0, 0, 2) divider.BackgroundColor3 = CREAM_DARK divider.BorderSizePixel = 0 divider.LayoutOrder = 5 divider.Parent = frame -- Model lane (RA-1): pick the .lowpoly file from Share & Export → Roblox makeHint("Made a hat, prop or flatling? Pick the .lowpoly from Share & Export.", 6, frame) local modelBtn = makeButton("📦 Build a model…", APRICOT, 7, frame) local divider2 = Instance.new("Frame") divider2.Size = UDim2.new(1, 0, 0, 2) divider2.BackgroundColor3 = CREAM_DARK divider2.BorderSizePixel = 0 divider2.LayoutOrder = 8 divider2.Parent = frame -- Publish: opens Roblox's own Save to Roblox dialog for the selection, -- fronted by the pre-upload checklist (RA-4, reference §8 facts -- verified 2026-07-20 — re-verify fees when copy changes): local checklist = Instance.new("TextLabel") checklist.Size = UDim2.new(1, 0, 0, 128) checklist.BackgroundTransparency = 1 checklist.Text = "Before you upload an accessory:" .. "\n🖼 Pick its thumbnail first — it can't change after upload." .. "\n🪪 No accessory choice in the dropdown? Roblox wants ID" .. "\n verification or a linked parental account — not a bug." .. "\n🪙 Upload isn't selling: wearing your own upload costs" .. "\n 80 Robux; selling adds 2-step verification and a" .. "\n Roblox Plus or Premium membership." .. "\n🎯 Don't add attachments — the Fitting Tool places them." checklist.TextColor3 = INK checklist.TextTransparency = 0.2 checklist.TextSize = 11 checklist.Font = BODY_FONT checklist.TextWrapped = true checklist.TextXAlignment = Enum.TextXAlignment.Left checklist.TextYAlignment = Enum.TextYAlignment.Top checklist.LayoutOrder = 9 checklist.Parent = frame local publishBtn = makeButton("⬆ Save to Roblox…", LEAF, 10, frame) local closeBtn = makeButton("Close", CORAL, 11, frame) closeBtn.Size = UDim2.new(1, 0, 0, 30) closeBtn.TextSize = 13 -- Wire events clothBtn.MouseButton1Click:Connect(function() self.onClassicRequested:Fire() end) modelBtn.MouseButton1Click:Connect(function() local ok, fileOrErr = pcall(function() return StudioService:PromptImportFile({ "lowpoly" }) end) if not ok then self:showError("The file picker wouldn't open — try again in a moment.") return end if not fileOrErr then return -- user cancelled the picker end local okRead, contents = pcall(function() return fileOrErr:GetBinaryContents() end) if not okRead or not contents then self:showError("Couldn't read that file — was it the .lowpoly Share & Export saved?") return end local okJson, data = pcall(function() return HttpService:JSONDecode(contents) end) if not okJson or not data or data.app ~= "lowpolycreator" then self:showError("Not an Ünflat .lowpoly file — pick the one from Share & Export → Roblox.") return end self:setStatus("Building…", APRICOT_DARK) self.onImportRequested:Fire(contents) end) publishBtn.MouseButton1Click:Connect(function() self.onPublishRequested:Fire() end) closeBtn.MouseButton1Click:Connect(function() self.onCloseRequested:Fire() end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() if not widget.Enabled then self.visible = false self.onCloseRequested:Fire() end end) end function UI:hide() if self.widget then self.widget.Enabled = false end self.visible = false end function UI:setStatus(text, color) if self.statusLabel then self.statusLabel.Text = text self.statusLabel.TextColor3 = color or APRICOT_DARK end end function UI:showSuccess(msg) self:setStatus("✓ " .. msg, Color3.fromRGB(90, 165, 90)) -- --leaf-dark end function UI:showError(msg) self:setStatus("✕ " .. msg, Color3.fromRGB(212, 85, 74)) -- --coral-dark end return UI