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", } 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" 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 return self:wrapForItemType(model, data, hasJoints) 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 TweenService animations from pose chains. 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") part:SetAttribute("lowpoly_trigger", anim.trigger or "loop") part:SetAttribute("lowpoly_turnAxis", anim.turnAxis or "y") local module = Instance.new("ModuleScript") module.Name = part.Name .. "_animation" local source = [[ local TweenService = game:GetService("TweenService") local joint = script.Parent local baseC1 = joint.C1 local poses = ]] .. self:posesToLuaTable(poses) .. [[ local speedMap = { slow = 1.1, normal = 0.65, fast = 0.4 } local legS = speedMap[joint:GetAttribute("lowpoly_speed")] or 0.65 local stepS = 0.25 local function animate() while true do 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 local yaw = pose.yaw + (nextPose.yaw - pose.yaw) * t local swing = pose.swing + (nextPose.swing - pose.swing) * t local scale = pose.scaleMul + (nextPose.scaleMul - pose.scaleMul) * 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(stepS) end end end end task.spawn(animate) ]] module.Source = source module.Parent = joint end 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