diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e8520c6..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Build Release - -on: - workflow_dispatch: - -env: - packageName: "dev.hai-vr.animator-as-code.v1" - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: get version - id: version - uses: notiz-dev/github-action-json-property@7c8cf5cc36eb85d8d287a8086a39dac59628eb31 - with: - path: "Packages/${{env.packageName}}/package.json" - prop_path: "version" - - - name: Set Environment Variables - run: | - echo "zipFile=${{ env.packageName }}-${{ steps.version.outputs.prop }}".zip >> $GITHUB_ENV - echo "unityPackage=${{ env.packageName }}-${{ steps.version.outputs.prop }}.unitypackage" >> $GITHUB_ENV - - - name: Create Zip - uses: thedoctor0/zip-release@09336613be18a8208dfa66bd57efafd9e2685657 - with: - type: "zip" - directory: "Packages/${{env.packageName}}/" - filename: "../../${{env.zipFile}}" # make the zip file two directories up, since we start two directories in above - - - run: find "Packages/${{env.packageName}}/" -name \*.meta >> metaList - - - name: Create UnityPackage - uses: pCYSl5EDgo/create-unitypackage@cfcd3cf0391a5ef1306342794866a9897c32af0b - with: - package-path: ${{ env.unityPackage }} - include-files: metaList - - - - name: Make Release - uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 - with: - tag_name: ${{ steps.version.outputs.prop }} - files: | - ${{ env.zipFile }} - ${{ env.unityPackage }} - Packages/${{ env.packageName }}/package.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4a847d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/result diff --git a/Packages/dev.hai-vr.animator-as-code.v1/package.json b/Packages/dev.hai-vr.animator-as-code.v1/package.json deleted file mode 100644 index 3c04ae5..0000000 --- a/Packages/dev.hai-vr.animator-as-code.v1/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "dev.hai-vr.animator-as-code.v1", - "displayName": "Animator As Code V1", - "version": "1.3.0-alpha.1", - "unity": "2019.4", - "description": "Base Animator As Code library. This library only requires Unity.", - "vrchatVersion" : "2022.1.1", - "author" : { - "name" : "Haï~" - }, - "url" : "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base", - "documentationUrl": "https://docs.hai-vr.dev/docs/products/animator-as-code/functions/base", - "changelogUrl": "https://docs.hai-vr.dev/docs/changelogs/animator-as-code", - "license": "MIT" -} diff --git a/README.md b/README.md index bf00ce4..552cae5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,82 @@ -## Animation As Crab +## Animator As Crab -This is a hard fork of https://github.com/hai-vr/av3-animator-as-code -We will be using [rhai](https://rhai.rs/) for writing animation controllers instead of C# +A hard fork of [Animator As Code](https://github.com/hai-vr/av3-animator-as-code) where animator +controllers are written in [Rhai](https://rhai.rs/) instead of C#. -the idea is, (rhai + rust) will output a big eval'd json file that the C# side will parse and generate a native unity animation controller from. +A Rhai script is evaluated by a Rust library (`libaac`), which validates it and emits the whole +controller as JSON. A Unity Editor window deserializes that JSON and builds a real +`AnimatorController` through the original Animator As Code V1 library, which is left untouched. -Original Creator is [hai-vr](https://github.com/hai-vr) +``` +script.rhai --> libaac (Rust) --JSON--> AacCrabWindow (Unity Editor) --> AnimatorController +``` + +## Layout + +| Path | Contents | +| --- | --- | +| `rust/` | The Rhai DSL, graph model, validator, and the `libaac` cdylib. | +| `rust/examples/avatar.rhai` | The reference script, exercising every feature of the DSL. | +| `csharp/dev.doloro.animator-as-crab/` | The Unity package: the untouched Animator As Code V1 library and the bridge in `V1/Editor/Crab/`. | +| `flake.nix` | Dev shell with the Rust toolchain, `dotnet`, `mono`, and `jq`. | + +## Building + +```sh +nix develop +cargo test --manifest-path rust/Cargo.toml +cargo run --bin aac-dump -- rust/examples/avatar.rhai # print the generated JSON +nix build .#aac # libaac.so + aac-dump +``` + +Copy `libaac.so` (or `libaac.dll`, `libaac.dylib`) into the Unity project's `Assets/Plugins` folder, +and add `csharp/dev.doloro.animator-as-crab` to the project's packages. + +## Writing a script + +```rhai +let aac = AnimatorAsCode(); +aac.system_name("MyAvatar"); +aac.asset_key("AAC_"); + +let speed = aac.float_param("Speed", 0.0); +let ctrl = aac.new_controller(); +let base = ctrl.layer("Base"); + +let idle = base.state("Idle", 0, 0); +let walk = base.state("Walk", 1, 0); + +let walk_clip = aac.clip("walk_anim"); +walk_clip.toggle("Body/Props", true); + +walk.set_clip(walk_clip); +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); +``` + +Conditions are written on the parameters themselves (`speed > 0.1`, `is_sitting == true`). +There is no `&&` or `||` — Rhai's are short-circuit built-ins that cannot be overloaded — so +use `when_all([speed < 0.05, is_sitting == false])`. + +See `rust/examples/avatar.rhai` for the full surface: parameters, clips, curves, blend trees, +sub-state machines, any-state transitions, and transition settings. + +## Generating + +Open *Tools > Animator As Crab* and fill in the window: + +- the path to the `.rhai` script (it is watched, so saving the script regenerates the controller), +- the `AnimatorController` to generate into, +- the animator root, the asset container, and the container mode, +- whether states should write defaults. + +Nothing is defaulted silently, and the controller is rebuilt with Animator As Code's modification +API: the controller is cleared, and the clips and blend trees of the same asset key are removed +from the container before being recreated. Animator As Code's own C# API is untouched and still +usable on the same controller. + +## Limitations + +- The Unity side has not been run: there is no Unity project in this repository, so the bridge and + the generator are verified by reading, not by executing. +- Blend trees can only nest a blend tree that was declared earlier in the script. +- The graph carries no avatar masks, layer weights, state behaviours, or parameter drivers yet. diff --git a/Packages/dev.hai-vr.animator-as-code.v1/.gitignore b/csharp/dev.doloro.animator-as-crab/.gitignore similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/.gitignore rename to csharp/dev.doloro.animator-as-crab/.gitignore diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1.meta b/csharp/dev.doloro.animator-as-crab/V1.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1.meta rename to csharp/dev.doloro.animator-as-crab/V1.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/Aac.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/Aac.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAnimatorNode.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAnimatorNode.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacAssetContainerProvider.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacAssetContainerProvider.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacDefaultsProvider.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacDefaultsProvider.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlAnimations.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlAnimations.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlBlendTrees.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlBlendTrees.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlConditions.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlConditions.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlModification.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlModification.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacFlStates.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacFlStates.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AacInternals.cs.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AacInternals.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef similarity index 82% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef index 80acc17..cd63ead 100644 --- a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef @@ -7,7 +7,9 @@ "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": true, - "precompiledReferences": [], + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/AnimatorAsCode.V1.asmdef.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/AnimatorAsCode.V1.asmdef.meta diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs new file mode 100644 index 0000000..cd437a6 --- /dev/null +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGenerator.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AnimatorAsCode.V1; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace AnimatorAsCrab.V1 +{ + /// + /// Turns an into an AnimatorController by driving Animator As Code V1. + /// This uses the modification workflow: the controller is cleared and rebuilt, and no layer of the + /// controller is touched beforehand. + /// + public static class AacCrabGenerator + { + /// Clears the AnimatorController and the assets of the same asset key, then rebuilds them. + public static void Generate(AacCrabGraph graph, AacConfiguration configuration, AnimatorController controller) + { + if (graph == null) throw new ArgumentNullException(nameof(graph)); + if (controller == null) throw new ArgumentNullException(nameof(controller)); + if (graph.Controller == null) throw new InvalidOperationException("The graph has no controller."); + + var aac = AacV1.Create(configuration); + var modification = aac.Modification(); + + aac.ClearPreviousAssets(); + var aacController = modification.ResetAnimatorController(controller); + + var layers = new Dictionary(); + foreach (var layer in graph.Controller.Layers) + { + layers[layer.Name] = aacController.NewLayer(layer.Name); + } + + var floatingParameters = CreateParameters(graph, layers, controller); + + var clips = new Dictionary(); + foreach (var clip in graph.Clips) + { + clips[clip.Name] = CreateClip(aac, clip); + } + + // Blend trees are created in declaration order, so a child motion can only refer to a tree + // that was declared before its parent. This also makes cycles impossible. + var blendTrees = new Dictionary(); + foreach (var blendTree in graph.BlendTrees) + { + blendTrees[blendTree.Name] = CreateBlendTree(aac, blendTree, clips, blendTrees, floatingParameters); + } + + foreach (var layer in graph.Controller.Layers) + { + BuildLayer(layers[layer.Name], layer, clips, blendTrees); + } + + modification.SetDirtyAll(); + EditorUtility.SetDirty(controller); + } + + private static IReadOnlyDictionary CreateParameters(AacCrabGraph graph, IReadOnlyDictionary layers, AnimatorController controller) + { + var floatingParameters = new Dictionary(); + if (graph.Parameters.Count == 0) + { + return floatingParameters; + } + + if (layers.Count == 0) + { + throw new InvalidOperationException("The controller declares parameters but no layer to hold them."); + } + + // Parameters are controller-wide, so any layer can create them. + var layer = layers.First().Value; + foreach (var parameter in graph.Parameters) + { + switch (parameter.Type) + { + case AacCrabParameterType.Float: + floatingParameters[parameter.Name] = layer.FloatParameter(parameter.Name); + break; + case AacCrabParameterType.Int: + layer.IntParameter(parameter.Name); + break; + case AacCrabParameterType.Bool: + layer.BoolParameter(parameter.Name); + break; + default: + throw new InvalidOperationException($"Unknown parameter type {parameter.Type}."); + } + } + + ApplyParameterDefaults(controller, graph.Parameters); + return floatingParameters; + } + + // Animator As Code creates parameters with Unity's defaults; the graph's defaults are applied + // afterwards by mutating the controller's own parameter list. + private static void ApplyParameterDefaults(AnimatorController controller, IEnumerable parameters) + { + var wanted = parameters.ToDictionary(parameter => parameter.Name); + var current = controller.parameters; + foreach (var parameter in current) + { + if (!wanted.TryGetValue(parameter.name, out var declared)) + { + continue; + } + + switch (declared.Type) + { + case AacCrabParameterType.Float: + parameter.defaultFloat = declared.DefaultFloat; + break; + case AacCrabParameterType.Int: + parameter.defaultInt = declared.DefaultInt; + break; + case AacCrabParameterType.Bool: + parameter.defaultBool = declared.DefaultBool; + break; + } + } + + controller.parameters = current; + } + + private static AacFlClip CreateClip(AacFlBase aac, AacCrabClip graph) + { + var clip = aac.NewClip(graph.Name); + if (graph.Looping) + { + clip.Looping(); + } + else + { + clip.NonLooping(); + } + + return clip.Animating(edit => + { + foreach (var curve in graph.Curves) + { + var keys = curve.Keys + .Select(key => new Keyframe(key.Time, key.Value, key.InTangent, key.OutTangent)) + .ToArray(); + edit.Animates(curve.Path, UnityType(curve.Target), curve.Property) + .WithAnimationCurve(new AnimationCurve(keys)); + } + }); + } + + private static Type UnityType(AacCrabTargetType target) + { + switch (target) + { + case AacCrabTargetType.GameObject: + return typeof(GameObject); + case AacCrabTargetType.SkinnedMeshRenderer: + return typeof(SkinnedMeshRenderer); + default: + throw new InvalidOperationException($"Unknown curve target {target}."); + } + } + + private static AacFlBlendTree CreateBlendTree( + AacFlBase aac, + AacCrabBlendTree graph, + IReadOnlyDictionary clips, + IReadOnlyDictionary trees, + IReadOnlyDictionary floatingParameters) + { + Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees); + + AacFlFloatParameter FloatParameter(string name) + { + if (name == null || !floatingParameters.TryGetValue(name, out var parameter)) + { + throw new InvalidOperationException($"Blend tree '{graph.Name}' uses '{name}', which is not a Float parameter."); + } + + return parameter; + } + + var uninitialized = aac.NewBlendTree(graph.Name); + switch (graph.BlendType) + { + case AacCrabBlendType.Simple1D: + { + var tree = uninitialized.Simple1D(FloatParameter(graph.ParamX)); + tree.BlendTree.useAutomaticThresholds = graph.UseAutomaticThresholds; + foreach (var child in graph.Children) + { + tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f); + } + + return tree; + } + case AacCrabBlendType.SimpleDirectional2D: + case AacCrabBlendType.FreeformDirectional2D: + case AacCrabBlendType.FreeformCartesian2D: + { + var x = FloatParameter(graph.ParamX); + var y = FloatParameter(graph.ParamY); + var tree = graph.BlendType == AacCrabBlendType.SimpleDirectional2D + ? uninitialized.SimpleDirectional2D(x, y) + : graph.BlendType == AacCrabBlendType.FreeformDirectional2D + ? uninitialized.FreeformDirectional2D(x, y) + : uninitialized.FreeformCartesian2D(x, y); + foreach (var child in graph.Children) + { + tree.WithAnimation(Resolve(child.Motion), child.Threshold ?? 0f, child.ThresholdY ?? 0f); + } + + return tree; + } + case AacCrabBlendType.Direct: + { + var tree = uninitialized.Direct(); + foreach (var child in graph.Children) + { + tree.WithAnimation(Resolve(child.Motion), FloatParameter(child.DirectParam)); + } + + return tree; + } + default: + throw new InvalidOperationException($"Unknown blend type {graph.BlendType}."); + } + } + + private static Motion MotionOf( + AacCrabMotionRef reference, + IReadOnlyDictionary clips, + IReadOnlyDictionary trees) + { + switch (reference.Type) + { + case AacCrabMotionType.Clip: + if (!clips.TryGetValue(reference.Name, out var clip)) + { + throw new InvalidOperationException($"Clip '{reference.Name}' is not declared."); + } + + return clip.Clip; + case AacCrabMotionType.BlendTree: + // Blend trees are built in declaration order, so a tree can only refer to an earlier one. + if (!trees.TryGetValue(reference.Name, out var tree)) + { + throw new InvalidOperationException($"Blend tree '{reference.Name}' is not declared yet; declare it before the motion that uses it."); + } + + return tree.BlendTree; + default: + throw new InvalidOperationException($"Unknown motion type {reference.Type}."); + } + } + + private sealed class MachineScope + { + public AacFlStateMachine Machine; + public AacCrabStateMachine Graph; + } + + private static void BuildLayer( + AacFlLayer layer, + AacCrabLayer graph, + IReadOnlyDictionary clips, + IReadOnlyDictionary trees) + { + if (graph.StateMachine == null) + { + throw new InvalidOperationException($"Layer '{graph.Name}' has no state machine."); + } + + Motion Resolve(AacCrabMotionRef reference) => MotionOf(reference, clips, trees); + + // State names are unique within a layer, so a single dictionary resolves every transition, + // including the ones that cross state machines. + var states = new Dictionary(); + var scopes = new List(); + + AacFlStateMachine CreateMachine(AacFlStateMachine machine, AacCrabStateMachine machineGraph) + { + scopes.Add(new MachineScope { Machine = machine, Graph = machineGraph }); + foreach (var state in machineGraph.States) + { + var aacState = machine.NewState(state.Name, state.Position.X, state.Position.Y); + if (state.Motion != null) + { + aacState.WithAnimation(Resolve(state.Motion)); + } + + states[state.Name] = aacState; + } + + foreach (var subMachine in machineGraph.SubMachines) + { + CreateMachine(machine.NewSubStateMachine(subMachine.Name, subMachine.Position.X, subMachine.Position.Y), subMachine); + } + + return machine; + } + + // Create every state first: transitions may point forward. + CreateMachine(layer.StateMachine, graph.StateMachine); + + foreach (var scope in scopes) + { + foreach (var state in scope.Graph.States) + { + foreach (var transition in state.Transitions) + { + ApplyTransition(states[state.Name].TransitionsTo(Destination(states, transition)), transition); + } + } + + foreach (var transition in scope.Graph.AnyStateTransitions) + { + ApplyTransition(scope.Machine.AnyTransitionsTo(Destination(states, transition)), transition); + } + } + } + + private static AacFlState Destination(IReadOnlyDictionary states, AacCrabTransition transition) + { + if (!states.TryGetValue(transition.To, out var destination)) + { + throw new InvalidOperationException($"Transition target '{transition.To}' is not a state in this layer."); + } + + return destination; + } + + private static void ApplyTransition(AacFlTransition transition, AacCrabTransition graph) + { + transition.WithTransitionDurationSeconds(graph.Duration); + if (graph.OrderedInterruption) + { + transition.WithOrderedInterruption(); + } + else + { + transition.WithNoOrderedInterruption(); + } + + if (graph.SourceInterruption) + { + transition.WithSourceInterruption(); + } + + if (graph.CanTransitionToSelf) + { + transition.WithTransitionToSelf(); + } + + if (graph.HasExitTime) + { + transition.AfterAnimationIsAtLeastAtNormalized(graph.ExitTime); + } + + // Conditions are applied last: Animator As Code forbids configuring a transition afterwards. + if (graph.Conditions.Count == 0) + { + return; + } + + var continuation = transition.When(Condition(graph.Conditions[0])); + for (var index = 1; index < graph.Conditions.Count; index++) + { + continuation = continuation.And(Condition(graph.Conditions[index])); + } + } + + // Animator As Code only exposes typed comparisons for some parameter types, so conditions are + // built the same way the library builds them internally. + private static IAacFlCondition Condition(AacCrabCondition condition) + { + var parameter = condition.Parameter; + var mode = UnityConditionMode(condition.Mode); + var threshold = condition.Threshold; + return AacFlConditionSimple.Just(appender => appender.Add(parameter, mode, threshold)); + } + + private static AnimatorConditionMode UnityConditionMode(AacCrabCondMode mode) + { + switch (mode) + { + case AacCrabCondMode.Greater: + return AnimatorConditionMode.Greater; + case AacCrabCondMode.Less: + return AnimatorConditionMode.Less; + case AacCrabCondMode.Equals: + return AnimatorConditionMode.Equals; + case AacCrabCondMode.NotEqual: + return AnimatorConditionMode.NotEqual; + case AacCrabCondMode.If: + return AnimatorConditionMode.If; + case AacCrabCondMode.IfNot: + return AnimatorConditionMode.IfNot; + default: + throw new InvalidOperationException($"Unknown condition mode {mode}."); + } + } + } +} diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs new file mode 100644 index 0000000..a3ce546 --- /dev/null +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabGraph.cs @@ -0,0 +1,192 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace AnimatorAsCrab.V1 +{ + /// + /// The wire format produced by the Rust core. Keep in sync with rust/src/graph.rs. + /// Property names are snake_cased by the serializer settings, so C# names must only differ + /// from the wire format by casing; enum values are explicit. + /// + public static class AacCrabJson + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + // A graph the generator does not understand is a bug, not something to ignore. + MissingMemberHandling = MissingMemberHandling.Error, + ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() }, + Converters = { new StringEnumConverter() }, + }; + + public static AacCrabGraph Parse(string json) + { + return JsonConvert.DeserializeObject(json, Settings); + } + } + + public sealed class AacCrabGraph + { + public string SystemName { get; set; } + public string AssetKey { get; set; } + public List Parameters { get; set; } + public List Clips { get; set; } + public List BlendTrees { get; set; } + public AacCrabController Controller { get; set; } + } + + public sealed class AacCrabParameter + { + public AacCrabParameterType Type { get; set; } + public string Name { get; set; } + public JToken Default { get; set; } + + public float DefaultFloat => Default.Value(); + public int DefaultInt => Default.Value(); + public bool DefaultBool => Default.Value(); + } + + public enum AacCrabParameterType + { + [EnumMember(Value = "float")] Float, + [EnumMember(Value = "int")] Int, + [EnumMember(Value = "bool")] Bool, + } + + public sealed class AacCrabClip + { + public string Name { get; set; } + public bool Looping { get; set; } + public List Curves { get; set; } + } + + public sealed class AacCrabCurve + { + public string Path { get; set; } + public AacCrabTargetType Target { get; set; } + public string Property { get; set; } + public List Keys { get; set; } + } + + public enum AacCrabTargetType + { + [EnumMember(Value = "game_object")] GameObject, + [EnumMember(Value = "skinned_mesh_renderer")] SkinnedMeshRenderer, + } + + public sealed class AacCrabKeyframe + { + public float Time { get; set; } + public float Value { get; set; } + public float InTangent { get; set; } + public float OutTangent { get; set; } + } + + public sealed class AacCrabBlendTree + { + public string Name { get; set; } + public AacCrabBlendType BlendType { get; set; } + public string ParamX { get; set; } + public string ParamY { get; set; } + public List Children { get; set; } + public bool UseAutomaticThresholds { get; set; } + } + + public enum AacCrabBlendType + { + [EnumMember(Value = "simple_1d")] Simple1D, + [EnumMember(Value = "simple_directional_2d")] SimpleDirectional2D, + [EnumMember(Value = "freeform_directional_2d")] FreeformDirectional2D, + [EnumMember(Value = "freeform_cartesian_2d")] FreeformCartesian2D, + [EnumMember(Value = "direct")] Direct, + } + + public sealed class AacCrabBlendChild + { + public AacCrabMotionRef Motion { get; set; } + public float? Threshold { get; set; } + public float? ThresholdY { get; set; } + public string DirectParam { get; set; } + } + + /// A motion is always a reference: clips and blend trees are declared at the top level. + public sealed class AacCrabMotionRef + { + public AacCrabMotionType Type { get; set; } + public string Name { get; set; } + } + + public enum AacCrabMotionType + { + [EnumMember(Value = "clip")] Clip, + [EnumMember(Value = "blend_tree")] BlendTree, + } + + public sealed class AacCrabController + { + public List Layers { get; set; } + } + + public sealed class AacCrabLayer + { + public string Name { get; set; } + public AacCrabStateMachine StateMachine { get; set; } + } + + /// Name is null for the root state machine of a layer. + public sealed class AacCrabStateMachine + { + public string Name { get; set; } + public AacCrabGridPos Position { get; set; } + public List States { get; set; } + public List SubMachines { get; set; } + public List AnyStateTransitions { get; set; } + } + + public sealed class AacCrabState + { + public string Name { get; set; } + public AacCrabGridPos Position { get; set; } + public AacCrabMotionRef Motion { get; set; } + public List Transitions { get; set; } + } + + public sealed class AacCrabTransition + { + /// The name of the destination state, within the same layer. + public string To { get; set; } + public List Conditions { get; set; } + public bool HasExitTime { get; set; } + public float ExitTime { get; set; } + public float Duration { get; set; } + public bool OrderedInterruption { get; set; } + public bool SourceInterruption { get; set; } + public bool CanTransitionToSelf { get; set; } + } + + public sealed class AacCrabCondition + { + public string Parameter { get; set; } + public AacCrabCondMode Mode { get; set; } + public float Threshold { get; set; } + } + + public enum AacCrabCondMode + { + [EnumMember(Value = "greater")] Greater, + [EnumMember(Value = "less")] Less, + [EnumMember(Value = "equals")] Equals, + [EnumMember(Value = "not_equal")] NotEqual, + [EnumMember(Value = "if")] If, + [EnumMember(Value = "if_not")] IfNot, + } + + public struct AacCrabGridPos + { + public int X { get; set; } + public int Y { get; set; } + } +} diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs new file mode 100644 index 0000000..0ea7473 --- /dev/null +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabNative.cs @@ -0,0 +1,115 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace AnimatorAsCrab.V1 +{ + /// + /// P/Invoke bindings for `libaac` (see rust/src/lib.rs). The native library is expected to live + /// in the project's Assets/Plugins folder; place libaac.so, libaac.dll or libaac.dylib there. + /// + public static class AacCrabNative + { + private const string Library = "libaac"; + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr aac_create(); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int aac_eval_rhai(IntPtr context, byte[] script); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr aac_to_json(IntPtr context); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr aac_last_error(IntPtr context); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void aac_free_string(IntPtr value); + + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void aac_destroy(IntPtr context); + + /// + /// Evaluate a Rhai script and return the graph as JSON, or null with a message in + /// . The context lives only for the duration of this call. + /// + public static string Evaluate(string script, out string error) + { + IntPtr context; + try + { + context = aac_create(); + } + catch (DllNotFoundException exception) + { + error = $"Could not load {Library}: {exception.Message}. Place the native library in Assets/Plugins."; + return null; + } + + if (context == IntPtr.Zero) + { + error = "aac_create returned null."; + return null; + } + + try + { + var status = aac_eval_rhai(context, Utf8Z(script)); + if (status != 0) + { + error = ReadUtf8(aac_last_error(context)) ?? $"The script failed with status {status}."; + return null; + } + + var json = aac_to_json(context); + if (json == IntPtr.Zero) + { + error = ReadUtf8(aac_last_error(context)) ?? "The graph could not be serialized."; + return null; + } + + try + { + error = null; + return ReadUtf8(json); + } + finally + { + aac_free_string(json); + } + } + finally + { + aac_destroy(context); + } + } + + private static byte[] Utf8Z(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var terminated = new byte[bytes.Length + 1]; + Array.Copy(bytes, terminated, bytes.Length); + return terminated; + } + + private static string ReadUtf8(IntPtr pointer) + { + if (pointer == IntPtr.Zero) + { + return null; + } + + // Marshal.PtrToStringUTF8 is not available on all Unity runtimes, so the bytes are copied by hand. + var length = 0; + while (Marshal.ReadByte(pointer, length) != 0) + { + length++; + } + + var bytes = new byte[length]; + Marshal.Copy(pointer, bytes, 0, length); + return Encoding.UTF8.GetString(bytes); + } + } +} diff --git a/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs new file mode 100644 index 0000000..1a61226 --- /dev/null +++ b/csharp/dev.doloro.animator-as-crab/V1/Editor/Crab/AacCrabWindow.cs @@ -0,0 +1,201 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEditor.Animations; +using UnityEngine; + +namespace AnimatorAsCrab.V1 +{ + /// + /// Evaluates a Rhai script with libaac and generates the Animator Controller through Animator As Code. + /// The Unity-side configuration is never guessed: every field below is supplied by the user, and the + /// system name and asset key are declared by the script itself. + /// + public class AacCrabWindow : EditorWindow + { + [SerializeField] private string _scriptPath = ""; + [SerializeField] private bool _generateOnScriptChange = true; + [SerializeField] private AnimatorController _controller; + [SerializeField] private Transform _animatorRoot; + [SerializeField] private UnityEngine.Object _assetContainer; + [SerializeField] private AacConfiguration.Container _containerMode = AacConfiguration.Container.Everything; + [SerializeField] private bool _writeDefaults; + [SerializeField] private long _lastWriteUtcTicks; + + private string _error; + private string _status; + private bool _scriptChanged; + + [MenuItem("Tools/Animator As Crab")] + public static void Open() + { + var window = GetWindow(); + window.titleContent = new GUIContent("Animator As Crab"); + window.Show(); + } + + private void OnEnable() + { + EditorApplication.update += OnUpdate; + _scriptChanged = _lastWriteUtcTicks != 0 && _lastWriteUtcTicks != LastWriteUtcTicks(); + _lastWriteUtcTicks = LastWriteUtcTicks(); + } + + private void OnDisable() + { + EditorApplication.update -= OnUpdate; + } + + private void OnUpdate() + { + if (string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath)) + { + return; + } + + var ticks = LastWriteUtcTicks(); + if (ticks == _lastWriteUtcTicks) + { + return; + } + + _lastWriteUtcTicks = ticks; + _scriptChanged = true; + if (_generateOnScriptChange) + { + Generate(); + } + else + { + Repaint(); + } + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Rhai script", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + _scriptPath = EditorGUILayout.TextField(_scriptPath); + if (GUILayout.Button("...", GUILayout.Width(28))) + { + var directory = string.IsNullOrEmpty(_scriptPath) ? null : Path.GetDirectoryName(_scriptPath); + var picked = EditorUtility.OpenFilePanel("Rhai script", directory ?? string.Empty, "rhai"); + if (!string.IsNullOrEmpty(picked)) + { + _scriptPath = picked; + _lastWriteUtcTicks = LastWriteUtcTicks(); + _scriptChanged = false; + Generate(); + } + } + + EditorGUILayout.EndHorizontal(); + _generateOnScriptChange = EditorGUILayout.Toggle("Generate when the script changes", _generateOnScriptChange); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Unity side", EditorStyles.boldLabel); + _controller = (AnimatorController)EditorGUILayout.ObjectField("Animator Controller", _controller, typeof(AnimatorController), false); + _animatorRoot = (Transform)EditorGUILayout.ObjectField("Animator Root", _animatorRoot, typeof(Transform), true); + _assetContainer = EditorGUILayout.ObjectField("Asset Container", _assetContainer, typeof(UnityEngine.Object), false); + _containerMode = (AacConfiguration.Container)EditorGUILayout.EnumPopup("Container Mode", _containerMode); + _writeDefaults = EditorGUILayout.Toggle("Write Defaults", _writeDefaults); + + EditorGUILayout.Space(); + if (GUILayout.Button("Generate", GUILayout.Height(24))) + { + Generate(); + } + + EditorGUILayout.Space(); + if (_error != null) + { + EditorGUILayout.HelpBox(_error, MessageType.Error); + } + else if (_scriptChanged) + { + EditorGUILayout.HelpBox("The script changed since the last generation.", MessageType.Warning); + } + + if (_status != null) + { + EditorGUILayout.HelpBox(_status, MessageType.Info); + } + } + + private void Generate() + { + _error = null; + _status = null; + _scriptChanged = false; + try + { + _status = GenerateOrThrow(); + } + catch (Exception exception) + { + _error = exception.Message; + Debug.LogException(exception); + } + + Repaint(); + } + + private string GenerateOrThrow() + { + if (string.IsNullOrEmpty(_scriptPath)) + { + throw new InvalidOperationException("Select a Rhai script."); + } + + if (!File.Exists(_scriptPath)) + { + throw new InvalidOperationException($"'{_scriptPath}' does not exist."); + } + + if (_controller == null) + { + throw new InvalidOperationException("Select the Animator Controller to generate into."); + } + + if (_animatorRoot == null) + { + throw new InvalidOperationException("Select the animator root Transform."); + } + + if (_assetContainer == null) + { + throw new InvalidOperationException("Select the asset container that will hold the generated clips and blend trees."); + } + + var json = AacCrabNative.Evaluate(File.ReadAllText(_scriptPath), out var nativeError); + if (json == null) + { + throw new InvalidOperationException(nativeError); + } + + var graph = AacCrabJson.Parse(json); + var configuration = new AacConfiguration + { + SystemName = graph.SystemName, + AssetKey = graph.AssetKey, + AnimatorRoot = _animatorRoot, + AssetContainer = _assetContainer, + ContainerMode = _containerMode, + DefaultsProvider = new AacDefaultsProvider(_writeDefaults), + }; + + AacCrabGenerator.Generate(graph, configuration, _controller); + AssetDatabase.SaveAssets(); + + return $"Generated '{graph.SystemName}' with asset key '{graph.AssetKey}': " + + $"{graph.Controller.Layers.Count} layer(s), {graph.Clips.Count} clip(s), {graph.BlendTrees.Count} blend tree(s)."; + } + + private long LastWriteUtcTicks() + { + return string.IsNullOrEmpty(_scriptPath) || !File.Exists(_scriptPath) + ? 0 + : File.GetLastWriteTimeUtc(_scriptPath).Ticks; + } + } +} diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE b/csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE rename to csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE diff --git a/Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE.meta b/csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/V1/Editor/LICENSE.meta rename to csharp/dev.doloro.animator-as-crab/V1/Editor/LICENSE.meta diff --git a/csharp/dev.doloro.animator-as-crab/package.json b/csharp/dev.doloro.animator-as-crab/package.json new file mode 100644 index 0000000..5deb217 --- /dev/null +++ b/csharp/dev.doloro.animator-as-crab/package.json @@ -0,0 +1,15 @@ +{ + "name": "dev.doloro.animator-as-crab", + "displayName": "Animator As Crab", + "version": "0.1.0", + "unity": "2019.4", + "description": "Writes Animator Controllers from a Rhai script, evaluated by the libaac native library.", + "vrchatVersion" : "2022.1.1", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.1" + }, + "author" : { + "name" : "doloro" + }, + "license": "MIT" +} diff --git a/Packages/dev.hai-vr.animator-as-code.v1/package.json.meta b/csharp/dev.doloro.animator-as-crab/package.json.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1/package.json.meta rename to csharp/dev.doloro.animator-as-crab/package.json.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AacEditModeTests.asmdef.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1/EditMode/AbstractSimpleSingleLayerAnimatorInternalAC.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACAnimatorWideTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACBoolTransitionTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACStateTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACSubStateMachineTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AIACTransitionFunctionsTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AacPlayModeTests.asmdef.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/AbstractSimpleSingleLayerAIAC.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsDriverGenerationTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/Tests/V1VRC/PlayMode/GenerationTests/AIACVRCExtensionsMiscGenerationTest.cs.meta diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json b/csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json diff --git a/Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta b/csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta similarity index 100% rename from Packages/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta rename to csharp/dev.hai-vr.animator-as-code.v1.base.test/package.json.meta diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..452a155 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1788465171, + "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=", + "owner": "ipetkov", + "repo": "crane", + "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1789546076, + "narHash": "sha256-zVxLZiSnmaaPLwnhj7pwmqe3axBg/C6nG5JZsJMh2g4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1789715798, + "narHash": "sha256-hWw9vlrsFm9kOw8MT5k6IX/7xCzgROuJ6ZvQOrkq66Q=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "fbdb2de9e7619d660ae7e8f752f38b261020b701", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..806fecc --- /dev/null +++ b/flake.nix @@ -0,0 +1,77 @@ +{ + description = "animator-as-crab: Animator Controllers described in Rhai, generated from Rust into Unity"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + crane.url = "github:ipetkov/crane"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + crane, + rust-overlay, + }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + + perSystem = + system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + "clippy" + "rustfmt" + ]; + }; + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; + + # The Rust core: evaluates the Rhai DSL and emits the graph as JSON for Unity. + aac = craneLib.buildPackage { + src = ./rust; + strictDeps = true; + doCheck = true; + }; + in + { + packages = { + inherit aac; + default = aac; + }; + + devShells.default = craneLib.devShell { + inputsFrom = [ aac ]; + packages = [ + rustToolchain + pkgs.dotnet-sdk + pkgs.mono + pkgs.jq + ]; + }; + + formatter = pkgs.nixfmt-rfc-style; + }; + in + { + packages = forAllSystems (system: (perSystem system).packages); + devShells = forAllSystems (system: (perSystem system).devShells); + formatter = forAllSystems (system: (perSystem system).formatter); + }; +} diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..c6c7486 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,463 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aac" +version = "0.1.0" +dependencies = [ + "rhai", + "serde", + "serde_json", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rhai" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5" +dependencies = [ + "ahash", + "bitflags", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thin-vec" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..aff6fa9 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "aac" +version = "0.1.0" +edition = "2021" + +[lib] +name = "aac" +crate-type = ["cdylib", "rlib"] + +[dependencies] +rhai = "1.26" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/rust/examples/avatar.rhai b/rust/examples/avatar.rhai new file mode 100644 index 0000000..8670f27 --- /dev/null +++ b/rust/examples/avatar.rhai @@ -0,0 +1,58 @@ +// An Animator Controller described as Rhai. +// +// cargo run --bin aac-dump -- rust/examples/avatar.rhai +// +// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded, so +// `when(a > 0.1 && b)` is not valid. + +let aac = AnimatorAsCode(); +aac.system_name("MyAvatar"); +aac.asset_key("AAC_"); + +let speed = aac.float_param("Speed", 0.0); +let is_sitting = aac.bool_param("IsSitting", false); +let gesture = aac.int_param("Gesture", 0); + +let ctrl = aac.new_controller(); +let base = ctrl.layer("Base"); + +let idle = base.state("Idle", 0, 0); +let walk = base.state("Walk", 1, 0); +let run = base.state("Run", 2, 0); +let sit = base.state("Sit", 0, 1); + +let idle_clip = aac.clip("idle_anim"); +idle_clip.looping(true); +idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0); +idle_clip.blend_shape("Body", "Smile", 0.0, 0.0); +idle_clip.blend_shape("Body", "Smile", 1.0, 0.8); + +let walk_clip = aac.clip("walk_anim"); +walk_clip.looping(true); +walk_clip.toggle("Body/Props", true); + +let run_clip = aac.clip("run_anim"); +run_clip.looping(true); +run_clip.toggle("Body/Props", false); + +idle.set_clip(idle_clip); +walk.set_clip(walk_clip); + +let locomotion = aac.blend_tree("locomotion"); +locomotion.simple_1d(speed); +locomotion.add_motion(walk_clip, 0.0); +locomotion.add_motion(run_clip, 5.0); +run.set_motion(locomotion); + +idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25); +walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.25); +run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.25); +walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25); + +base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2); + +let gestures = base.sub_machine("Gestures", 2, 0); +let peace = gestures.state("Peace", 0, 0); +let point = gestures.state("Point", 1, 0); +peace.transition_to(point).when(gesture == 1).duration(0.1); +point.transition_to(peace).when(gesture == 0).duration(0.1); diff --git a/rust/src/bin/aac-dump.rs b/rust/src/bin/aac-dump.rs new file mode 100644 index 0000000..14b2ebe --- /dev/null +++ b/rust/src/bin/aac-dump.rs @@ -0,0 +1,26 @@ +//! Evaluate a `.rhai` script and print the generated JSON. +//! +//! ```text +//! cargo run --bin aac-dump -- rust/examples/avatar.rhai +//! ``` + +fn main() { + let Some(path) = std::env::args().nth(1) else { + eprintln!("usage: aac-dump "); + std::process::exit(2); + }; + let script = match std::fs::read_to_string(&path) { + Ok(script) => script, + Err(error) => { + eprintln!("cannot read {path}: {error}"); + std::process::exit(2); + } + }; + match aac::evaluate_to_json(&script) { + Ok(json) => println!("{json}"), + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } +} diff --git a/rust/src/builder.rs b/rust/src/builder.rs new file mode 100644 index 0000000..b5b40be --- /dev/null +++ b/rust/src/builder.rs @@ -0,0 +1,234 @@ +use crate::graph::*; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// Shared, thread-safe handle to the graph being built. Every builder carries a clone. +#[derive(Clone)] +pub struct Aac { + pub(crate) graph: Arc>, +} + +impl Default for Aac { + fn default() -> Self { + Self::new() + } +} + +impl Aac { + pub fn new() -> Self { + Self { + graph: Arc::new(Mutex::new(ControllerGraph::default())), + } + } + + /// A poisoned lock still holds a usable graph; recovering avoids panicking across the FFI boundary. + fn lock(&self) -> MutexGuard<'_, ControllerGraph> { + self.graph.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn write(&self, f: impl FnOnce(&mut ControllerGraph) -> R) -> R { + f(&mut self.lock()) + } +} + +/// Parameter handles only carry the name; conditions read nothing else from them. +#[derive(Clone)] +pub struct FloatParam { + pub(crate) name: String, +} + +#[derive(Clone)] +pub struct IntParam { + pub(crate) name: String, +} + +#[derive(Clone)] +pub struct BoolParam { + pub(crate) name: String, +} + +#[derive(Clone)] +pub struct ControllerBuilder { + pub(crate) aac: Aac, +} + +#[derive(Clone)] +pub struct LayerBuilder { + pub(crate) aac: Aac, + pub(crate) layer: usize, +} + +/// A state machine. `machine` is the chain of sub-machine indices from the layer root, so the empty +/// chain is the layer's root machine and indices stay stable because machines are only ever appended. +#[derive(Clone)] +pub struct MachineRef { + pub(crate) aac: Aac, + pub(crate) layer: usize, + pub(crate) machine: Vec, +} + +#[derive(Clone)] +pub struct StateRef { + pub(crate) aac: Aac, + pub(crate) layer: usize, + pub(crate) machine: Vec, + pub(crate) state: usize, +} + +#[derive(Clone)] +pub struct AnyStateRef { + pub(crate) aac: Aac, + pub(crate) layer: usize, + pub(crate) machine: Vec, +} + +#[derive(Clone)] +pub struct ClipBuilder { + pub(crate) aac: Aac, + pub(crate) name: String, +} + +#[derive(Clone)] +pub struct BlendTreeBuilder { + pub(crate) aac: Aac, + pub(crate) name: String, +} + +/// `source` is the originating state, or `None` for an any-state transition. +#[derive(Clone)] +pub struct TransitionRef { + pub(crate) aac: Aac, + pub(crate) layer: usize, + pub(crate) machine: Vec, + pub(crate) source: Option, + pub(crate) index: usize, +} + +pub(crate) fn machine_mut<'a>( + graph: &'a mut ControllerGraph, + layer: usize, + path: &[usize], +) -> &'a mut StateMachine { + let mut machine = &mut graph.controller.layers[layer].state_machine; + for &index in path { + machine = &mut machine.sub_machines[index]; + } + machine +} + +pub(crate) fn clip_mut<'a>(graph: &'a mut ControllerGraph, name: &str) -> Option<&'a mut ClipData> { + graph.clips.iter_mut().find(|clip| clip.name == name) +} + +pub(crate) fn blend_tree_mut<'a>( + graph: &'a mut ControllerGraph, + name: &str, +) -> Option<&'a mut BlendTreeData> { + graph.blend_trees.iter_mut().find(|tree| tree.name == name) +} + +impl StateRef { + pub fn state_name(&self) -> String { + self.aac + .write(|graph| machine_mut(graph, self.layer, &self.machine).states[self.state].name.clone()) + } + + pub fn set_motion(&self, motion: MotionRef) { + self.aac.write(|graph| { + machine_mut(graph, self.layer, &self.machine).states[self.state].motion = Some(motion); + }); + } + + pub fn transition_to(&self, target: &StateRef) -> TransitionRef { + let to = target.state_name(); + let index = self.aac.write(|graph| { + let state = &mut machine_mut(graph, self.layer, &self.machine).states[self.state]; + state.transitions.push(Transition::new(to)); + state.transitions.len() - 1 + }); + TransitionRef { + aac: self.aac.clone(), + layer: self.layer, + machine: self.machine.clone(), + source: Some(self.state), + index, + } + } +} + +impl AnyStateRef { + pub fn transition_to(&self, target: &StateRef) -> TransitionRef { + let to = target.state_name(); + let index = self.aac.write(|graph| { + let machine = machine_mut(graph, self.layer, &self.machine); + machine.any_state_transitions.push(Transition::new(to)); + machine.any_state_transitions.len() - 1 + }); + TransitionRef { + aac: self.aac.clone(), + layer: self.layer, + machine: self.machine.clone(), + source: None, + index, + } + } +} + +impl TransitionRef { + /// Apply a mutation to the transition this handle points at. + pub fn update(&self, f: impl FnOnce(&mut Transition)) { + self.aac.write(|graph| { + let machine = machine_mut(graph, self.layer, &self.machine); + let transitions = match self.source { + Some(state) => &mut machine.states[state].transitions, + None => &mut machine.any_state_transitions, + }; + f(&mut transitions[self.index]); + }); + } + + pub fn add_conditions(&self, conditions: impl IntoIterator) { + self.update(|transition| transition.conditions.extend(conditions)); + } +} + +impl ClipBuilder { + pub fn data_mut(&self, f: impl FnOnce(&mut ClipData) -> R) -> Option { + let name = self.name.clone(); + self.aac.write(|graph| clip_mut(graph, &name).map(f)) + } + + /// Append one keyframe to the curve for (path, property), creating the curve if needed. + pub fn push_key(&self, path: &str, property: &str, key: Keyframe) -> Result<(), String> { + let target = infer_target(property).ok_or_else(|| { + format!( + "cannot tell which component `{property}` belongs to; \ + known properties are `m_IsActive` and `blendShape.*`" + ) + })?; + self.data_mut(|clip| { + clip.curve_mut(path, target, property).keys.push(key); + }) + .ok_or_else(|| format!("unknown clip `{}`", self.name)) + } +} + +impl BlendTreeBuilder { + pub fn data_mut(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option { + let name = self.name.clone(); + self.aac.write(|graph| blend_tree_mut(graph, &name).map(f)) + } + + pub fn configure(&self, blend_type: BlendType, param_x: &str, param_y: Option<&str>) -> Result<(), String> { + self.data_mut(|tree| { + tree.blend_type = blend_type; + tree.param_x = param_x.to_string(); + tree.param_y = param_y.map(str::to_string); + }) + .ok_or_else(|| format!("unknown blend tree `{}`", self.name)) + } + + pub fn push_child(&self, child: BlendChild) -> Result<(), String> { + self.data_mut(|tree| tree.children.push(child)) + .ok_or_else(|| format!("unknown blend tree `{}`", self.name)) + } +} diff --git a/rust/src/export.rs b/rust/src/export.rs new file mode 100644 index 0000000..7d2e96c --- /dev/null +++ b/rust/src/export.rs @@ -0,0 +1,325 @@ +use crate::graph::*; +use std::collections::{HashMap, HashSet}; + +/// Sort keyframes into time order, then validate, then serialize. +pub fn to_json(graph: &ControllerGraph) -> Result { + let mut graph = graph.clone(); + normalize(&mut graph); + validate(&graph)?; + serde_json::to_string_pretty(&graph).map_err(|e| format!("serialization failed: {e}")) +} + +fn normalize(graph: &mut ControllerGraph) { + for clip in &mut graph.clips { + for curve in &mut clip.curves { + curve + .keys + .sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal)); + } + } +} + +fn validate(graph: &ControllerGraph) -> Result<(), String> { + let mut errors = Vec::new(); + + if graph.system_name.trim().is_empty() { + errors.push("system_name is not set (call `aac.system_name(\"...\")`)".to_string()); + } + if graph.asset_key.trim().is_empty() { + errors.push("asset_key is not set (call `aac.asset_key(\"...\")`)".to_string()); + } + + let parameters = parameter_types(graph, &mut errors); + validate_clips(graph, &mut errors); + validate_blend_trees(graph, ¶meters, &mut errors); + validate_layers(graph, ¶meters, &mut errors); + + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "graph is invalid ({} problem{}):\n- {}", + errors.len(), + if errors.len() == 1 { "" } else { "s" }, + errors.join("\n- ") + )) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum ParamType { + Float, + Int, + Bool, +} + +fn parameter_types(graph: &ControllerGraph, errors: &mut Vec) -> HashMap { + let mut seen = HashSet::new(); + let mut map = HashMap::new(); + for parameter in &graph.parameters { + let name = parameter.name(); + if name.trim().is_empty() { + errors.push("a parameter has an empty name".to_string()); + continue; + } + if !seen.insert(name.to_string()) { + errors.push(format!("parameter `{name}` is declared more than once")); + } + let ty = match parameter { + Parameter::Float { .. } => ParamType::Float, + Parameter::Int { .. } => ParamType::Int, + Parameter::Bool { .. } => ParamType::Bool, + }; + map.insert(name.to_string(), ty); + } + map +} + +fn validate_clips(graph: &ControllerGraph, errors: &mut Vec) { + let mut seen = HashSet::new(); + for clip in &graph.clips { + if clip.name.trim().is_empty() { + errors.push("a clip has an empty name".to_string()); + } else if !seen.insert(clip.name.clone()) { + errors.push(format!("clip `{}` is declared more than once", clip.name)); + } + for curve in &clip.curves { + if curve.path.trim().is_empty() { + errors.push(format!("clip `{}` has a curve with an empty path", clip.name)); + } + if curve.keys.is_empty() { + errors.push(format!( + "clip `{}` curve `{}` on `{}` has no keyframes", + clip.name, curve.property, curve.path + )); + } + for key in &curve.keys { + if !key.time.is_finite() || !key.value.is_finite() { + errors.push(format!( + "clip `{}` curve `{}` on `{}` has a non-finite keyframe", + clip.name, curve.property, curve.path + )); + } + } + } + } +} + +fn validate_blend_trees( + graph: &ControllerGraph, + parameters: &HashMap, + errors: &mut Vec, +) { + let mut seen = HashSet::new(); + for tree in &graph.blend_trees { + if tree.name.trim().is_empty() { + errors.push("a blend tree has an empty name".to_string()); + } else if !seen.insert(tree.name.clone()) { + errors.push(format!("blend tree `{}` is declared more than once", tree.name)); + } + let has_y = tree.param_y.as_ref().is_some_and(|y| !y.trim().is_empty()); + if tree.blend_type == BlendType::Direct { + if !tree.param_x.trim().is_empty() || tree.param_y.is_some() { + errors.push(format!( + "blend tree `{}` is Direct and must not have blend parameters", + tree.name + )); + } + } else { + if tree.param_x.trim().is_empty() { + errors.push(format!("blend tree `{}` has no x parameter", tree.name)); + } else { + expect_param(parameters, &tree.param_x, ParamType::Float, &tree.name, "x", errors); + } + if tree.blend_type.is_2d() && !has_y { + errors.push(format!( + "blend tree `{}` is 2D and needs a y parameter", + tree.name + )); + } + if !tree.blend_type.is_2d() && has_y { + errors.push(format!( + "blend tree `{}` is {:?} and must not have a y parameter", + tree.name, tree.blend_type + )); + } + if tree.blend_type.is_2d() { + if let Some(y) = &tree.param_y { + expect_param(parameters, y, ParamType::Float, &tree.name, "y", errors); + } + } + } + + if tree.children.is_empty() { + errors.push(format!("blend tree `{}` has no children", tree.name)); + } + for child in &tree.children { + expect_motion(&child.motion, graph, errors); + if tree.blend_type == BlendType::Direct { + match &child.direct_param { + None => errors.push(format!( + "blend tree `{}` is Direct and a child has no direct parameter", + tree.name + )), + Some(p) => expect_param(parameters, p, ParamType::Float, &tree.name, "direct", errors), + } + } else if child.direct_param.is_some() { + errors.push(format!( + "blend tree `{}` is {:?} and thresholds are used, so a child must not have a direct parameter", + tree.name, tree.blend_type + )); + } + } + } +} + +fn validate_layers( + graph: &ControllerGraph, + parameters: &HashMap, + errors: &mut Vec, +) { + let mut layer_names = HashSet::new(); + for layer in &graph.controller.layers { + let label = if layer.name.trim().is_empty() { + errors.push("a layer has an empty name".to_string()); + "".to_string() + } else { + if !layer_names.insert(layer.name.clone()) { + errors.push(format!("layer `{}` is declared more than once", layer.name)); + } + layer.name.clone() + }; + + // Transitions cannot cross layers, so resolution is per-layer. + let mut machines = Vec::new(); + walk_machines(&layer.state_machine, &label, &mut machines); + + let mut state_names = HashSet::new(); + for (machine_label, machine) in &machines { + for state in &machine.states { + if state.name.trim().is_empty() { + errors.push(format!("machine `{machine_label}` has a state with an empty name")); + } else if !state_names.insert(state.name.clone()) { + errors.push(format!( + "state `{}` is declared more than once in layer `{label}`", + state.name + )); + } + } + } + + for (machine_label, machine) in &machines { + for state in &machine.states { + if let Some(motion) = &state.motion { + expect_motion(motion, graph, errors); + } + for transition in &state.transitions { + validate_transition( + transition, + &format!("state `{}`", state.name), + &state_names, + parameters, + errors, + ); + } + } + for transition in &machine.any_state_transitions { + validate_transition( + transition, + &format!("machine `{machine_label}` any-state"), + &state_names, + parameters, + errors, + ); + } + } + } +} + +/// State names are collected in a first pass so that forward references resolve. +fn walk_machines<'a>(machine: &'a StateMachine, label: &str, out: &mut Vec<(String, &'a StateMachine)>) { + out.push((label.to_string(), machine)); + for sub in &machine.sub_machines { + let name = sub.name.as_deref().unwrap_or(""); + walk_machines(sub, &format!("{label}/{name}"), out); + } +} + +fn validate_transition( + transition: &Transition, + origin: &str, + state_names: &HashSet, + parameters: &HashMap, + errors: &mut Vec, +) { + if !state_names.contains(&transition.to) { + errors.push(format!( + "{origin} transitions to `{}`, which is not a state in that layer", + transition.to + )); + } + if !transition.duration.is_finite() || !transition.exit_time.is_finite() { + errors.push(format!("{origin} has a non-finite duration or exit time")); + } + if !transition.conditions.is_empty() && transition.has_exit_time { + errors.push(format!( + "{origin} has both conditions and an exit time; Unity will ignore the conditions" + )); + } + for condition in &transition.conditions { + let Some(ty) = parameters.get(&condition.parameter) else { + errors.push(format!( + "{origin} uses parameter `{}`, which is not declared", + condition.parameter + )); + continue; + }; + if !condition.threshold.is_finite() { + errors.push(format!("{origin} has a non-finite condition threshold")); + } + let ok = match condition.mode { + CondMode::If | CondMode::IfNot => *ty == ParamType::Bool, + CondMode::Greater | CondMode::Less => *ty != ParamType::Bool, + CondMode::Equals | CondMode::NotEqual => true, + }; + if !ok { + errors.push(format!( + "{origin} uses {:?} on `{}`, but that parameter cannot be compared that way", + condition.mode, condition.parameter + )); + } + } +} + +fn expect_motion(motion: &MotionRef, graph: &ControllerGraph, errors: &mut Vec) { + let (kind, name, exists) = match motion { + MotionRef::Clip { name } => ("clip", name, graph.clips.iter().any(|c| &c.name == name)), + MotionRef::BlendTree { name } => ( + "blend tree", + name, + graph.blend_trees.iter().any(|t| &t.name == name), + ), + }; + if !exists { + errors.push(format!("reference to {kind} `{name}`, which is not declared")); + } +} + +fn expect_param( + parameters: &HashMap, + name: &str, + expected: ParamType, + owner: &str, + role: &str, + errors: &mut Vec, +) { + match parameters.get(name) { + None => errors.push(format!( + "blend tree `{owner}` uses `{name}` as its {role} parameter, which is not declared" + )), + Some(actual) if *actual != expected => errors.push(format!( + "blend tree `{owner}` uses `{name}` as its {role} parameter, which is not a float parameter" + )), + Some(_) => {} + } +} diff --git a/rust/src/graph.rs b/rust/src/graph.rs new file mode 100644 index 0000000..4666cac --- /dev/null +++ b/rust/src/graph.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; + +/// The complete description of one animator controller, produced entirely by a Rhai script. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ControllerGraph { + pub system_name: String, + pub asset_key: String, + pub parameters: Vec, + pub clips: Vec, + pub blend_trees: Vec, + pub controller: Controller, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Controller { + pub layers: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Layer { + /// The layer suffix as written in the script. Unity's layer name is derived from it by the C# side. + pub name: String, + pub state_machine: StateMachine, +} + +/// Grid position in Unity's animator graph. Serialized as an object so C# can bind it directly. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct GridPos { + pub x: i32, + pub y: i32, +} + +impl GridPos { + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct StateMachine { + /// `None` for a layer's root machine, `Some` for a sub-state machine. + pub name: Option, + pub position: GridPos, + pub states: Vec, + pub sub_machines: Vec, + pub any_state_transitions: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct State { + pub name: String, + pub position: GridPos, + pub motion: Option, + pub transitions: Vec, +} + +/// References to entries in `ControllerGraph::clips` / `ControllerGraph::blend_trees`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MotionRef { + Clip { name: String }, + BlendTree { name: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Transition { + pub to: String, + pub conditions: Vec, + pub has_exit_time: bool, + pub exit_time: f32, + pub duration: f32, + pub ordered_interruption: bool, + pub source_interruption: bool, + pub can_transition_to_self: bool, +} + +impl Transition { + /// Mirrors `AacDefaultsProvider.ConfigureTransition`, so unspecified fields match library defaults. + pub fn new(to: impl Into) -> Self { + Self { + to: to.into(), + conditions: Vec::new(), + has_exit_time: false, + exit_time: 0.0, + duration: 0.0, + ordered_interruption: true, + source_interruption: false, + can_transition_to_self: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub parameter: String, + pub mode: CondMode, + pub threshold: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CondMode { + Greater, + Less, + Equals, + NotEqual, + If, + IfNot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ClipData { + pub name: String, + pub looping: bool, + pub curves: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Curve { + pub path: String, + pub target: TargetType, + pub property: String, + pub keys: Vec, +} + +/// The Unity component a curve binds to. Closed set: an unknown property is an error, not a guess. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetType { + GameObject, + SkinnedMeshRenderer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Keyframe { + pub time: f32, + pub value: f32, + pub in_tangent: f32, + pub out_tangent: f32, +} + +impl Keyframe { + pub fn linear(time: f32, value: f32) -> Self { + Self { + time, + value, + in_tangent: 0.0, + out_tangent: 0.0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BlendTreeData { + pub name: String, + pub blend_type: BlendType, + pub param_x: String, + pub param_y: Option, + pub children: Vec, + pub use_automatic_thresholds: bool, +} + +/// `rename_all = "snake_case"` would turn `SimpleDirectional2D` into `simple_directional2_d`, +/// so every variant is renamed explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum BlendType { + #[serde(rename = "simple_1d")] + Simple1D, + #[serde(rename = "simple_directional_2d")] + SimpleDirectional2D, + #[serde(rename = "freeform_directional_2d")] + FreeformDirectional2D, + #[serde(rename = "freeform_cartesian_2d")] + FreeformCartesian2D, + #[serde(rename = "direct")] + Direct, +} + +impl BlendType { + pub fn is_2d(self) -> bool { + matches!( + self, + BlendType::SimpleDirectional2D + | BlendType::FreeformDirectional2D + | BlendType::FreeformCartesian2D + ) + } + + /// Blend trees that place children by threshold rather than by a direct blend parameter. + pub fn uses_thresholds(self) -> bool { + self != BlendType::Direct + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BlendChild { + pub motion: MotionRef, + pub threshold: f32, + pub threshold_y: Option, + /// Only meaningful on `Direct` blend trees. + pub direct_param: Option, +} + +impl BlendChild { + pub fn motion(motion: MotionRef) -> Self { + Self { + motion, + threshold: 0.0, + threshold_y: None, + direct_param: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Parameter { + Float { name: String, default: f32 }, + Int { name: String, default: i32 }, + Bool { name: String, default: bool }, +} + +impl Parameter { + pub fn name(&self) -> &str { + match self { + Parameter::Float { name, .. } | Parameter::Int { name, .. } | Parameter::Bool { name, .. } => name, + } + } +} + +/// Property-name -> component-type inference. Deliberately strict; an unmapped property is an error. +pub fn infer_target(property: &str) -> Option { + if property.starts_with("blendShape.") { + return Some(TargetType::SkinnedMeshRenderer); + } + match property { + "m_IsActive" => Some(TargetType::GameObject), + _ => None, + } +} + +impl ClipData { + /// Find-or-create the curve for one (path, target, property) binding. + pub fn curve_mut(&mut self, path: &str, target: TargetType, property: &str) -> &mut Curve { + let index = match self + .curves + .iter() + .position(|c| c.path == path && c.target == target && c.property == property) + { + Some(index) => index, + None => { + self.curves.push(Curve { + path: path.to_string(), + target, + property: property.to_string(), + keys: Vec::new(), + }); + self.curves.len() - 1 + } + }; + &mut self.curves[index] + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..2d652cd --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,167 @@ +//! Evaluates a Rhai script that describes an Animator Controller, and hands the result to C# as JSON. +//! +//! The C# side never sees Rhai: it receives a `ControllerGraph` and drives the Animator As Code +//! modification API with it. + +pub mod builder; +pub mod export; +pub mod graph; +pub mod motion_api; +pub mod rhai_api; + +use builder::Aac; +use rhai::{Dynamic, Engine, Scope}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::ptr; + +pub use graph::ControllerGraph; + +/// Opaque to C#. Owns the graph, the Rhai engine, and the last error message. +pub struct AacContext { + aac: Aac, + engine: Engine, + last_error: Option, +} + +impl AacContext { + fn set_error(&mut self, message: String) { + // Interior NUL bytes would make CString::new fail; they can only come from a script string. + self.last_error = Some(CString::new(message.replace('\0', "\\0")).unwrap_or_default()); + } +} + +/// Evaluate a script and serialize the resulting graph. The testable core of the FFI. +pub fn evaluate_to_json(script: &str) -> Result { + let aac = Aac::new(); + let engine = rhai_api::engine(aac.clone()); + let mut scope = Scope::new(); + let _ = engine + .eval_with_scope::(&mut scope, script) + .map_err(|error| error.to_string())?; + let graph = aac.write(|graph| graph.clone()); + export::to_json(&graph) +} + +/// Create a context. The returned pointer owns everything; release it with `aac_destroy`. +#[no_mangle] +pub extern "C" fn aac_create() -> *mut AacContext { + let aac = Aac::new(); + let engine = rhai_api::engine(aac.clone()); + Box::into_raw(Box::new(AacContext { + aac, + engine, + last_error: None, + })) +} + +/// Evaluate a Rhai script. Returns 0 on success; on failure, call `aac_last_error`. +#[no_mangle] +pub extern "C" fn aac_eval_rhai(handle: *mut AacContext, script: *const c_char) -> i32 { + if handle.is_null() || script.is_null() { + return 1; + } + // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed. + let context = unsafe { &mut *handle }; + let script = match unsafe { CStr::from_ptr(script) }.to_str() { + Ok(script) => script, + Err(error) => { + context.set_error(format!("script is not valid UTF-8: {error}")); + return 1; + } + }; + + let outcome = { + let engine = &context.engine; + catch_unwind(AssertUnwindSafe(|| { + let mut scope = Scope::new(); + engine.eval_with_scope::(&mut scope, script) + })) + }; + + match outcome { + Ok(Ok(_)) => { + context.last_error = None; + 0 + } + Ok(Err(error)) => { + context.set_error(error.to_string()); + 1 + } + Err(_) => { + context.set_error("internal error: the evaluator panicked".to_string()); + 2 + } + } +} + +/// Serialize the graph to JSON. Returns null on error; release the result with `aac_free_string`. +#[no_mangle] +pub extern "C" fn aac_to_json(handle: *mut AacContext) -> *mut c_char { + if handle.is_null() { + return ptr::null_mut(); + } + // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed. + let context = unsafe { &mut *handle }; + let outcome = catch_unwind(AssertUnwindSafe(|| { + let graph = context.aac.write(|graph| graph.clone()); + export::to_json(&graph) + })); + + match outcome { + Ok(Ok(json)) => match CString::new(json) { + Ok(json) => { + context.last_error = None; + json.into_raw() + } + Err(_) => { + context.set_error("internal error: serialized JSON contained a NUL byte".to_string()); + ptr::null_mut() + } + }, + Ok(Err(error)) => { + context.set_error(error); + ptr::null_mut() + } + Err(_) => { + context.set_error("internal error: serialization panicked".to_string()); + ptr::null_mut() + } + } +} + +/// The last error message, or null if the last call succeeded. Borrowed: valid until the next call +/// on this context, and must not be freed. +#[no_mangle] +pub extern "C" fn aac_last_error(handle: *mut AacContext) -> *const c_char { + if handle.is_null() { + return ptr::null(); + } + // SAFETY: the caller guarantees `handle` came from `aac_create` and has not been destroyed. + let context = unsafe { &*handle }; + match &context.last_error { + Some(message) => message.as_ptr(), + None => ptr::null(), + } +} + +/// Free a string returned by `aac_to_json`. +#[no_mangle] +pub extern "C" fn aac_free_string(string: *mut c_char) { + if string.is_null() { + return; + } + // SAFETY: `string` must come from `aac_to_json`, which uses `CString::into_raw`. + unsafe { drop(CString::from_raw(string)) }; +} + +/// Destroy a context created by `aac_create`. +#[no_mangle] +pub extern "C" fn aac_destroy(handle: *mut AacContext) { + if handle.is_null() { + return; + } + // SAFETY: `handle` must come from `aac_create`, which uses `Box::into_raw`. + unsafe { drop(Box::from_raw(handle)) }; +} diff --git a/rust/src/motion_api.rs b/rust/src/motion_api.rs new file mode 100644 index 0000000..d41a31e --- /dev/null +++ b/rust/src/motion_api.rs @@ -0,0 +1,188 @@ +use crate::builder::*; +use crate::graph::*; +use crate::rhai_api::err; +use rhai::{Engine, EvalAltResult}; + +pub fn register(engine: &mut Engine) { + register_clips(engine); + register_blend_trees(engine); + register_state_motion(engine); +} + +fn clip_ref(clip: &ClipBuilder) -> MotionRef { + MotionRef::Clip { + name: clip.name.clone(), + } +} + +fn tree_ref(tree: &BlendTreeBuilder) -> MotionRef { + MotionRef::BlendTree { + name: tree.name.clone(), + } +} + +fn register_clips(engine: &mut Engine) { + engine.register_fn("clip", |a: &mut Aac, name: &str| -> Result> { + if name.trim().is_empty() { + return Err(err("clip name is empty".to_string())); + } + if a.write(|graph| graph.clips.iter().any(|clip| clip.name == name)) { + return Err(err(format!("clip `{name}` is already declared"))); + } + a.write(|graph| { + graph.clips.push(ClipData { + name: name.to_string(), + looping: false, + curves: Vec::new(), + }) + }); + Ok(ClipBuilder { + aac: a.clone(), + name: name.to_string(), + }) + }); + + engine.register_fn("looping", |clip: ClipBuilder, value: bool| { + clip.data_mut(|data| data.looping = value); + clip + }); + + engine.register_fn( + "keyframe", + |clip: ClipBuilder, path: &str, property: &str, time: f64, value: f64| -> Result> { + clip.push_key(path, property, Keyframe::linear(time as f32, value as f32)) + .map_err(err)?; + Ok(clip) + }, + ); + + engine.register_fn("toggle", |clip: ClipBuilder, path: &str, value: bool| -> Result> { + let value = if value { 1.0 } else { 0.0 }; + // A one-frame constant, matching AAC's toggling semantics. + clip.push_key(path, "m_IsActive", Keyframe::linear(0.0, value)) + .map_err(err)?; + clip.push_key(path, "m_IsActive", Keyframe::linear(1.0 / 60.0, value)) + .map_err(err)?; + Ok(clip) + }); + + engine.register_fn( + "blend_shape", + |clip: ClipBuilder, path: &str, shape: &str, time: f64, value: f64| -> Result> { + let property = format!("blendShape.{shape}"); + clip.push_key(path, &property, Keyframe::linear(time as f32, value as f32)) + .map_err(err)?; + Ok(clip) + }, + ); +} + +fn register_blend_trees(engine: &mut Engine) { + engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result> { + if name.trim().is_empty() { + return Err(err("blend tree name is empty".to_string())); + } + if a.write(|graph| graph.blend_trees.iter().any(|tree| tree.name == name)) { + return Err(err(format!("blend tree `{name}` is already declared"))); + } + a.write(|graph| { + graph.blend_trees.push(BlendTreeData { + name: name.to_string(), + blend_type: BlendType::Simple1D, + param_x: String::new(), + param_y: None, + children: Vec::new(), + use_automatic_thresholds: false, + }) + }); + Ok(BlendTreeBuilder { + aac: a.clone(), + name: name.to_string(), + }) + }); + + engine.register_fn("simple_1d", |tree: BlendTreeBuilder, x: FloatParam| { + tree.configure(BlendType::Simple1D, &x.name, None).map_err(err)?; + Ok::>(tree) + }); + engine.register_fn("simple_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| { + tree.configure(BlendType::SimpleDirectional2D, &x.name, Some(&y.name)) + .map_err(err)?; + Ok::>(tree) + }); + engine.register_fn("freeform_directional_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| { + tree.configure(BlendType::FreeformDirectional2D, &x.name, Some(&y.name)) + .map_err(err)?; + Ok::>(tree) + }); + engine.register_fn("freeform_cartesian_2d", |tree: BlendTreeBuilder, x: FloatParam, y: FloatParam| { + tree.configure(BlendType::FreeformCartesian2D, &x.name, Some(&y.name)) + .map_err(err)?; + Ok::>(tree) + }); + engine.register_fn("direct", |tree: BlendTreeBuilder| { + tree.configure(BlendType::Direct, "", None).map_err(err)?; + Ok::>(tree) + }); + engine.register_fn("automatic_thresholds", |tree: BlendTreeBuilder, value: bool| { + tree.data_mut(|data| data.use_automatic_thresholds = value); + tree + }); + + engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, threshold: f64| { + push_child(&tree, clip_ref(&clip), threshold, None, None)?; + Ok::>(tree) + }); + engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, threshold: f64| { + push_child(&tree, tree_ref(&child), threshold, None, None)?; + Ok::>(tree) + }); + engine.register_fn("add_motion", |tree: BlendTreeBuilder, clip: ClipBuilder, x: f64, y: f64| { + push_child(&tree, clip_ref(&clip), x, Some(y), None)?; + Ok::>(tree) + }); + engine.register_fn("add_motion", |tree: BlendTreeBuilder, child: BlendTreeBuilder, x: f64, y: f64| { + push_child(&tree, tree_ref(&child), x, Some(y), None)?; + Ok::>(tree) + }); + + engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, clip: ClipBuilder, parameter: FloatParam| { + push_child(&tree, clip_ref(&clip), 0.0, None, Some(¶meter.name))?; + Ok::>(tree) + }); + engine.register_fn("add_motion_direct", |tree: BlendTreeBuilder, child: BlendTreeBuilder, parameter: FloatParam| { + push_child(&tree, tree_ref(&child), 0.0, None, Some(¶meter.name))?; + Ok::>(tree) + }); +} + +fn register_state_motion(engine: &mut Engine) { + engine.register_fn("set_clip", |state: StateRef, clip: ClipBuilder| { + state.set_motion(clip_ref(&clip)); + state + }); + engine.register_fn("set_motion", |state: StateRef, clip: ClipBuilder| { + state.set_motion(clip_ref(&clip)); + state + }); + engine.register_fn("set_motion", |state: StateRef, tree: BlendTreeBuilder| { + state.set_motion(tree_ref(&tree)); + state + }); +} + +fn push_child( + tree: &BlendTreeBuilder, + motion: MotionRef, + threshold: f64, + threshold_y: Option, + direct_param: Option<&str>, +) -> Result<(), Box> { + let child = BlendChild { + motion, + threshold: threshold as f32, + threshold_y: threshold_y.map(|y| y as f32), + direct_param: direct_param.map(str::to_string), + }; + tree.push_child(child).map_err(err) +} diff --git a/rust/src/rhai_api.rs b/rust/src/rhai_api.rs new file mode 100644 index 0000000..7dca468 --- /dev/null +++ b/rust/src/rhai_api.rs @@ -0,0 +1,271 @@ +use crate::builder::*; +use crate::graph::*; +use rhai::{Array, Engine, EvalAltResult, Position}; + +pub(crate) fn err(message: String) -> Box { + Box::new(EvalAltResult::ErrorRuntime(message.into(), Position::NONE)) +} + +fn condition(parameter: &str, mode: CondMode, threshold: f32) -> Condition { + Condition { + parameter: parameter.to_string(), + mode, + threshold, + } +} + +fn grid(value: i64) -> Result> { + i32::try_from(value).map_err(|_| err(format!("grid position {value} does not fit in a 32-bit integer"))) +} + +fn add_state(aac: &Aac, layer: usize, machine: &[usize], name: &str, x: i64, y: i64) -> Result> { + let position = GridPos::new(grid(x)?, grid(y)?); + let state = aac.write(|graph| { + let states = &mut machine_mut(graph, layer, machine).states; + states.push(State { + name: name.to_string(), + position, + ..Default::default() + }); + states.len() - 1 + }); + Ok(StateRef { + aac: aac.clone(), + layer, + machine: machine.to_vec(), + state, + }) +} + +fn add_sub_machine( + aac: &Aac, + layer: usize, + machine: &[usize], + name: &str, + x: i64, + y: i64, +) -> Result> { + let position = GridPos::new(grid(x)?, grid(y)?); + let index = aac.write(|graph| { + let sub_machines = &mut machine_mut(graph, layer, machine).sub_machines; + sub_machines.push(StateMachine { + name: Some(name.to_string()), + position, + ..Default::default() + }); + sub_machines.len() - 1 + }); + let mut path = machine.to_vec(); + path.push(index); + Ok(MachineRef { + aac: aac.clone(), + layer, + machine: path, + }) +} + +fn assert_new_parameter(aac: &Aac, name: &str) -> Result<(), Box> { + if name.trim().is_empty() { + return Err(err("parameter name is empty".to_string())); + } + let duplicate = aac.write(|graph| graph.parameters.iter().any(|p| p.name() == name)); + if duplicate { + return Err(err(format!("parameter `{name}` is already declared"))); + } + Ok(()) +} + +pub fn engine(aac: Aac) -> Engine { + let mut engine = Engine::new(); + + engine.register_type_with_name::("Aac"); + engine.register_type_with_name::("FloatParam"); + engine.register_type_with_name::("IntParam"); + engine.register_type_with_name::("BoolParam"); + engine.register_type_with_name::("Condition"); + engine.register_type_with_name::("Controller"); + engine.register_type_with_name::("Layer"); + engine.register_type_with_name::("Machine"); + engine.register_type_with_name::("State"); + engine.register_type_with_name::("Transition"); + engine.register_type_with_name::("Clip"); + engine.register_type_with_name::("BlendTree"); + + register_conditions(&mut engine); + register_setup(&mut engine, aac); + register_navigation(&mut engine); + register_transitions(&mut engine); + crate::motion_api::register(&mut engine); + + engine +} + +/// Leaf conditions come from overloaded comparison operators, because Rhai's `&&`/`||` cannot be +/// overloaded. Combine several conditions with `when_all([...])`. +fn register_conditions(engine: &mut Engine) { + engine.register_fn(">", |p: FloatParam, v: f64| condition(&p.name, CondMode::Greater, v as f32)); + engine.register_fn("<", |p: FloatParam, v: f64| condition(&p.name, CondMode::Less, v as f32)); + engine.register_fn("==", |p: FloatParam, v: f64| condition(&p.name, CondMode::Equals, v as f32)); + engine.register_fn("!=", |p: FloatParam, v: f64| condition(&p.name, CondMode::NotEqual, v as f32)); + engine.register_fn(">", |p: IntParam, v: i64| condition(&p.name, CondMode::Greater, v as f32)); + engine.register_fn("<", |p: IntParam, v: i64| condition(&p.name, CondMode::Less, v as f32)); + engine.register_fn("==", |p: IntParam, v: i64| condition(&p.name, CondMode::Equals, v as f32)); + engine.register_fn("!=", |p: IntParam, v: i64| condition(&p.name, CondMode::NotEqual, v as f32)); + engine.register_fn("==", |p: BoolParam, v: bool| { + condition(&p.name, if v { CondMode::If } else { CondMode::IfNot }, 0.0) + }); + engine.register_fn("!=", |p: BoolParam, v: bool| { + condition(&p.name, if v { CondMode::IfNot } else { CondMode::If }, 0.0) + }); +} + +fn register_setup(engine: &mut Engine, aac: Aac) { + engine.register_fn("AnimatorAsCode", move || aac.clone()); + + engine.register_fn("system_name", |a: &mut Aac, name: &str| { + a.write(|graph| graph.system_name = name.to_string()); + }); + engine.register_fn("asset_key", |a: &mut Aac, key: &str| { + a.write(|graph| graph.asset_key = key.to_string()); + }); + + engine.register_fn("float_param", |a: &mut Aac, name: &str, default: f64| -> Result> { + assert_new_parameter(a, name)?; + a.write(|graph| { + graph.parameters.push(Parameter::Float { + name: name.to_string(), + default: default as f32, + }) + }); + Ok(FloatParam { + name: name.to_string(), + }) + }); + engine.register_fn("int_param", |a: &mut Aac, name: &str, default: i64| -> Result> { + assert_new_parameter(a, name)?; + a.write(|graph| { + graph.parameters.push(Parameter::Int { + name: name.to_string(), + default: default as i32, + }) + }); + Ok(IntParam { + name: name.to_string(), + }) + }); + engine.register_fn( + "bool_param", + |a: &mut Aac, name: &str, default: bool| -> Result> { + assert_new_parameter(a, name)?; + a.write(|graph| { + graph.parameters.push(Parameter::Bool { + name: name.to_string(), + default, + }) + }); + Ok(BoolParam { + name: name.to_string(), + }) + }, + ); + + engine.register_fn("new_controller", |a: &mut Aac| ControllerBuilder { aac: a.clone() }); +} + +fn register_navigation(engine: &mut Engine) { + // There is exactly one controller, so this is the only place layers are attached. + engine.register_fn("layer", |c: ControllerBuilder, name: &str| -> LayerBuilder { + let layer = c.aac.write(|graph| { + graph.controller.layers.push(Layer { + name: name.to_string(), + ..Default::default() + }); + graph.controller.layers.len() - 1 + }); + LayerBuilder { + aac: c.aac.clone(), + layer, + } + }); + + engine.register_fn("state", |l: LayerBuilder, name: &str, x: i64, y: i64| { + add_state(&l.aac, l.layer, &[], name, x, y) + }); + engine.register_fn("state", |m: MachineRef, name: &str, x: i64, y: i64| { + add_state(&m.aac, m.layer, &m.machine, name, x, y) + }); + + engine.register_fn("sub_machine", |l: LayerBuilder, name: &str, x: i64, y: i64| { + add_sub_machine(&l.aac, l.layer, &[], name, x, y) + }); + engine.register_fn("sub_machine", |m: MachineRef, name: &str, x: i64, y: i64| { + add_sub_machine(&m.aac, m.layer, &m.machine, name, x, y) + }); + + engine.register_fn("any_state", |l: LayerBuilder| AnyStateRef { + aac: l.aac.clone(), + layer: l.layer, + machine: Vec::new(), + }); + engine.register_fn("any_state", |m: MachineRef| AnyStateRef { + aac: m.aac.clone(), + layer: m.layer, + machine: m.machine.clone(), + }); +} + +fn register_transitions(engine: &mut Engine) { + engine.register_fn("transition_to", |from: StateRef, to: StateRef| from.transition_to(&to)); + engine.register_fn("transition_to", |from: AnyStateRef, to: StateRef| from.transition_to(&to)); + + engine.register_fn("when", |t: TransitionRef, c: Condition| { + t.add_conditions([c]); + t + }); + engine.register_fn( + "when_all", + |t: TransitionRef, conditions: Array| -> Result> { + let mut collected = Vec::with_capacity(conditions.len()); + for value in conditions { + collected.push( + value + .try_cast::() + .ok_or_else(|| err("when_all expects a list of conditions".to_string()))?, + ); + } + t.add_conditions(collected); + Ok(t) + }, + ); + + engine.register_fn("duration", |t: TransitionRef, seconds: f64| { + t.update(|transition| transition.duration = seconds as f32); + t + }); + engine.register_fn("no_exit_time", |t: TransitionRef| { + t.update(|transition| { + transition.has_exit_time = false; + transition.exit_time = 0.0; + }); + t + }); + engine.register_fn("exit_time", |t: TransitionRef, normalized: f64| { + t.update(|transition| { + transition.has_exit_time = true; + transition.exit_time = normalized as f32; + }); + t + }); + engine.register_fn("ordered_interruption", |t: TransitionRef, value: bool| { + t.update(|transition| transition.ordered_interruption = value); + t + }); + engine.register_fn("source_interruption", |t: TransitionRef| { + t.update(|transition| transition.source_interruption = true); + t + }); + engine.register_fn("to_self", |t: TransitionRef| { + t.update(|transition| transition.can_transition_to_self = true); + t + }); +} diff --git a/rust/tests/dsl.rs b/rust/tests/dsl.rs new file mode 100644 index 0000000..16827ac --- /dev/null +++ b/rust/tests/dsl.rs @@ -0,0 +1,321 @@ +use aac::graph::*; +use serde_json::Value; + +const AVATAR: &str = include_str!("../examples/avatar.rhai"); + +fn json(script: &str) -> Value { + serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json") +} + +fn error(script: &str) -> String { + aac::evaluate_to_json(script).expect_err("script should fail") +} + +/// A minimal but valid graph, for mutating into invalid shapes. +fn valid_graph() -> ControllerGraph { + let script = r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let speed = aac.float_param("Speed", 0.0); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let a = base.state("A", 0, 0); + let b = base.state("B", 1, 0); + let clip = aac.clip("clip"); + clip.looping(true); + a.set_clip(clip); + a.transition_to(b).when(speed > 0.5).duration(0.1); + "#; + serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes") +} + +#[test] +fn example_script_describes_the_whole_controller() { + let value = json(AVATAR); + + assert_eq!(value["system_name"], "MyAvatar"); + assert_eq!(value["asset_key"], "AAC_"); + assert_eq!(value["parameters"].as_array().unwrap().len(), 3); + assert_eq!(value["clips"].as_array().unwrap().len(), 3); + assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1); + + let layers = value["controller"]["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["name"], "Base"); + + let states = layers[0]["state_machine"]["states"].as_array().unwrap(); + assert_eq!(states.len(), 4); + assert_eq!(states[0]["name"], "Idle"); + assert_eq!(states[0]["position"]["x"], 0); + + // A clip used both as a state motion and as a blend tree child. + assert_eq!(states[0]["motion"]["type"], "clip"); + assert_eq!(states[0]["motion"]["name"], "idle_anim"); + assert_eq!(states[2]["motion"]["type"], "blend_tree"); + assert_eq!(states[2]["motion"]["name"], "locomotion"); + + // `speed > 0.1` becomes a greater-than condition. + let transition = &states[0]["transitions"][0]; + assert_eq!(transition["conditions"][0]["parameter"], "Speed"); + assert_eq!(transition["conditions"][0]["mode"], "greater"); + assert_eq!(transition["conditions"][0]["threshold"], 0.1); + assert_eq!(transition["duration"], 0.25); + assert_eq!(transition["has_exit_time"], false); + + // `when_all([...])` produces two conditions on one transition. + let both = states[1]["transitions"][1]["conditions"].as_array().unwrap(); + assert_eq!(both.len(), 2); + assert_eq!(both[1]["parameter"], "IsSitting"); + assert_eq!(both[1]["mode"], "if_not"); + + let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap(); + assert_eq!(any_state.len(), 1); + assert_eq!(any_state[0]["to"], "Sit"); + + let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap(); + assert_eq!(sub.len(), 1); + assert_eq!(sub[0]["name"], "Gestures"); + assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2); + + // Blend shape keys landed in time order on one curve. + let clip = find_clip(&value, "idle_anim"); + let curve = curve_of(clip, "blendShape.Smile"); + assert_eq!(curve["target"], "skinned_mesh_renderer"); + assert_eq!(curve["keys"].as_array().unwrap().len(), 2); + assert_eq!(curve["keys"][0]["time"], 0.0); + assert_eq!(curve["keys"][1]["time"], 1.0); + + // A toggle is a one-frame constant: two keys with the same value. + let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive"); + assert_eq!(toggle["target"], "game_object"); + let keys = toggle["keys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0]["value"], keys[1]["value"]); +} + +fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value { + value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap() +} + +fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value { + clip["curves"] + .as_array() + .unwrap() + .iter() + .find(|c| c["property"] == property) + .unwrap() +} + +#[test] +fn keyframes_are_sorted_by_time() { + let value = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let c = aac.clip("c"); + c.keyframe("Body", "m_IsActive", 2.0, 1.0); + c.keyframe("Body", "m_IsActive", 0.5, 0.0); + c.keyframe("Body", "m_IsActive", 1.0, 1.0); + "#, + ); + let times: Vec = value["clips"][0]["curves"][0]["keys"] + .as_array() + .unwrap() + .iter() + .map(|k| k["time"].as_f64().unwrap()) + .collect(); + assert_eq!(times, vec![0.5, 1.0, 2.0]); +} + +#[test] +fn blend_tree_shapes() { + let value = json( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let x = aac.float_param("X", 0.0); + let y = aac.float_param("Y", 0.0); + let direct = aac.float_param("Direct", 0.0); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let s = base.state("S", 0, 0); + let one = aac.clip("one"); + let two = aac.clip("two"); + let inner = aac.blend_tree("inner"); + inner.simple_1d(x); + inner.add_motion(one, 0.0); + let outer = aac.blend_tree("outer"); + outer.simple_directional_2d(x, y); + outer.add_motion(two, 0.0, 1.0); + outer.add_motion(inner, 2.0, 3.0); + outer.automatic_thresholds(true); + s.set_motion(outer); + let flat = aac.blend_tree("flat"); + flat.direct(); + flat.add_motion_direct(one, direct); + "#, + ); + let trees = value["blend_trees"].as_array().unwrap(); + let outer = trees.iter().find(|t| t["name"] == "outer").unwrap(); + assert_eq!(outer["blend_type"], "simple_directional_2d"); + assert_eq!(outer["param_x"], "X"); + assert_eq!(outer["param_y"], "Y"); + assert_eq!(outer["use_automatic_thresholds"], true); + let children = outer["children"].as_array().unwrap(); + assert_eq!(children.len(), 2); + assert_eq!(children[0]["threshold_y"], 1.0); + assert_eq!(children[1]["motion"]["type"], "blend_tree"); + assert_eq!(children[1]["motion"]["name"], "inner"); + + let flat = trees.iter().find(|t| t["name"] == "flat").unwrap(); + assert_eq!(flat["blend_type"], "direct"); + assert_eq!(flat["param_x"], ""); + assert_eq!(flat["children"][0]["direct_param"], "Direct"); +} + +#[test] +fn json_round_trips_through_the_graph_type() { + // Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits. + let json = aac::evaluate_to_json(AVATAR).expect("evaluates"); + let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes"); + let reserialized = serde_json::to_string_pretty(&graph).expect("serializes"); + assert_eq!(reserialized, json); +} + +#[test] +fn missing_configuration_is_reported() { + assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set")); + assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set")); +} + +#[test] +fn duplicate_names_are_rejected_at_the_call_site() { + assert!(error( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let a = aac.float_param("Speed", 0.0); + let b = aac.float_param("Speed", 0.0); + "# + ) + .contains("already declared")); + + assert!(error( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let a = aac.clip("c"); + let b = aac.clip("c"); + "# + ) + .contains("already declared")); +} + +#[test] +fn unknown_curve_property_is_rejected() { + let message = error( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let c = aac.clip("c"); + c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0); + "#, + ); + assert!(message.contains("cannot tell which component"), "unexpected: {message}"); +} + +#[test] +fn and_operator_is_not_available() { + // The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`. + let message = error( + r#" + let aac = AnimatorAsCode(); + aac.system_name("S"); + aac.asset_key("K"); + let a = aac.float_param("A", 0.0); + let b = aac.bool_param("B", false); + let ctrl = aac.new_controller(); + let base = ctrl.layer("Base"); + let s = base.state("S", 0, 0); + let t = base.state("T", 1, 0); + s.transition_to(t).when(a > 0.0 && b == true); + "#, + ); + assert!(message.contains("bool"), "unexpected: {message}"); +} + +/// The validator is a safety net for graphs that do not come from the typed Rhai DSL. +#[test] +fn validator_rejects_broken_graphs() { + let mut graph = valid_graph(); + graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string(); + let message = aac::export::to_json(&graph).expect_err("dangling target"); + assert!(message.contains("not a state in that layer"), "unexpected: {message}"); + + let mut graph = valid_graph(); + graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string(); + let message = aac::export::to_json(&graph).expect_err("undeclared parameter"); + assert!(message.contains("not declared"), "unexpected: {message}"); + + let mut graph = valid_graph(); + graph.clips.push(graph.clips[0].clone()); + let message = aac::export::to_json(&graph).expect_err("duplicate clip"); + assert!(message.contains("declared more than once"), "unexpected: {message}"); + + let mut graph = valid_graph(); + graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() }); + let message = aac::export::to_json(&graph).expect_err("dangling motion"); + assert!(message.contains("not declared"), "unexpected: {message}"); + + // Forward references across states must resolve. + let graph = valid_graph(); + aac::export::to_json(&graph).expect("a valid graph stays valid"); +} + +/// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or +/// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set. +#[test] +fn json_keys_are_the_ones_the_unity_dtos_expect() { + let value = json(AVATAR); + + fn keys(value: &Value) -> Vec<&str> { + value.as_object().expect("object").keys().map(String::as_str).collect() + } + + assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]); + assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]); + assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]); + assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]); + assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]); + + let tree = &value["blend_trees"][0]; + assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]); + assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]); + assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]); + + let layer = &value["controller"]["layers"][0]; + assert_eq!(keys(layer), ["name", "state_machine"]); + assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]); + assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]); + assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]); + assert_eq!( + keys(&layer["state_machine"]["states"][0]["transitions"][0]), + [ + "can_transition_to_self", + "conditions", + "duration", + "exit_time", + "has_exit_time", + "ordered_interruption", + "source_interruption", + "to" + ] + ); + assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]); +}