diff --git a/Pixi/Common/BlockInfo.cs b/Pixi/Common/BlockInfo.cs deleted file mode 100644 index e887038..0000000 --- a/Pixi/Common/BlockInfo.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -using GamecraftModdingAPI.Blocks; - -namespace Pixi.Common -{ - public struct BlockInfo - { - public BlockIDs block; - - public BlockColors color; - - public byte darkness; - - public bool visible; - } -} diff --git a/Pixi/Common/BlockJsonInfo.cs b/Pixi/Common/BlockJsonInfo.cs index 38e3fb5..0193254 100644 --- a/Pixi/Common/BlockJsonInfo.cs +++ b/Pixi/Common/BlockJsonInfo.cs @@ -1,4 +1,9 @@ using System; + +using Unity.Mathematics; + +using GamecraftModdingAPI.Blocks; + namespace Pixi.Common { public struct BlockJsonInfo @@ -12,5 +17,25 @@ namespace Pixi.Common public float[] color; public float[] scale; + + internal ProcessedVoxelObjectNotation Process() + { + BlockIDs block = ConversionUtility.BlockIDsToEnum(name); + return new ProcessedVoxelObjectNotation + { + block = block, + blueprint = block == BlockIDs.Invalid, + color = ColorSpaceUtility.QuantizeToBlockColor(color), + metadata = name, + position = ConversionUtility.FloatArrayToFloat3(position), + rotation = ConversionUtility.FloatArrayToFloat3(rotation), + scale = ConversionUtility.FloatArrayToFloat3(scale), + }; + } + + public override string ToString() + { + return $"BlockJsonInfo {{ name:{name}, color:(r{color[0]},g{color[1]},b{color[2]}), position:({position[0]},{position[1]},{position[2]}), rotation:({rotation[0]},{rotation[1]},{rotation[2]}), scale:({scale[0]},{scale[1]},{scale[2]})}}"; + } } } diff --git a/Pixi/Common/BlueprintProvider.cs b/Pixi/Common/BlueprintProvider.cs new file mode 100644 index 0000000..349aca0 --- /dev/null +++ b/Pixi/Common/BlueprintProvider.cs @@ -0,0 +1,9 @@ +namespace Pixi.Common +{ + public interface BlueprintProvider + { + string Name { get; } + + BlockJsonInfo[] Blueprint(string name, BlockJsonInfo root); + } +} \ No newline at end of file diff --git a/Pixi/Common/BlueprintUtility.cs b/Pixi/Common/BlueprintUtility.cs new file mode 100644 index 0000000..382556d --- /dev/null +++ b/Pixi/Common/BlueprintUtility.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Reflection; + +using Newtonsoft.Json; + +namespace Pixi.Common +{ + public static class BlueprintUtility + { + public static Dictionary ParseBlueprintFile(string name) + { + StreamReader bluemap = new StreamReader(File.OpenRead(name)); + return JsonConvert.DeserializeObject>(bluemap.ReadToEnd()); + } + + public static Dictionary ParseBlueprintResource(string name) + { + StreamReader bluemap = new StreamReader(Assembly.GetExecutingAssembly().GetManifestResourceStream(name)); + return JsonConvert.DeserializeObject>(bluemap.ReadToEnd()); + } + + public static ProcessedVoxelObjectNotation[][] ProcessAndExpandBlocks(string name, BlockJsonInfo[] blocks, BlueprintProvider blueprints) + { + List expandedBlocks = new List(); + for (int i = 0; i < blocks.Length; i++) + { + ProcessedVoxelObjectNotation root = blocks[i].Process(); + if (root.blueprint) + { + if (blueprints == null) + { + throw new NullReferenceException("Blueprint block info found but BlueprintProvider is null"); + } + + BlockJsonInfo[] blueprint = blueprints.Blueprint(name, blocks[i]); + ProcessedVoxelObjectNotation[] expanded = new ProcessedVoxelObjectNotation[blueprint.Length]; + for (int j = 0; j < expanded.Length; j++) + { + expanded[j] = blueprint[j].Process(); + } + + expandedBlocks.Add(expanded); + } + else + { + expandedBlocks.Add(new ProcessedVoxelObjectNotation[]{root}); + } + } + return expandedBlocks.ToArray(); + } + + public static ProcessedVoxelObjectNotation[] ProcessBlocks(BlockJsonInfo[] blocks) + { + ProcessedVoxelObjectNotation[] procBlocks = new ProcessedVoxelObjectNotation[blocks.Length]; + for (int i = 0; i < blocks.Length; i++) + { + procBlocks[i] = blocks[i].Process(); + } + + return procBlocks; + } + } +} \ No newline at end of file diff --git a/Pixi/Common/ColorSpaceUtility.cs b/Pixi/Common/ColorSpaceUtility.cs index e7de250..554a843 100644 --- a/Pixi/Common/ColorSpaceUtility.cs +++ b/Pixi/Common/ColorSpaceUtility.cs @@ -65,6 +65,14 @@ namespace Pixi.Common [MethodImpl(MethodImplOptions.AggressiveInlining)] public static BlockColor QuantizeToBlockColor(float[] pixel) { + if (pixel.Length < 3 || pixel[0] < 0 || pixel[1] < 0 || pixel[2] < 0) + { + return new BlockColor + { + Color = BlockColors.Default, + Darkness = 0, + }; + } return QuantizeToBlockColor(new Color(pixel[0], pixel[1], pixel[2])); } @@ -229,6 +237,8 @@ namespace Pixi.Common colorMap[new BlockColor { Color = BlockColors.Red, Darkness = 7 }] = new float[3] { 0.455f, 0.105f, 0.108f }; colorMap[new BlockColor { Color = BlockColors.Red, Darkness = 8 }] = new float[3] { 0.320f, 0.121f, 0.133f }; colorMap[new BlockColor { Color = BlockColors.Red, Darkness = 9 }] = new float[3] { 0.687f, 0.571f, 0.661f }; + // default + colorMap[new BlockColor { Color = BlockColors.Default, Darkness = 0 }] = new float[3] { -1f, -1f, -1f }; } private static void BuildBotColorMap() diff --git a/Pixi/Common/CommandRoot.cs b/Pixi/Common/CommandRoot.cs new file mode 100644 index 0000000..4a81f66 --- /dev/null +++ b/Pixi/Common/CommandRoot.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using UnityEngine; +using Unity.Mathematics; +using Svelto.ECS; + +using GamecraftModdingAPI; +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Commands; +using GamecraftModdingAPI.Utility; + +namespace Pixi.Common +{ + /// + /// Command implementation. + /// CommandRoot.Pixi is the root of all Pixi calls from the CLI + /// + public class CommandRoot : ICustomCommandEngine + { + public void Ready() + { + CommandRegistrationHelper.Register(Name, (name) => tryOrCommandLogError(() => this.Pixi(null, name)), Description); + CommandRegistrationHelper.Register(Name+"2", this.Pixi, "Import something into Gamecraft using magic. Usage: Pixi \"importer\" \"myfile.png\""); + } + + public EntitiesDB entitiesDB { get; set; } + + public void Dispose() + { + CommandRegistrationHelper.Unregister(Name); + CommandRegistrationHelper.Unregister(Name+"2"); + } + + public string Name { get; } = "Pixi"; + + public bool isRemovable { get; } = false; + + public string Description { get; } = "Import something into Gamecraft using magic. Usage: Pixi \"myfile.png\""; + + public Dictionary importers = new Dictionary(); + + public const float BLOCK_SIZE = 0.2f; + + public const float DELTA = BLOCK_SIZE / 2048; + + public static int OPTIMISATION_PASSES = 2; + + public CommandRoot() + { + CommandManager.AddCommand(this); + } + + public void Inject(Importer imp) + { + if (importers.ContainsKey(imp.Priority)) + { + // extend array by 1 and place imp at the end + Importer[] oldArr = importers[imp.Priority]; + Importer[] newArr = new Importer[oldArr.Length + 1]; + for (int i = 0; i < oldArr.Length; i++) + { + newArr[i] = oldArr[i]; + } + newArr[oldArr.Length] = imp; + importers[imp.Priority] = newArr; + } + else + { + importers[imp.Priority] = new Importer[] {imp}; + } + } + + private void Pixi(string importerName, string name) + { + // organise priorities + int[] priorities = importers.Keys.ToArray(); + Array.Sort(priorities); + Array.Reverse(priorities); // higher priorities go first + // find relevant importer + Importer magicImporter = null; + foreach (int p in priorities) + { + Importer[] imps = importers[p]; + for (int i = 0; i < imps.Length; i++) + { + //Logging.MetaLog($"Now checking importer {imps[i].Name}"); + if ((importerName == null && imps[i].Qualifies(name)) + || (importerName != null && imps[i].Name.Contains(importerName))) + { + magicImporter = imps[i]; + break; + } + } + if (magicImporter != null) break; + } + + if (magicImporter == null) + { + Logging.CommandLogError("Unsupported file or string."); + return; + } +#if DEBUG + Logging.MetaLog($"Using '{magicImporter.Name}' to import '{name}'"); +#endif + // import blocks + BlockJsonInfo[] blocksInfo = magicImporter.Import(name); + if (blocksInfo.Length == 0) + { +#if DEBUG + Logging.CommandLogError($"Importer {magicImporter.Name} didn't provide any blocks to import. Mission Aborted!"); +#endif + return; + } + + ProcessedVoxelObjectNotation[][] procVONs; + BlueprintProvider blueprintProvider = magicImporter.BlueprintProvider; + if (blueprintProvider == null) + { + // convert block info to API-compatible format + procVONs = new ProcessedVoxelObjectNotation[][] {BlueprintUtility.ProcessBlocks(blocksInfo)}; + } + else + { + // expand blueprints and convert block info + procVONs = BlueprintUtility.ProcessAndExpandBlocks(name, blocksInfo, magicImporter.BlueprintProvider); + } + // reduce block placements by grouping neighbouring similar blocks + // (after flattening block data representation) + List optVONs = new List(); + for (int arr = 0; arr < procVONs.Length; arr++) + { + for (int elem = 0; elem < procVONs[arr].Length; elem++) + { + optVONs.Add(procVONs[arr][elem]); + } + } +#if DEBUG + Logging.MetaLog($"Imported {optVONs.Count} blocks for '{name}'"); +#endif + int blockCountPreOptimisation = optVONs.Count; + if (magicImporter.Optimisable) + { + for (int pass = 0; pass < OPTIMISATION_PASSES; pass++) + { + OptimiseBlocks(ref optVONs); +#if DEBUG + Logging.MetaLog($"Optimisation pass {pass} completed"); +#endif + } +#if DEBUG + Logging.MetaLog($"Optimised down to {optVONs.Count} blocks for '{name}'"); +#endif + } + ProcessedVoxelObjectNotation[] optVONsArr = optVONs.ToArray(); + magicImporter.PreProcess(name, ref optVONsArr); + // place blocks + Block[] blocks = new Block[optVONsArr.Length]; + for (int i = 0; i < optVONsArr.Length; i++) + { + ProcessedVoxelObjectNotation desc = optVONsArr[i]; + if (desc.block != BlockIDs.Invalid) + { + Block b = Block.PlaceNew(desc.block, desc.position, desc.rotation, desc.color.Color, + desc.color.Darkness, 1, desc.scale); + blocks[i] = b; + } + } + magicImporter.PostProcess(name, ref blocks); + if (magicImporter.Optimisable && blockCountPreOptimisation > blocks.Length) + { + Logging.CommandLog($"Imported {blocks.Length} blocks using {magicImporter.Name} ({blockCountPreOptimisation/blocks.Length}x ratio)"); + } + else + { + Logging.CommandLog($"Imported {blocks.Length} blocks using {magicImporter.Name}"); + } + + } + + private void OptimiseBlocks(ref List optVONs) + { + // a really complicated algorithm to determine if two similar blocks are touching (before they're placed) + // the general concept: + // two blocks are touching when they have a common face (equal to 4 corners on the cube, where the 4 corners aren't completely opposite each other) + // between the two blocks, the 8 corners that aren't in common are the corners for the merged block + // + // to merge the 2 blocks, switch out the 4 common corners of one block with the nearest non-common corners from the other block + // i.e. swap the common face on block A with the face opposite the common face of block B + // to prevent a nonsensical face (rotated compared to other faces), the corners of the face should be swapped out with the corresponding corner which shares an edge + // + // note: e.g. if common face on block A is its top, the common face of block B is not necessarily the bottom face because blocks can be rotated differently + // this means it's not safe to assume that block A's common face (top) can be swapped with block B's non-common opposite face (top) to get the merged block + // + // note2: this does not work with blocks which aren't cubes (i.e. any block where rotation matters) + // TODO multithread this expensive operation + int item = 0; + while (item < optVONs.Count) + { + bool isItemUpdated = false; + ProcessedVoxelObjectNotation itemVON = optVONs[item]; + if (isOptimisableBlock(itemVON.block)) + { + float3[] itemCorners = calculateCorners(itemVON); + int seeker = item + 1; // despite this, assume that seeker goes thru the entire list (not just blocks after item) + while (seeker < optVONs.Count) + { + if (seeker == item) + { + seeker++; + } + else + { + ProcessedVoxelObjectNotation seekerVON = optVONs[seeker]; + //Logging.MetaLog($"Comparing {itemVON} and {seekerVON}"); + float3[] seekerCorners = calculateCorners(seekerVON); + int[][] mapping = findMatchingCorners(itemCorners, seekerCorners); + if (mapping.Length != 0 + && itemVON.block == seekerVON.block + && itemVON.color.Color == seekerVON.color.Color + && itemVON.color.Darkness == seekerVON.color.Darkness + && isOptimisableBlock(seekerVON.block)) // match found + { + // switch out corners based on mapping + //Logging.MetaLog($"Corners {float3ArrToString(itemCorners)}\nand {float3ArrToString(seekerCorners)}"); + //Logging.MetaLog($"Mappings (len:{mapping[0].Length}) {mapping[0][0]} -> {mapping[1][0]}\n{mapping[0][1]} -> {mapping[1][1]}\n{mapping[0][2]} -> {mapping[1][2]}\n{mapping[0][3]} -> {mapping[1][3]}\n"); + for (byte i = 0; i < 4; i++) + { + itemCorners[mapping[0][i]] = seekerCorners[mapping[1][i]]; + } + // remove 2nd block, since it's now part of the 1st block + //Logging.MetaLog($"Removing {seekerVON}"); + optVONs.RemoveAt(seeker); + if (seeker < item) + { + item--; // note: this will never become less than 0 + } + isItemUpdated = true; + // regenerate info + //Logging.MetaLog($"Final corners {float3ArrToString(itemCorners)}"); + updateVonFromCorners(itemCorners, ref itemVON); + itemCorners = calculateCorners(itemVON); + //Logging.MetaLog($"Merged block is {itemVON}"); + } + else + { + seeker++; + } + } + } + + if (isItemUpdated) + { + optVONs[item] = itemVON; + //Logging.MetaLog($"Optimised block is now {itemVON}"); + } + item++; + } + else + { + item++; + } + } + } + + private float3[] calculateCorners(ProcessedVoxelObjectNotation von) + { + float3[] cornerMultiplicands = new float3[8] + { + new float3(1, 1, 1), + new float3(1, 1, -1), + new float3(-1, 1, 1), + new float3(-1, 1, -1), + new float3(-1, -1, 1), + new float3(-1, -1, -1), + new float3(1, -1, 1), + new float3(1, -1, -1), + }; + float3[] corners = new float3[8]; + Quaternion rotation = Quaternion.Euler(von.rotation); + float3 rotatedScale = rotation * von.scale; + float3 trueCenter = von.position; + // generate corners + for (int i = 0; i < corners.Length; i++) + { + corners[i] = trueCenter + BLOCK_SIZE * (cornerMultiplicands[i] * rotatedScale / 2); + } + return corners; + } + + private void updateVonFromCorners(float3[] corners, ref ProcessedVoxelObjectNotation von) + { + float3[] cornerMultiplicands = new float3[8] + { + new float3(1, 1, 1), + new float3(1, 1, -1), + new float3(1, -1, 1), + new float3(1, -1, -1), + new float3(-1, 1, 1), + new float3(-1, 1, -1), + new float3(-1, -1, 1), + new float3(-1, -1, -1), + }; + float3 newCenter = sumOfFloat3Arr(corners) / corners.Length; + float3 newPosition = newCenter; + Quaternion rot = Quaternion.Euler(von.rotation); + float3 rotatedScale = 2 * (corners[0] - newCenter) / BLOCK_SIZE; + von.scale = Quaternion.Inverse(rot) * rotatedScale; + von.position = newPosition; + //Logging.MetaLog($"Updated VON scale {von.scale} (absolute {rotatedScale})"); + } + + private int[][] findMatchingCorners(float3[] corners1, float3[] corners2) + { + int[][] cornerFaceMappings = new int[][] + { + new int[] {0, 1, 2, 3}, // top + new int[] {2, 3, 4, 5}, // left + new int[] {4, 5, 6, 7}, // bottom + new int[] {6, 7, 0, 1}, // right + new int[] {0, 2, 4, 6}, // back + new int[] {1, 3, 5, 7}, // front + }; + int[][] oppositeFaceMappings = new int[][] + { + new int[] {6, 7, 4, 5}, // bottom + new int[] {0, 1, 6, 7}, // right + new int[] {2, 3, 0, 1}, // top + new int[] {4, 5, 2, 3}, // left + new int[] {1, 3, 5, 7}, // front + new int[] {0, 2, 4, 6}, // back + }; + float3[][] faces1 = facesFromCorners(corners1); + float3[][] faces2 = facesFromCorners(corners2); + for (byte i = 0; i < faces1.Length; i++) + { + for (byte j = 0; j < faces2.Length; j++) + { + //Logging.MetaLog($"Checking faces {float3ArrToString(faces1[i])} and {float3ArrToString(faces2[j])}"); + int[] match = matchFace(faces1[i], faces2[j]); + if (match.Length != 0) + { + //Logging.MetaLog($"Matched faces {float3ArrToString(faces1[i])} and {float3ArrToString(faces2[j])}"); + // translate from face mapping to corner mapping + for (byte k = 0; k < match.Length; k++) + { + match[k] = oppositeFaceMappings[j][match[k]]; + } + return new int[][] {cornerFaceMappings[i], match}; // {{itemCorners index}, {seekerCorners index}} + } + } + } + return new int[0][]; + } + + // this assumes the corners are in the order that calculateCorners outputs + private float3[][] facesFromCorners(float3[] corners) + { + return new float3[][] + { + new float3[] {corners[0], corners[1], corners[2], corners[3]}, // top + new float3[] {corners[2], corners[3], corners[4], corners[5]}, // left + new float3[] {corners[4], corners[5], corners[6], corners[7]}, // bottom + new float3[] {corners[6], corners[7], corners[0], corners[1]}, // right + new float3[] {corners[0], corners[2], corners[4], corners[6]}, // back + new float3[] {corners[1], corners[3], corners[5], corners[7]}, // front + }; + } + + private int[] matchFace(float3[] face1, float3[] face2) + { + int[] result = new int[4]; + byte count = 0; + for (int i = 0; i < 4; i++) + { + for (int j = 0; j < 4; j++) + { + //Logging.MetaLog($"Comparing {face1[i]} and {face1[i]} ({Mathf.Abs(face1[i].x - face2[j].x)} & {Mathf.Abs(face1[i].y - face2[j].y)} & {Mathf.Abs(face1[i].z - face2[j].z)} vs {DELTA})"); + // if (face1[i] == face2[j]) + if (Mathf.Abs(face1[i].x - face2[j].x) < DELTA + && Mathf.Abs(face1[i].y - face2[j].y) < DELTA + && Mathf.Abs(face1[i].z - face2[j].z) < DELTA) + { + count++; + result[i] = j; // map corners to each other + break; + } + } + } + //Logging.MetaLog($"matched {count}/4"); + if (count == 4) + { + return result; + } + return new int[0]; + } + + private float3 sumOfFloat3Arr(float3[] arr) + { + float3 total = float3.zero; + for (int i = 0; i < arr.Length; i++) + { + total += arr[i]; + } + + return total; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool isOptimisableBlock(BlockIDs block) + { + return block.ToString().EndsWith("Cube", StringComparison.InvariantCultureIgnoreCase); + } + + private string float3ArrToString(float3[] arr) + { + string result = "["; + foreach (float3 f in arr) + { + result += f.ToString() + ", "; + } + + return result.Substring(0, result.Length - 2) + "]"; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void tryOrCommandLogError(Action toTry) + { + try + { + toTry(); + } + catch (Exception e) + { +#if DEBUG + Logging.CommandLogError("RIP\n" + e); +#else + Logging.CommandLogError("Pixi failed (reason: " + e.Message + ")"); +#endif + } + } + } +} \ No newline at end of file diff --git a/Pixi/Common/ConversionUtility.cs b/Pixi/Common/ConversionUtility.cs new file mode 100644 index 0000000..0755e58 --- /dev/null +++ b/Pixi/Common/ConversionUtility.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using Unity.Mathematics; + +using GamecraftModdingAPI.Blocks; + +namespace Pixi.Common +{ + public static class ConversionUtility + { + private static Dictionary blockEnumMap = null; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void loadBlockEnumMap() + { + blockEnumMap = new Dictionary(); + foreach(BlockIDs e in Enum.GetValues(typeof(BlockIDs))) + { + blockEnumMap[e.ToString()] = e; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static BlockIDs BlockIDsToEnum(string name) + { + if (blockEnumMap == null) loadBlockEnumMap(); + if (blockEnumMap.ContainsKey(name)) return blockEnumMap[name]; + return BlockIDs.Invalid; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float3 FloatArrayToFloat3(float[] vec) + { + if (vec.Length < 3) return float3.zero; + return new float3(vec[0], vec[1], vec[2]); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float[] Float3ToFloatArray(float3 vec) + { + return new float[3] {vec.x, vec.y, vec.z}; + } + } +} \ No newline at end of file diff --git a/Pixi/Common/Importer.cs b/Pixi/Common/Importer.cs new file mode 100644 index 0000000..cd7a5d7 --- /dev/null +++ b/Pixi/Common/Importer.cs @@ -0,0 +1,27 @@ +using GamecraftModdingAPI; + +namespace Pixi.Common +{ + /// + /// Thing importer. + /// This imports the thing by converting it to a common block format that Pixi can understand. + /// + public interface Importer + { + int Priority { get; } + + bool Optimisable { get; } + + string Name { get; } + + BlueprintProvider BlueprintProvider { get; } + + bool Qualifies(string name); + + BlockJsonInfo[] Import(string name); + + void PreProcess(string name, ref ProcessedVoxelObjectNotation[] blocks); + + void PostProcess(string name, ref Block[] blocks); + } +} \ No newline at end of file diff --git a/Pixi/Common/ProcessedVoxelObjectNotation.cs b/Pixi/Common/ProcessedVoxelObjectNotation.cs new file mode 100644 index 0000000..7ae3bb2 --- /dev/null +++ b/Pixi/Common/ProcessedVoxelObjectNotation.cs @@ -0,0 +1,40 @@ +using Unity.Mathematics; + +using GamecraftModdingAPI.Blocks; + +namespace Pixi.Common +{ + public struct ProcessedVoxelObjectNotation + { + public BlockIDs block; + + public BlockColor color; + + public bool blueprint; + + public float3 position; + + public float3 rotation; + + public float3 scale; + + public string metadata; + + internal BlockJsonInfo VoxelObjectNotation() + { + return new BlockJsonInfo + { + name = block == BlockIDs.Invalid ? metadata.Split(' ')[0] : block.ToString(), + color = ColorSpaceUtility.UnquantizeToArray(color), + position = ConversionUtility.Float3ToFloatArray(position), + rotation = ConversionUtility.Float3ToFloatArray(rotation), + scale = ConversionUtility.Float3ToFloatArray(scale), + }; + } + + public override string ToString() + { + return $"ProcessedVoxelObjectNotation {{ block:{block}, color:{color.Color}-{color.Darkness}, blueprint:{blueprint}, position:{position}, rotation:{rotation}, scale:{scale}}} ({metadata})"; + } + } +} \ No newline at end of file diff --git a/Pixi/Images/ImageCanvasImporter.cs b/Pixi/Images/ImageCanvasImporter.cs new file mode 100644 index 0000000..76bc4a1 --- /dev/null +++ b/Pixi/Images/ImageCanvasImporter.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Unity.Mathematics; +using UnityEngine; + +using GamecraftModdingAPI; +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Players; +using GamecraftModdingAPI.Utility; +using Pixi.Common; + +namespace Pixi.Images +{ + public class ImageCanvasImporter : Importer + { + public static float3 Rotation = float3.zero; + + public static uint Thiccness = 1; + + public int Priority { get; } = 1; + + public bool Optimisable { get; } = true; + + public string Name { get; } = "ImageCanvas~Spell"; + + public BlueprintProvider BlueprintProvider { get; } = null; + + public bool Qualifies(string name) + { + //Logging.MetaLog($"Qualifies received name {name}"); + return name.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) + || name.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase); + } + + public BlockJsonInfo[] Import(string name) + { + // Load image file and convert to Gamecraft blocks + Texture2D img = new Texture2D(64, 64); + // load file into texture + try + { + byte[] imgData = File.ReadAllBytes(name); + img.LoadImage(imgData); + } + catch (Exception e) + { + Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); + Logging.MetaLog(e.Message + "\n" + e.StackTrace); + return new BlockJsonInfo[0]; + } + //Logging.CommandLog($"Image size: {img.width}x{img.height}"); + Player p = new Player(PlayerType.Local); + string pickedBlock = p.SelectedBlock == BlockIDs.Invalid ? BlockIDs.AluminiumCube.ToString() : p.SelectedBlock.ToString(); + Quaternion imgRotation = Quaternion.Euler(Rotation); + + BlockJsonInfo[] blocks = new BlockJsonInfo[img.width * img.height]; + // convert the image to blocks + // optimisation occurs later + for (int x = 0; x < img.width; x++) + { + for (int y = 0; y < img.height; y++) + { + Color pixel = img.GetPixel(x, y); + float3 position = (imgRotation * (new float3((x * CommandRoot.BLOCK_SIZE),y * CommandRoot.BLOCK_SIZE,0))); + BlockJsonInfo qPixel = new BlockJsonInfo + { + name = pixel.a > 0.75 ? pickedBlock : BlockIDs.GlassCube.ToString(), + color = new float[] {pixel.r, pixel.g, pixel.b}, + rotation = ConversionUtility.Float3ToFloatArray(Rotation), + position = ConversionUtility.Float3ToFloatArray(position), + scale = new float[] { 1, 1, Thiccness}, + }; + if (pixel.a < 0.5f) qPixel.name = BlockIDs.Invalid.ToString(); + blocks[(x * img.height) + y] = qPixel; + } + } + return blocks; + } + + public void PreProcess(string name, ref ProcessedVoxelObjectNotation[] blocks) + { + Player p = new Player(PlayerType.Local); + float3 pos = p.Position; + for (int i = 0; i < blocks.Length; i++) + { + blocks[i].position += pos; + } + } + + public void PostProcess(string name, ref Block[] blocks) { } + } +} \ No newline at end of file diff --git a/Pixi/Images/ImageCommandImporter.cs b/Pixi/Images/ImageCommandImporter.cs new file mode 100644 index 0000000..97a9551 --- /dev/null +++ b/Pixi/Images/ImageCommandImporter.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using Unity.Mathematics; +using UnityEngine; + +using GamecraftModdingAPI; +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Players; +using GamecraftModdingAPI.Utility; +using Pixi.Common; + +namespace Pixi.Images +{ + public class ImageCommandImporter : Importer + { + public int Priority { get; } = 0; + + public bool Optimisable { get; } = false; + + public string Name { get; } = "ImageConsole~Spell"; + + public BlueprintProvider BlueprintProvider { get; } = null; + + private Dictionary commandBlockContents = new Dictionary(); + + public bool Qualifies(string name) + { + return name.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) + || name.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase); + } + + public BlockJsonInfo[] Import(string name) + { + // Thanks to Nullpersona for the idea + // Load image file and convert to Gamecraft blocks + Texture2D img = new Texture2D(64, 64); + // load file into texture + try + { + byte[] imgData = File.ReadAllBytes(name); + img.LoadImage(imgData); + } + catch (Exception e) + { + Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); + Logging.MetaLog(e.Message + "\n" + e.StackTrace); + return new BlockJsonInfo[0]; + } + string text = PixelUtility.TextureToString(img); // conversion + // save console's command + commandBlockContents[name] = text; + return new BlockJsonInfo[] + { + new BlockJsonInfo + { + color = new float[] {-1f, -1f, -1f}, + name = "ConsoleBlock" + } + }; + } + + public void PreProcess(string name, ref ProcessedVoxelObjectNotation[] blocks) + { + Player p = new Player(PlayerType.Local); + float3 pos = p.Position; + for (int i = 0; i < blocks.Length; i++) + { + blocks[i].position += pos; + } + } + + public void PostProcess(string name, ref Block[] blocks) + { + // populate console block + AsyncUtils.WaitForSubmission(); // just in case + ConsoleBlock cb = blocks[0].Specialise(); + cb.Command = "ChangeTextBlockCommand"; + cb.Arg1 = "TextBlockID"; + cb.Arg2 = commandBlockContents[name]; + cb.Arg3 = ""; + commandBlockContents.Remove(name); + } + } +} \ No newline at end of file diff --git a/Pixi/Images/ImageCommands.cs b/Pixi/Images/ImageCommands.cs deleted file mode 100644 index 9610dc8..0000000 --- a/Pixi/Images/ImageCommands.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Security.Cryptography; - -using UnityEngine; -using Unity.Mathematics; -using Svelto.ECS.Experimental; -using Svelto.ECS; - -using GamecraftModdingAPI.Blocks; -using GamecraftModdingAPI.Commands; -using GamecraftModdingAPI.Players; -using GamecraftModdingAPI.Utility; -using GamecraftModdingAPI; - -using Pixi.Common; - -namespace Pixi.Images -{ - public static class ImageCommands - { - public const uint PIXEL_WARNING_THRESHOLD = 25_000; - // hash length to display after Pixi in text block id field - public const uint HASH_LENGTH = 6; - - private static double blockSize = 0.2; - - private static uint thiccness = 1; - - public static float3 Rotation = float3.zero; - - public static void CreateThiccCommand() - { - CommandBuilder.Builder() - .Name("PixiThicc") - .Description("Set the image thickness for Pixi2D. Use this if you'd like add depth to a 2D image after importing.") - .Action((d) => { - if (d > 0) - { - thiccness = (uint)d; - } - else Logging.CommandLogError(""); - }) - .Build(); - } - - public static void CreateImportCommand() - { - CommandBuilder.Builder() - .Name("Pixi2D") - .Description("Converts an image to blocks. Larger images will freeze your game until conversion completes.") - .Action(Pixelate2DFile) - .Build(); - } - - public static void CreateTextCommand() - { - CommandBuilder.Builder() - .Name("PixiText") - .Description("Converts an image to coloured text in a new text block. Larger images may cause save issues.") - .Action(Pixelate2DFileToTextBlock) - .Build(); - } - - public static void CreateTextConsoleCommand() - { - CommandBuilder.Builder() - .Name("PixiConsole") - .Description("Converts an image to a ChangeTextBlockCommand in a new console block. The first parameter is the image filepath and the second parameter is the text block id. Larger images may cause save issues.") - .Action(Pixelate2DFileToCommand) - .Build(); - } - - public static void Pixelate2DFile(string filepath) - { - // Load image file and convert to Gamecraft blocks - Texture2D img = new Texture2D(64, 64); - // load file into texture - try - { - byte[] imgData = File.ReadAllBytes(filepath); - img.LoadImage(imgData); - } - catch (Exception e) - { - Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); - Logging.MetaLog(e.Message + "\n" + e.StackTrace); - return; - } - Logging.CommandLog($"Image size: {img.width}x{img.height}"); - Player p = new Player(PlayerType.Local); - float3 position = p.Position; - BlockIDs pickedBlock = p.SelectedBlock == BlockIDs.Invalid ? BlockIDs.AluminiumCube : p.SelectedBlock; - uint blockCount = 0; - Quaternion imgRotation = Quaternion.Euler(Rotation); - position += (float3)(imgRotation * new float3(1f, (float)blockSize, 0f)); - float3 basePosition = position; - Stopwatch timer = Stopwatch.StartNew(); - // convert the image to blocks - // this groups same-colored pixels in the same column into a single block to reduce the block count - // any further pixel-grouping optimisations (eg 2D grouping) risk increasing conversion time higher than O(x*y) - for (int x = 0; x < img.width; x++) - { - BlockInfo qVoxel = new BlockInfo - { - block = BlockIDs.AbsoluteMathsBlock, // impossible canvas block - color = BlockColors.Default, - darkness = 10, - visible = false, - }; - float3 scale = new float3(1, 1, thiccness); - //position.x += (float)(blockSize); - for (int y = 0; y < img.height; y++) - { - //position.y += (float)blockSize; - Color pixel = img.GetPixel(x, y); - BlockInfo qPixel = PixelUtility.QuantizePixel(pixel); - if (qPixel.darkness != qVoxel.darkness - || qPixel.color != qVoxel.color - || qPixel.visible != qVoxel.visible - || qPixel.block != qVoxel.block) - { - if (y != 0) - { - if (qVoxel.visible) - { - position = basePosition + (float3)(imgRotation * (new float3(0,1,0) * (float)((y * blockSize + (y - scale.y) * blockSize) / 2) + new float3(1, 0, 0) * (float)(x * blockSize))); - BlockIDs blockType = qVoxel.block == BlockIDs.AluminiumCube ? pickedBlock : qVoxel.block; - Block.PlaceNew(blockType, position, rotation: Rotation,color: qVoxel.color, darkness: qVoxel.darkness, scale: scale); - blockCount++; - } - scale = new float3(1, 1, thiccness); - } - qVoxel = qPixel; - } - else - { - scale.y += 1; - } - - } - if (qVoxel.visible) - { - position = basePosition + (float3)(imgRotation * (new float3(0, 1, 0) * (float)((img.height * blockSize + (img.height - scale.y) * blockSize) / 2) + new float3(1, 0, 0) * (float)(x * blockSize))); - BlockIDs blockType = qVoxel.block == BlockIDs.AluminiumCube ? pickedBlock : qVoxel.block; - Block.PlaceNew(blockType, position, rotation: Rotation, color: qVoxel.color, darkness: qVoxel.darkness, scale: scale); - blockCount++; - } - //position.y = zero_y; - } - timer.Stop(); - Logging.CommandLog($"Placed {img.width}x{img.height} image beside you ({blockCount} blocks total, {blockCount * 100 / (img.width * img.height)}%)"); - Logging.MetaLog($"Placed {blockCount} in {timer.ElapsedMilliseconds}ms (saved {(img.width * img.height) - blockCount} blocks -- {blockCount * 100 / (img.width * img.height)}% original size) for {filepath}"); - } - - public static void Pixelate2DFileToTextBlock(string filepath) - { - // Thanks to TheGreenGoblin for the idea (and the working Python implementation for reference) - // Load image file and convert to Gamecraft blocks - Texture2D img = new Texture2D(64, 64); - // load file into texture - try - { - byte[] imgData = File.ReadAllBytes(filepath); - img.LoadImage(imgData); - } - catch (Exception e) - { - Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); - Logging.MetaLog(e.Message + "\n" + e.StackTrace); - return; - } - float3 position = new Player(PlayerType.Local).Position; - position.x += 1f; - position.y += (float)blockSize; - Stopwatch timer = Stopwatch.StartNew(); - string text = PixelUtility.TextureToString(img); - TextBlock textBlock = TextBlock.PlaceNew(position, scale: new float3(Mathf.Ceil(img.width / 16), 1, Mathf.Ceil(img.height / 16))); - textBlock.Text = text; - byte[] textHash; - using (HashAlgorithm hasher = SHA256.Create()) - textHash = hasher.ComputeHash(Encoding.UTF8.GetBytes(text)); - string textId = "Pixi_"; - // every byte converts to 2 hexadecimal characters so hash length needs to be halved - for (int i = 0; i < HASH_LENGTH/2 && i < textHash.Length; i++) - { - textId += textHash[i].ToString("X2"); - } - textBlock.TextBlockId = textId; - timer.Stop(); - Logging.CommandLog($"Placed {img.width}x{img.height} image in text block named {textId} beside you ({text.Length} characters)"); - Logging.MetaLog($"Completed image text block {textId} synthesis in {timer.ElapsedMilliseconds}ms containing {text.Length} characters for {img.width*img.height} pixels"); - } - - public static void Pixelate2DFileToCommand(string filepath, string textBlockId) - { - // Thanks to Nullpersonan for the idea - // Load image file and convert to Gamecraft blocks - Texture2D img = new Texture2D(64, 64); - // load file into texture - try - { - byte[] imgData = File.ReadAllBytes(filepath); - img.LoadImage(imgData); - } - catch (Exception e) - { - Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); - Logging.MetaLog(e.Message + "\n" + e.StackTrace); - return; - } - float3 position = new Player(PlayerType.Local).Position; - position.x += 1f; - position.y += (float)blockSize; - Stopwatch timer = Stopwatch.StartNew(); - float zero_y = position.y; - string text = PixelUtility.TextureToString(img); // conversion - ConsoleBlock console = ConsoleBlock.PlaceNew(position); - // set console's command - console.Command = "ChangeTextBlockCommand"; - console.Arg1 = textBlockId; - console.Arg2 = text; - console.Arg3 = ""; - Logging.CommandLog($"Placed {img.width}x{img.height} image in console block beside you ({text.Length} characters)"); - Logging.MetaLog($"Completed image console block {textBlockId} synthesis in {timer.ElapsedMilliseconds}ms containing {text.Length} characters for {img.width * img.height} pixels"); - } - } -} diff --git a/Pixi/Images/ImageTextBlockImporter.cs b/Pixi/Images/ImageTextBlockImporter.cs new file mode 100644 index 0000000..7a9fc24 --- /dev/null +++ b/Pixi/Images/ImageTextBlockImporter.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Unity.Mathematics; +using UnityEngine; + +using GamecraftModdingAPI; +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Players; +using GamecraftModdingAPI.Utility; +using Pixi.Common; + +namespace Pixi.Images +{ + public class ImageTextBlockImporter : Importer + { + public int Priority { get; } = 0; + + public bool Optimisable { get; } = false; + + public string Name { get; } = "ImageText~Spell"; + + public BlueprintProvider BlueprintProvider { get; } = null; + + private Dictionary textBlockContents = new Dictionary(); + + public bool Qualifies(string name) + { + return name.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) + || name.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase); + } + + public BlockJsonInfo[] Import(string name) + { + Texture2D img = new Texture2D(64, 64); + // load file into texture + try + { + byte[] imgData = File.ReadAllBytes(name); + img.LoadImage(imgData); + } + catch (Exception e) + { + Logging.CommandLogError($"Failed to load picture data. Reason: {e.Message}"); + Logging.MetaLog(e.Message + "\n" + e.StackTrace); + return new BlockJsonInfo[0]; + } + string text = PixelUtility.TextureToString(img); + // generate text block name + byte[] textHash; + using (HashAlgorithm hasher = SHA256.Create()) + textHash = hasher.ComputeHash(Encoding.UTF8.GetBytes(text)); + string textId = "Pixi_"; + for (int i = 0; i < 2 && i < textHash.Length; i++) + { + textId += textHash[i].ToString("X2"); + } + + // save text block info for post-processing + textBlockContents[name] = new string[2] { textId, text}; + + return new BlockJsonInfo[1] + { + new BlockJsonInfo + { + color = new float[] {-1f, -1f, -1f}, + name = "TextBlock", + position = new float[] {0f, 0f, 0f}, + rotation = new float[] {0f, 0f, 0f}, + scale = new float[] {Mathf.Ceil(img.width / 16f), 1f, Mathf.Ceil(img.height / 16f)} + } + }; + } + + public void PreProcess(string name, ref ProcessedVoxelObjectNotation[] blocks) + { + Player p = new Player(PlayerType.Local); + float3 pos = p.Position; + for (int i = 0; i < blocks.Length; i++) + { + blocks[i].position += pos; + } + } + + public void PostProcess(string name, ref Block[] blocks) + { + // populate text block + AsyncUtils.WaitForSubmission(); // just in case + TextBlock tb = blocks[0].Specialise(); + tb.TextBlockId = textBlockContents[name][0]; + tb.Text = textBlockContents[name][1]; + textBlockContents.Remove(name); + } + } +} \ No newline at end of file diff --git a/Pixi/Images/PixelUtility.cs b/Pixi/Images/PixelUtility.cs index 40298ed..1e19ebc 100644 --- a/Pixi/Images/PixelUtility.cs +++ b/Pixi/Images/PixelUtility.cs @@ -14,42 +14,42 @@ namespace Pixi.Images public static class PixelUtility { [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static BlockInfo QuantizePixel(Color pixel) - { -#if DEBUG - Logging.MetaLog($"Color (r:{pixel.r}, g:{pixel.g}, b:{pixel.b})"); -#endif - BlockColor c = ColorSpaceUtility.QuantizeToBlockColor(pixel); - - BlockInfo result = new BlockInfo - { - block = pixel.a > 0.75 ? BlockIDs.AluminiumCube : BlockIDs.GlassCube, - color = c.Color, - darkness = c.Darkness, - visible = pixel.a > 0.5f, - }; -#if DEBUG - Logging.MetaLog($"Quantized {result.color} (b:{result.block} d:{result.darkness} v:{result.visible})"); -#endif - return result; - } - - public static string HexPixel(Color pixel) + public static string HexPixel(Color pixel) { return "#"+ColorUtility.ToHtmlStringRGBA(pixel); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Color PixelHex(string hex) + { + if (ColorUtility.TryParseHtmlString(hex, out Color result)) + { + return result; + } + return default; + } public static string TextureToString(Texture2D img) { StringBuilder imgString = new StringBuilder(""); + bool lastPixelAssigned = false; + Color lastPixel = new Color(); for (int y = img.height-1; y >= 0 ; y--) // text origin is top right, but img origin is bottom right { for (int x = 0; x < img.width; x++) { Color pixel = img.GetPixel(x, y); - imgString.Append(""); + if (!lastPixelAssigned || lastPixel != pixel) + { + imgString.Append(""); + lastPixel = pixel; + if (!lastPixelAssigned) + { + lastPixelAssigned = true; + } + } imgString.Append("\u25a0"); } imgString.Append("
"); diff --git a/Pixi/Pixi.csproj b/Pixi/Pixi.csproj index fb46a18..2eca527 100644 --- a/Pixi/Pixi.csproj +++ b/Pixi/Pixi.csproj @@ -3,7 +3,7 @@ net472 true - 0.4.0 + 1.0.0 NGnius MIT https://git.exmods.org/NGnius/Pixi diff --git a/Pixi/PixiPlugin.cs b/Pixi/PixiPlugin.cs index d80a0af..15e1e28 100644 --- a/Pixi/PixiPlugin.cs +++ b/Pixi/PixiPlugin.cs @@ -9,6 +9,7 @@ using Unity.Mathematics; // float3 using IllusionPlugin; using GamecraftModdingAPI.Utility; +using Pixi.Common; using Pixi.Images; using Pixi.Robots; @@ -19,7 +20,7 @@ namespace Pixi public string Name { get; } = Assembly.GetExecutingAssembly().GetName().Name; // Pixi // To change the name, change the project's name - public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version.ToString(); // 0.1.0 (for now) + public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version.ToString(); // To change the version, change #.#.# in Pixi.csproj // called when Gamecraft shuts down @@ -40,20 +41,19 @@ namespace Pixi // check out the modding API docs here: https://mod.exmods.org/ // Initialize Pixi mod - // 2D image functionality - ImageCommands.CreateThiccCommand(); - ImageCommands.CreateImportCommand(); - ImageCommands.CreateTextCommand(); - ImageCommands.CreateTextConsoleCommand(); + CommandRoot root = new CommandRoot(); + // 2D Image Functionality + root.Inject(new ImageCanvasImporter()); + root.Inject(new ImageTextBlockImporter()); + root.Inject(new ImageCommandImporter()); // Robot functionality - RobotCommands.CreateRobotCRFCommand(); - RobotCommands.CreateRobotFileCommand(); + root.Inject(new RobotInternetImporter()); + //RobotCommands.CreateRobotCRFCommand(); + //RobotCommands.CreateRobotFileCommand(); #if DEBUG // Development functionality RobotCommands.CreatePartDumpCommand(); #endif - - Logging.LogDebug($"{Name} has started up"); } // unused methods diff --git a/Pixi/Robots/CubeUtility.cs b/Pixi/Robots/CubeUtility.cs index 713670f..a658d94 100644 --- a/Pixi/Robots/CubeUtility.cs +++ b/Pixi/Robots/CubeUtility.cs @@ -75,7 +75,7 @@ namespace Pixi.Robots public static CubeInfo TranslateSpacialEnumerations(uint cubeId, byte x, byte y, byte z, byte rotation, byte colour, byte colour_x, byte colour_y, byte colour_z) { if (x != colour_x || z != colour_z || y != colour_y) return default; - CubeInfo result = new CubeInfo { visible = true, cubeId = cubeId }; + CubeInfo result = new CubeInfo {visible = true, cubeId = cubeId, scale = new float3(1, 1, 1)}; TranslateBlockColour(colour, ref result); TranslateBlockPosition(x, y, z, ref result); TranslateBlockRotation(rotation, ref result); @@ -196,6 +196,22 @@ namespace Pixi.Robots result.darkness = c.Darkness; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string CubeIdDescription(uint cubeId) + { + if (map == null) + { + StreamReader cubemap = new StreamReader(Assembly.GetExecutingAssembly().GetManifestResourceStream("Pixi.cubes-id.json")); + map = JsonConvert.DeserializeObject>(cubemap.ReadToEnd()); + } + if (!map.ContainsKey(cubeId)) + { + return "Unknown cube #" + cubeId.ToString(); + //result.rotation = float3.zero; + } + return map[cubeId]; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void TranslateBlockId(uint cubeId, ref CubeInfo result) { @@ -314,12 +330,12 @@ namespace Pixi.Robots Logging.LogWarning($"Found empty blueprint for {cube.name} (id:{cube.cubeId}), is the blueprint correct?"); return new Block[0]; } - float3 defaultCorrectionVec = new float3((float)(0), (float)(RobotCommands.blockSize), (float)(0)); + float3 defaultCorrectionVec = new float3((float)(0), (float)(CommandRoot.BLOCK_SIZE), (float)(0)); float3 baseRot = new float3(blueprint[0].rotation[0], blueprint[0].rotation[1], blueprint[0].rotation[2]); float3 baseScale = new float3(blueprint[0].scale[0], blueprint[0].scale[1], blueprint[0].scale[2]); Block[] placedBlocks = new Block[blueprint.Length]; bool isBaseScaled = !(blueprint[0].scale[1] > 0f && blueprint[0].scale[1] < 2f); - float3 correctionVec = isBaseScaled ? (float3)(Quaternion.Euler(baseRot) * baseScale / 2) * (float)-RobotCommands.blockSize : -defaultCorrectionVec; + float3 correctionVec = isBaseScaled ? (float3)(Quaternion.Euler(baseRot) * baseScale / 2) * (float)-CommandRoot.BLOCK_SIZE : -defaultCorrectionVec; // FIXME scaled base blocks cause the blueprint to be placed in the wrong location (this also could be caused by a bug in DumpVON command) if (isBaseScaled) { diff --git a/Pixi/Robots/RobotBlueprintProvider.cs b/Pixi/Robots/RobotBlueprintProvider.cs new file mode 100644 index 0000000..3e617d3 --- /dev/null +++ b/Pixi/Robots/RobotBlueprintProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +using Svelto.DataStructures; +using Unity.Mathematics; +using UnityEngine; + +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Utility; +using Pixi.Common; + +namespace Pixi.Robots +{ + public class RobotBlueprintProvider : BlueprintProvider + { + public string Name { get; } = "RobotBlueprintProvider"; + + private Dictionary botprints = null; + + private RobotInternetImporter parent; + + public RobotBlueprintProvider(RobotInternetImporter rii) + { + parent = rii; + } + + public BlockJsonInfo[] Blueprint(string name, BlockJsonInfo root) + { + if (botprints == null) + { + botprints = BlueprintUtility.ParseBlueprintResource("Pixi.blueprints.json"); + } + + if (!botprints.ContainsKey(root.name) || RobotInternetImporter.CubeSize != 3) + { + if (!parent.textBlockInfo.ContainsKey(name)) + { + parent.textBlockInfo[name] = new FasterList(); + } + BlockJsonInfo copy = root; + copy.name = "TextBlock"; + Logging.MetaLog($"Parsing uint from '{root.name}'"); + parent.textBlockInfo[name].Add(root.name + " (" + CubeUtility.CubeIdDescription(uint.Parse(root.name)) + ")"); + return new BlockJsonInfo[1] {copy}; + } + BlockJsonInfo[] blueprint = botprints[root.name]; + BlockJsonInfo[] adjustedBlueprint = new BlockJsonInfo[blueprint.Length]; + Quaternion cubeQuaternion = Quaternion.Euler(ConversionUtility.FloatArrayToFloat3(root.rotation)); + if (blueprint.Length == 0) + { + Logging.LogWarning($"Found empty blueprint for {root.name} (during '{name}'), is the blueprint correct?"); + return new BlockJsonInfo[0]; + } + // move blocks to correct position & rotation + float3 defaultCorrectionVec = new float3((float)(0), (float)(CommandRoot.BLOCK_SIZE), (float)(0)); + float3 baseRot = new float3(blueprint[0].rotation[0], blueprint[0].rotation[1], blueprint[0].rotation[2]); + float3 baseScale = new float3(blueprint[0].scale[0], blueprint[0].scale[1], blueprint[0].scale[2]); + //Block[] placedBlocks = new Block[blueprint.Length]; + bool isBaseScaled = !(blueprint[0].scale[1] > 0f && blueprint[0].scale[1] < 2f); + float3 correctionVec = isBaseScaled ? (float3)(Quaternion.Euler(baseRot) * baseScale / 2) * (float)-CommandRoot.BLOCK_SIZE : -defaultCorrectionVec; + // FIXME scaled base blocks cause the blueprint to be placed in the wrong location (this also could be caused by a bug in DumpVON command) + if (isBaseScaled) + { + Logging.LogWarning($"Found blueprint with scaled base block for {root.name} (during '{name}'), this is not currently supported"); + } + + float3 rootPos = ConversionUtility.FloatArrayToFloat3(root.position); + for (int i = 0; i < blueprint.Length; i++) + { + BlockColor blueprintBlockColor = ColorSpaceUtility.QuantizeToBlockColor(blueprint[i].color); + float[] physicalColor = blueprintBlockColor.Color == BlockColors.White && blueprintBlockColor.Darkness == 0 ? root.color : blueprint[i].color; + float3 bluePos = ConversionUtility.FloatArrayToFloat3(blueprint[i].position); + float3 blueScale = ConversionUtility.FloatArrayToFloat3(blueprint[i].scale); + float3 blueRot = ConversionUtility.FloatArrayToFloat3(blueprint[i].rotation); + float3 physicalLocation = (float3)(cubeQuaternion * bluePos) + rootPos;// + (blueprintSizeRotated / 2); + //physicalLocation.x += blueprintSize.x / 2; + physicalLocation += (float3)(cubeQuaternion * (correctionVec)); + //physicalLocation.y -= (float)(RobotCommands.blockSize * scale / 2); + //float3 physicalScale = (float3)(cubeQuaternion * blueScale); // this actually over-rotates when combined with rotation + float3 physicalScale = blueScale; + float3 physicalRotation = (cubeQuaternion * Quaternion.Euler(blueRot)).eulerAngles; +#if DEBUG + Logging.MetaLog($"Placing blueprint block at {physicalLocation} rot{physicalRotation} scale{physicalScale}"); + Logging.MetaLog($"Location math check original:{bluePos} rotated: {(float3)(cubeQuaternion * bluePos)} actualPos: {rootPos} result: {physicalLocation}"); + Logging.MetaLog($"Scale math check original:{blueScale} rotation: {(float3)cubeQuaternion.eulerAngles} result: {physicalScale}"); + Logging.MetaLog($"Rotation math check original:{blueRot} rotated: {(cubeQuaternion * Quaternion.Euler(blueRot))} result: {physicalRotation}"); +#endif + adjustedBlueprint[i] = new BlockJsonInfo + { + color = physicalColor, + name = blueprint[i].name, + position = ConversionUtility.Float3ToFloatArray(physicalLocation), + rotation = ConversionUtility.Float3ToFloatArray(physicalRotation), + scale = ConversionUtility.Float3ToFloatArray(physicalScale) + }; + } + return adjustedBlueprint; + } + } +} \ No newline at end of file diff --git a/Pixi/Robots/RobotCommands.cs b/Pixi/Robots/RobotCommands.cs index 68e8560..1c14f20 100644 --- a/Pixi/Robots/RobotCommands.cs +++ b/Pixi/Robots/RobotCommands.cs @@ -18,29 +18,7 @@ namespace Pixi.Robots { public static class RobotCommands { - internal const double blockSize = 0.2; - - public static int CubeSize = 3; - - public static void CreateRobotFileCommand() - { - CommandBuilder.Builder() - .Name("PixiBotFile") - .Description("Converts a robot file from RCBUP into Gamecraft blocks. Larger robots will freeze your game until conversion completes.") - .Action(ImportRobotFile) - .Build(); - } - - public static void CreateRobotCRFCommand() - { - CommandBuilder.Builder() - .Name("PixiBot") - .Description("Downloads a robot from Robocraft's Factory and converts it into Gamecraft blocks. Larger robots will freeze your game until conversion completes.") - .Action(ImportRobotOnline) - .Build(); - } - - public static void CreatePartDumpCommand() + public static void CreatePartDumpCommand() { CommandBuilder.Builder() .Name("DumpVON") @@ -49,159 +27,6 @@ namespace Pixi.Robots .Build(); } - private static void ImportRobotFile(string filepath) - { - string file; - try - { - file = File.ReadAllText(filepath); - } - catch (Exception e) - { - Logging.CommandLogError($"Failed to load robot data. Reason: {e.Message}"); - Logging.MetaLog(e); - return; - } - RobotStruct? robot = CubeUtility.ParseRobotInfo(file); - if (!robot.HasValue) - { - Logging.CommandLogError($"Failed to parse robot data. File format was not recognised."); - return; - } - float3 position = new Player(PlayerType.Local).Position; - position.y += (float)(blockSize * CubeSize * 3); // 3 is roughly the max height of any cube in RC - CubeInfo[] cubes = CubeUtility.ParseCubes(robot.Value); - // move origin closer to player (since bots are rarely built at the garage bay origin) - if (cubes.Length == 0) - { - Logging.CommandLogError($"Robot data contains no cubes"); - return; - } - float3 minPosition = cubes[0].position; - for (int c = 0; c < cubes.Length; c++) - { - float3 cubePos = cubes[c].position; - if (cubePos.x < minPosition.x) - { - minPosition.x = cubePos.x; - } - if (cubePos.y < minPosition.y) - { - minPosition.y = cubePos.y; - } - if (cubePos.z < minPosition.z) - { - minPosition.z = cubePos.z; - } - } - Block[][] blocks = new Block[cubes.Length][]; - for (int c = 0; c < cubes.Length; c++) // sometimes I wish this were C++ - { - CubeInfo cube = cubes[c]; - float3 realPosition = ((cube.position - minPosition) * (float)blockSize * CubeSize) + position; - if (cube.block == BlockIDs.TextBlock && !string.IsNullOrEmpty(cube.name)) - { - // TextBlock block ID means it's a placeholder - blocks[c] = CubeUtility.BuildBlueprintOrTextBlock(cube, realPosition, CubeSize); - } - else - { - blocks[c] = new Block[] { Block.PlaceNew(cube.block, realPosition, cube.rotation, cube.color, cube.darkness, CubeSize) }; - } - } - int blockCount = 0; - for (int c = 0; c < cubes.Length; c++) - { - CubeInfo cube = cubes[c]; - // the goal is for this to never evaluate to true (ie all cubes are translated correctly) - if (!string.IsNullOrEmpty(cube.name) && cube.block == BlockIDs.TextBlock && blocks[c].Length == 1) - { - //Logging.MetaLog($"Block is {blocks[c][0].Type} and was placed as {cube.block}"); - blocks[c][0].Specialise().Text = cube.name; - } - blockCount += blocks[c].Length; - } - Logging.CommandLog($"Placed {robot?.name} by {robot?.addedByDisplayName} beside you ({cubes.Length}RC -> {blockCount}GC)"); - } - - private static void ImportRobotOnline(string robotName) - { - Stopwatch timer = Stopwatch.StartNew(); - // download robot data - RobotStruct robot; - try - { - RobotBriefStruct[] botList = RoboAPIUtility.ListRobots(robotName); - if (botList.Length == 0) - throw new Exception("Failed to find robot"); - robot = RoboAPIUtility.QueryRobotInfo(botList[0].itemId); - - } - catch (Exception e) - { - Logging.CommandLogError($"Failed to download robot data. Reason: {e.Message}"); - Logging.MetaLog(e); - timer.Stop(); - return; - } - timer.Stop(); - Logging.MetaLog($"Completed API calls in {timer.ElapsedMilliseconds}ms"); - float3 position = new Player(PlayerType.Local).Position; - position.y += (float)(blockSize * CubeSize * 3); // 3 is roughly the max height of any cube in RC - CubeInfo[] cubes = CubeUtility.ParseCubes(robot); - // move origin closer to player (since bots are rarely built at the garage bay origin) - if (cubes.Length == 0) - { - Logging.CommandLogError($"Robot data contains no cubes"); - return; - } - float3 minPosition = cubes[0].position; - for (int c = 0; c < cubes.Length; c++) - { - float3 cubePos = cubes[c].position; - if (cubePos.x < minPosition.x) - { - minPosition.x = cubePos.x; - } - if (cubePos.y < minPosition.y) - { - minPosition.y = cubePos.y; - } - if (cubePos.z < minPosition.z) - { - minPosition.z = cubePos.z; - } - } - Block[][] blocks = new Block[cubes.Length][]; - for (int c = 0; c < cubes.Length; c++) // sometimes I wish this were C++ - { - CubeInfo cube = cubes[c]; - float3 realPosition = ((cube.position - minPosition) * (float)blockSize * CubeSize) + position; - if (cube.block == BlockIDs.TextBlock && !string.IsNullOrEmpty(cube.name)) - { - // TextBlock block ID means it's a placeholder - blocks[c] = CubeUtility.BuildBlueprintOrTextBlock(cube, realPosition, CubeSize); - } - else - { - blocks[c] = new Block[] { Block.PlaceNew(cube.block, realPosition, cube.rotation, cube.color, cube.darkness, CubeSize) }; - } - } - int blockCount = 0; - for (int c = 0; c < cubes.Length; c++) - { - CubeInfo cube = cubes[c]; - // the goal is for this to never evaluate to true (ie all cubes are translated correctly) - if (!string.IsNullOrEmpty(cube.name) && cube.block == BlockIDs.TextBlock && blocks[c].Length == 1) - { - //Logging.MetaLog($"Block is {blocks[c][0].Type} and was placed as {cube.block}"); - blocks[c][0].Specialise().Text = cube.name; - } - blockCount += blocks[c].Length; - } - Logging.CommandLog($"Placed {robot.name} by {robot.addedByDisplayName} beside you ({cubes.Length}RC -> {blockCount}GC)"); - } - private static void DumpBlockStructure(string filename) { Player local = new Player(PlayerType.Local); diff --git a/Pixi/Robots/RobotInternetImporter.cs b/Pixi/Robots/RobotInternetImporter.cs new file mode 100644 index 0000000..a79e6ae --- /dev/null +++ b/Pixi/Robots/RobotInternetImporter.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +using Svelto.DataStructures; +using Unity.Mathematics; +using UnityEngine; + +using GamecraftModdingAPI; +using GamecraftModdingAPI.Blocks; +using GamecraftModdingAPI.Players; +using GamecraftModdingAPI.Utility; +using Pixi.Common; + +namespace Pixi.Robots +{ + public class RobotInternetImporter : Importer + { + public int Priority { get; } = -100; + + public bool Optimisable { get; } = false; + + public string Name { get; } = "RobocraftRobot~Spell"; + + public BlueprintProvider BlueprintProvider { get; } + + public static int CubeSize = 3; + + internal readonly Dictionary> textBlockInfo = new Dictionary>(); + + public RobotInternetImporter() + { + BlueprintProvider = new RobotBlueprintProvider(this); + } + + public bool Qualifies(string name) + { + string[] extensions = name.Split('.'); + return extensions.Length == 1 + || !extensions[extensions.Length - 1].Contains(" "); + } + + public BlockJsonInfo[] Import(string name) + { + // download robot data + RobotStruct robot; + try + { + RobotBriefStruct[] botList = RoboAPIUtility.ListRobots(name); + if (botList.Length == 0) + throw new Exception("Failed to find robot"); + robot = RoboAPIUtility.QueryRobotInfo(botList[0].itemId); + + } + catch (Exception e) + { + Logging.CommandLogError($"Failed to download robot data. Reason: {e.Message}"); + Logging.MetaLog(e); + return new BlockJsonInfo[0]; + } + CubeInfo[] cubes = CubeUtility.ParseCubes(robot); + // move bot closer to origin (since bots are rarely built at the garage bay origin of the bottom south-west corner) + if (cubes.Length == 0) + { + Logging.CommandLogError($"Robot data contains no cubes"); + return new BlockJsonInfo[0]; + } + float3 minPosition = cubes[0].position; + for (int c = 0; c < cubes.Length; c++) + { + float3 cubePos = cubes[c].position; + if (cubePos.x < minPosition.x) + { + minPosition.x = cubePos.x; + } + if (cubePos.y < minPosition.y) + { + minPosition.y = cubePos.y; + } + if (cubePos.z < minPosition.z) + { + minPosition.z = cubePos.z; + } + } + BlockJsonInfo[] blocks = new BlockJsonInfo[cubes.Length]; + for (int c = 0; c < cubes.Length; c++) + { + ref CubeInfo cube = ref cubes[c]; + float3 realPosition = ((cube.position - minPosition) * CommandRoot.BLOCK_SIZE * CubeSize); + if (cube.block == BlockIDs.TextBlock && !string.IsNullOrEmpty(cube.name)) + { + // TextBlock block ID means it's a placeholder + blocks[c] = new BlockJsonInfo + { + color = ColorSpaceUtility.UnquantizeToArray(cube.color, cube.darkness), + name = cube.cubeId.ToString(), + position = ConversionUtility.Float3ToFloatArray(realPosition), + rotation = ConversionUtility.Float3ToFloatArray(cube.rotation), + scale = ConversionUtility.Float3ToFloatArray(cube.scale) + }; + } + else + { + blocks[c] = new BlockJsonInfo + { + color = ColorSpaceUtility.UnquantizeToArray(cube.color, cube.darkness), + name = cube.block.ToString(), + position = ConversionUtility.Float3ToFloatArray(realPosition), + rotation = ConversionUtility.Float3ToFloatArray(cube.rotation), + scale = ConversionUtility.Float3ToFloatArray(cube.scale * CubeSize) + }; + } + } + + return blocks; + } + + public void PreProcess(string name, ref ProcessedVoxelObjectNotation[] blocks) + { + Player p = new Player(PlayerType.Local); + float3 pos = p.Position; + for (int i = 0; i < blocks.Length; i++) + { + blocks[i].position += pos; + } + } + + public void PostProcess(string name, ref Block[] blocks) + { + int textBlockInfoIndex = 0; + for (int c = 0; c < blocks.Length; c++) + { + Block block = blocks[c]; + // the goal is for this to never evaluate to true (ie all cubes are translated correctly) + if (block.Type == BlockIDs.TextBlock) + { + textBlockInfoIndex++; + block.Specialise().Text = textBlockInfo[name][textBlockInfoIndex]; + } + } + + textBlockInfo.Remove(name); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index c71fbc4..6a44ca4 100644 --- a/README.md +++ b/README.md @@ -22,49 +22,40 @@ and `@"[dog name]"` could be a value like `@"Clifford"` or `@"doggo"`. ### Commands -`PixiText @"[image]"` converts an image to text and places a text block with that text beside you. - -`PixiConsole @"[image]" "[text block id]"` converts an image to text and places a console block beside you which changes the specified text block. - -`Pixi2D @"[image]"` converts an image to blocks and places it beside you. +`Pixi @"[thing]"` to import `thing` into your Gamecraft game. For example, if you want to add an image called `pixel_art.png`, stored in Gamecraft's installation directory, -execute the command `Pixi2D @"pixel_art.png"` to load the image as blocks. -It's important to include the file extension, since Pixi isn't capable of black magic (yet). - -**EXPERIMENTAL** +execute the command `Pixi @"pixel_art.png"` to load the image as blocks. +It's important to include the file extension, since that's what makes Pixi's magic work. -`PixiBot @"[bot]"` downloads a bot from Robocraft's community Factory and places it beside you. +If you know the name of the Pixi spell you want, you can also use -`PixiBotFile @"[bot]"` converts a `.bot` file from [rcbup](https://github.com/NGnius/rcbup) to blocks and places it beside you. +`Pixi2 "[spell]" @"[thing]"` to use `spell` to import `thing` into your Gamecraft game. -`PixiThicc [depth]` sets the block thickness, a positive integer value, for `Pixi2D` image conversion. -The default thickness is 1. - -Some commands also have hidden features, like image rotation. -Talk to NGnius on the Exmods Discord server or read the Pixi's source code to figure that out. +Some commands also have hidden features, like image rotation and bot scaling. +Talk to NGnius on the Exmods Discord server or read Pixi's source code to figure that out. ### Behaviour -PixiText and PixiConsole share the same image conversion system. +ImageText and ImageConsole share the same image conversion system. The conversion system converts every pixel to a [color tag](http://digitalnativestudios.com/textmeshpro/docs/rich-text/#color) followed by a square text character. -For PixiText, the resulting character string is set to the text field of the text block that the command places. -For PixiConsole, the character string is automatically set to a console block in the form `ChangeTextBlockCommand [text block id] [character string]`. -Due to limitations in Gamecraft, larger images will crash your game. +For ImageText, the resulting character string is set to the text field of the text block that the command places. +For ImageConsole, the character string is automatically set to a console block in the form `ChangeTextBlockCommand [text block id] [character string]`. +Due to limitations in Gamecraft, larger images will crash your game!!! -Pixi2D takes an image file and converts every pixel to a coloured block. -Pixi2D uses an algorithm to convert each pixel in an image into the closest paint colour, but colour accuracy will never be as good as a regular image. +ImageCanvas takes an image file and converts every pixel to a coloured block. +ImageCanvas uses an algorithm to convert each pixel in an image into the closest paint colour, but colour accuracy will never be as good as a regular image. -Pixi2D's colour-conversion algorithm also uses pixel transparency so you can cut out shapes. +ImageCanvas' colour-conversion algorithm also uses pixel transparency so you can cut out shapes. A pixel which has opacity of less than 50% will be ignored. A pixel which has an opacity between 75% and 50% will be converted into a glass cube. A pixel which has an opacity greater than 75% will be converted into the block you're holding (or aluminium if you've got your hand selected). This only works with `.PNG` image files since the `.JPG` format doesn't support image transparency. -Pixi2D also optimises block placement, since images have a lot of pixels. +ImageCanvas also optimises block placement, since images have a lot of pixels. The blocks grouping ratio is displayed in the command line output once image importing is completed. -PixiBot and PixiBotFile convert a robot to equivalent Gamecraft blocks. +RobocraftRobot converts a robot to equivalent Gamecraft blocks. If the conversion algorithm encounters a block it cannot convert, it will place a text block, with the block name, instead. ## Development @@ -77,7 +68,7 @@ Show your love by offering your help! - Report any bugs that you encounter while using Pixi. - Report an idea for an improvement to Pixi or for a new file format. -For questions, concerns or reports, please contact NGnius in the [Exmods Discord server](https://discord.exmods.org). +For questions, concerns, or any other inquiry, please contact NGnius in the [Exmods Discord server](https://discord.exmods.org). ### Setup @@ -96,14 +87,14 @@ Pixi also requires the [GamecraftModdingAPI](https://git.exmods.org/modtainers/G ### Building After you've completed the setup, open the solution file `Pixi.sln` in your prefered C# .NET/Mono development environment. -I'd recommend Visual Studio Community Edition or JetBrains Rider for Windows and Monodevelop for Linux. +I'd recommend Visual Studio Community Edition for Windows or JetBrains Rider for Linux. If you've successfully completed setup, you should be able to build the Pixi project without errors. -If it doesn't work and you can't figure out why, ask for help on the [Exmods Discord server](https://discord.gg/2CtWzZT). +If it doesn't work and you can't figure out why, ask for help on the [Exmods Discord server](https://discord.exmods.org). # Acknowledgements -PixiBot uses the Factory to download robots, which involves a partial re-implementation of [rcbup](https://github.com/NGnius/rcbup). +RobocraftRobot uses the Factory to download robots, which involves a partial re-implementation of [rcbup](https://github.com/NGnius/rcbup). Robot parsing uses information from [RobocraftAssembler](https://github.com/dddontshoot/RoboCraftAssembler). Gamecraft interactions use the [GamecraftModdingAPI](https://git.exmods.org/modtainers/GamecraftModdingAPI). @@ -114,9 +105,10 @@ Thanks to **Mr. Rotor** for all of the Robocraft blocks used in the PixiBot and # Disclaimer -Pixi, Exmods and NGnius are not endorsed or supported by Gamecraft or FreeJam. +Pixi source code and releases are available free of charge as open-source software for the purpose of modding Gamecraft. Modify Gamecraft at your own risk. -Read the LICENSE file for licensing information. +Read the LICENSE file for official licensing information. +Pixi, Exmods and NGnius are not endorsed or supported by Gamecraft or FreeJam. Please don't sue this project or its contributors (that's what all disclaimers boil down to, right?). -Pixi is not magic and is actually just sufficiently advanced technology that's indistinguishable from magic. +Pixi is actually just sufficiently advanced technology that's indistinguishable from magic.