From 8714b4710283b68715d307ea2980ee4ca3e7586c Mon Sep 17 00:00:00 2001 From: Edwin Jakobs Date: Tue, 7 Apr 2020 14:28:09 +0200 Subject: [PATCH] Add orx-keyframer --- orx-keyframer/README.md | 99 +++++ orx-keyframer/build.gradle | 47 +++ orx-keyframer/src/demo/kotlin/DemoFull01.kt | 23 + orx-keyframer/src/demo/kotlin/DemoScrub01.kt | 47 +++ orx-keyframer/src/demo/kotlin/DemoSimple01.kt | 19 + orx-keyframer/src/demo/kotlin/DemoSimple02.kt | 21 + .../demo/kotlin/DemoSimpleExpressions01.kt | 22 + .../demo/kotlin/DemoSimpleRepetitions01.kt | 20 + .../src/demo/resources/demo-full-01.json | 74 ++++ .../src/demo/resources/demo-simple-01.json | 20 + .../src/demo/resources/demo-simple-02.json | 32 ++ .../resources/demo-simple-expressions-01.json | 30 ++ .../resources/demo-simple-repetitions-01.json | 30 ++ orx-keyframer/src/main/antlr/KeyLangLexer.g4 | 90 ++++ orx-keyframer/src/main/antlr/KeyLangParser.g4 | 46 ++ orx-keyframer/src/main/kotlin/Expressions.kt | 398 ++++++++++++++++++ orx-keyframer/src/main/kotlin/Key.kt | 68 +++ orx-keyframer/src/main/kotlin/Keyframer.kt | 375 +++++++++++++++++ .../src/test/kotlin/TestExpressionErrors.kt | 57 +++ .../src/test/kotlin/TestFunctionCall.kt | 92 ++++ .../src/test/kotlin/TestKeyframerChannel.kt | 35 ++ .../src/test/kotlin/TestKeyframerErrors.kt | 105 +++++ .../src/test/kotlin/TestOperators.kt | 35 ++ .../resources/error-reporting/easing.json | 5 + .../resources/error-reporting/time-01.json | 5 + .../resources/error-reporting/time-02.json | 5 + .../resources/error-reporting/value-01.json | 7 + 27 files changed, 1807 insertions(+) create mode 100644 orx-keyframer/README.md create mode 100644 orx-keyframer/build.gradle create mode 100644 orx-keyframer/src/demo/kotlin/DemoFull01.kt create mode 100644 orx-keyframer/src/demo/kotlin/DemoScrub01.kt create mode 100644 orx-keyframer/src/demo/kotlin/DemoSimple01.kt create mode 100644 orx-keyframer/src/demo/kotlin/DemoSimple02.kt create mode 100644 orx-keyframer/src/demo/kotlin/DemoSimpleExpressions01.kt create mode 100644 orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt create mode 100644 orx-keyframer/src/demo/resources/demo-full-01.json create mode 100644 orx-keyframer/src/demo/resources/demo-simple-01.json create mode 100644 orx-keyframer/src/demo/resources/demo-simple-02.json create mode 100644 orx-keyframer/src/demo/resources/demo-simple-expressions-01.json create mode 100644 orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json create mode 100644 orx-keyframer/src/main/antlr/KeyLangLexer.g4 create mode 100644 orx-keyframer/src/main/antlr/KeyLangParser.g4 create mode 100644 orx-keyframer/src/main/kotlin/Expressions.kt create mode 100644 orx-keyframer/src/main/kotlin/Key.kt create mode 100644 orx-keyframer/src/main/kotlin/Keyframer.kt create mode 100644 orx-keyframer/src/test/kotlin/TestExpressionErrors.kt create mode 100644 orx-keyframer/src/test/kotlin/TestFunctionCall.kt create mode 100644 orx-keyframer/src/test/kotlin/TestKeyframerChannel.kt create mode 100644 orx-keyframer/src/test/kotlin/TestKeyframerErrors.kt create mode 100644 orx-keyframer/src/test/kotlin/TestOperators.kt create mode 100644 orx-keyframer/src/test/resources/error-reporting/easing.json create mode 100644 orx-keyframer/src/test/resources/error-reporting/time-01.json create mode 100644 orx-keyframer/src/test/resources/error-reporting/time-02.json create mode 100644 orx-keyframer/src/test/resources/error-reporting/value-01.json diff --git a/orx-keyframer/README.md b/orx-keyframer/README.md new file mode 100644 index 00000000..0976ef3b --- /dev/null +++ b/orx-keyframer/README.md @@ -0,0 +1,99 @@ +# orx-keyframer + +A highly reusable keyframer. + +This POC relies on JSON files, but that's not a hard dependency, it can be replaced with any deserializer scheme. + +What this allows you to do: + +1. Create a keyframed animation in a json file. + +```json +[ + { + "time": 0.0, + "easing": "cubic-in-out", + "x": 3.0, + "y": 4.0, + "z": 9.0, + "r": 0.1, + "g": 0.5, + "b": 0.2, + "radius": 50 + }, + { + "time": 2.0, + "easing": "cubic-in-out", + "r": 0.6, + "g": 0.5, + "b": 0.1 + }, + { + "time": 4.0, + "easing": "cubic-in-out", + "x": 10.0, + "y": 4.0, + "radius": 400 + }, + { + "time": 5.0, + "easing": "cubic-in-out", + "x": 100.0, + "y": 320.0, + "radius": 400 + }, + { + "time": 5.3, + "easing": "cubic-in-out", + "x": 100.0, + "y": 320.0, + "radius": 40 + } +] +``` + +2. Map the animation data to Kotlin types: + +```kotlin +class Animation : Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + val radius by DoubleChannel("radius") + val color by RGBChannel(arrayOf("r", "g", "b")) +} + +val animation = Animation() +animation.loadFromJson(File("data/keyframes/animation.json")) +``` + +3. Animate! (from an OPENRNDR program) + +```kotlin +extend { + animation(seconds) + drawer.fill = animation.color + drawer.circle(animation.position, animation.radius) +} +``` + +## Advanced features + +orx-keyframer uses two file formats. A `SIMPLE` format and a `FULL` format. For reference check the [example full format .json](src/demo/resources/demo-full-01.json) and the [example program](src/demo/kotlin/DemoFull01.kt). +The full format adds a `parameters` block and a `prototypes` block. + +[Repeats](src/demo/resources/demo-simple-repetitions-01.json), simple key repeating mechanism + +[Expressions](src/demo/resources/demo-simple-expressions-01.json), expression mechanism. Currently uses values `r` to indicate repeat index and `t` the last used key time, `v` the last used value (for the animated attribute). + +Supported functions in expressions: + - `min(x, y)`, `max(x, y)` + - `cos(x)`, `sin(x)`, `acos(x)`, `asin(x)`, `tan(x)`, `atan(x)`, `atan2(y, x)` + - `abs(x)`, `saturate(x)` + - `degrees(x)`, `radians(x)` + - `pow(x,y)`, `sqrt(x)`, `exp(x)` + - `mix(left, right, x)` + - `smoothstep(t0, t1, x)` + - `map(leftBefore, rightBefore, leftAfter, rightAfter, x)` + - `random()`, `random(min, max)` + +[Parameters and prototypes](src/demo/resources/demo-full-01.json) + diff --git a/orx-keyframer/build.gradle b/orx-keyframer/build.gradle new file mode 100644 index 00000000..53c69568 --- /dev/null +++ b/orx-keyframer/build.gradle @@ -0,0 +1,47 @@ +//plugins { +// id 'antlr' +//} + +apply plugin: 'antlr' + +sourceSets { + demo { + java { + srcDirs = ["src/demo/kotlin"] + compileClasspath += main.getCompileClasspath() + runtimeClasspath += main.getRuntimeClasspath() + } + } + main { + java { + srcDir("src/main/java") + srcDir("src/main/kotlin") + srcDir("build/generated-src/antlr") + } + } +} + + + +generateGrammarSource { + maxHeapSize = "64m" + arguments += ["-visitor", "-long-messages"] + outputDirectory = file("${project.buildDir}/generated-src/antlr/org/openrndr/extra/keyframer/antlr".toString()) + +} + +dependencies { + antlr("org.antlr:antlr4:$antlrVersion") + implementation("org.antlr:antlr4-runtime:$antlrVersion") + implementation(project(":orx-noise")) + implementation(project(":orx-easing")) + implementation "com.google.code.gson:gson:$gsonVersion" + implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") + + demoImplementation(project(":orx-camera")) + demoImplementation(project(":orx-panel")) + demoImplementation("org.openrndr:openrndr-core:$openrndrVersion") + demoRuntimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion") + demoRuntimeOnly("org.openrndr:openrndr-gl3-natives-$openrndrOS:$openrndrVersion") + demoImplementation(sourceSets.getByName("main").output) +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoFull01.kt b/orx-keyframer/src/demo/kotlin/DemoFull01.kt new file mode 100644 index 00000000..c358dd8d --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoFull01.kt @@ -0,0 +1,23 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.extra.keyframer.KeyframerFormat +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + class Animation: Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + val radius by DoubleChannel("radius") + val color by RGBChannel(arrayOf("r", "g", "b")) + } + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-full-01.json")), format = KeyframerFormat.FULL) + + extend { + animation(seconds) + drawer.fill = animation.color + drawer.circle(animation.position, animation.radius) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoScrub01.kt b/orx-keyframer/src/demo/kotlin/DemoScrub01.kt new file mode 100644 index 00000000..9f2c5204 --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoScrub01.kt @@ -0,0 +1,47 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.panel.controlManager +import org.openrndr.panel.elements.Range +import org.openrndr.panel.elements.Slider +import org.openrndr.panel.elements.slider +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + + // -- replace the default clock with an offset clock + var clockOffset = 0.0 + val oldClock = clock + clock = { oldClock() - clockOffset } + var clockSlider: Slider? = null + + // -- setup a simple UI + val cm = controlManager { + layout { + clockSlider = slider { + range = Range(0.0, 30.0) + events.valueChanged.listen { + if (it.interactive) { + clockOffset = oldClock() - it.newValue + } + } + } + } + } + extend(cm) + + class Animation: Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + } + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-simple-01.json"))) + + extend { + // -- update the slider + clockSlider?.value = seconds + animation(seconds) + drawer.circle(animation.position, 100.0) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoSimple01.kt b/orx-keyframer/src/demo/kotlin/DemoSimple01.kt new file mode 100644 index 00000000..42674697 --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoSimple01.kt @@ -0,0 +1,19 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + class Animation: Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + } + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-simple-01.json"))) + + extend { + animation(seconds) + drawer.circle(animation.position, 100.0) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoSimple02.kt b/orx-keyframer/src/demo/kotlin/DemoSimple02.kt new file mode 100644 index 00000000..65048b4a --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoSimple02.kt @@ -0,0 +1,21 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + class Animation: Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + val radius by DoubleChannel("radius") + val color by RGBChannel(arrayOf("r", "g", "b")) + } + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-simple-02.json"))) + extend { + animation(seconds) + drawer.fill = animation.color + drawer.circle(animation.position, animation.radius) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoSimpleExpressions01.kt b/orx-keyframer/src/demo/kotlin/DemoSimpleExpressions01.kt new file mode 100644 index 00000000..b936eafd --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoSimpleExpressions01.kt @@ -0,0 +1,22 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + class Animation : Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + val radius by DoubleChannel("x") + } + + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-simple-expressions-01.json")), + parameters = mapOf("cycleDuration" to 2.0)) + + extend { + animation(seconds) + drawer.circle(animation.position, animation.radius) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt b/orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt new file mode 100644 index 00000000..e1295cf3 --- /dev/null +++ b/orx-keyframer/src/demo/kotlin/DemoSimpleRepetitions01.kt @@ -0,0 +1,20 @@ +import org.openrndr.application +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.resourceUrl +import java.net.URL + +fun main() = application { + program { + class Animation: Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + val radius by DoubleChannel("x") + } + val animation = Animation() + animation.loadFromJson(URL(resourceUrl("/demo-simple-repetitions-01.json"))) + + extend { + animation(seconds) + drawer.circle(animation.position, animation.radius) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-full-01.json b/orx-keyframer/src/demo/resources/demo-full-01.json new file mode 100644 index 00000000..1f9f8bcc --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-full-01.json @@ -0,0 +1,74 @@ +{ + // this is breaking with proper json but.. gson accepts comments and they are invaluable + // in the parameters block you can add custom values, which can be used in expressions + "parameters": { + "smallRadius": 5.0, + "repetitionCount": 10, + "width": 640.0, + "height": 480.0, + // you can have expressions inside parameters too, they are evaluated once, on load + "resolvedOnLoad" : "width * 2.0" + }, + // in the prototypes you can set up key prototypes + "prototypes": { + "red": { + "r": 1.0, + "g": 0.0, + "b": 0.0 + }, + "blue": { + "r": 0.0, + "g": 0.0, + "b": 1.0 + }, + "center": { + // prototypes can have expressions too, they are evaluated as late as possible + // thus, they are evaluated more than once + "x": "width / 2", + "y": "height / 2" + }, + "small": { + "radius": "smallRadius" + }, + "large": { + "radius": "smallRadius * 10.0" + } + }, + "keys": [ + { + "time": 0.0, + "easing": "cubic-in-out", + "x": 3.0, + "y": 4.0, + "z": 9.0, + "r": 0.0, + "g": 1.0, + "b": 0.0, + "radius": 50, + "foo" : 0.0 + }, + { + "time": 2.0, + "easing": "cubic-in-out", + // here we apply the prototypes in cascading fashion from left to right + "prototypes": "red center small" + }, + { + "time": 3.0, + "repeat": { + "count": "repetitionCount", + "keys": [ + { + "time": "(rep * 2.0) + 3.0", + "prototypes": "blue large", + "easing": "cubic-in-out" + }, + { + "time": "t + 1.0", + "prototypes": "red small" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-simple-01.json b/orx-keyframer/src/demo/resources/demo-simple-01.json new file mode 100644 index 00000000..f0197538 --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-simple-01.json @@ -0,0 +1,20 @@ +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0 + }, + { + "time": 10.0, + "x": 0.0, + "y": 0.0, + "easing": "cubic-in-out" + }, + { + "time": 20.0, + "x": 640.0, + "y": 480.0, + "easing": "cubic-in-out" + } + +] \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-simple-02.json b/orx-keyframer/src/demo/resources/demo-simple-02.json new file mode 100644 index 00000000..eed5716c --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-simple-02.json @@ -0,0 +1,32 @@ +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0, + "radius": 0.0, + "r": 1.0, + "g": 1.0, + "b": 1.0 + }, + { + "time": 5.0, + "radius": 200.0, + "r": 0.0 + }, + { + "time": 10.0, + "g": 0.0, + "x": 0.0, + "y": 0.0, + "easing": "cubic-in-out" + }, + { + "time": 20.0, + "x": 640.0, + "y": 480.0, + "radius": 50.0, + "easing": "cubic-in-out", + "g": 1.0, + "b": 0.0 + } +] \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-simple-expressions-01.json b/orx-keyframer/src/demo/resources/demo-simple-expressions-01.json new file mode 100644 index 00000000..d216cb91 --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-simple-expressions-01.json @@ -0,0 +1,30 @@ +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0, + "radius": 0.0 + }, + { + "time": 3.0, + "repeat": { + "count": 5, + "keys": [ + { + "duration": "cycleDuration * 0.5", + "easing": "cubic-in-out", + "x": 10.0, + "y": 4.0, + "radius": 400 + }, + { + "duration": "cycleDuration * 0.5", + "easing": "cubic-in-out", + "x": 630.0, + "y": 470.0, + "radius": 40 + } + ] + } + } +] \ No newline at end of file diff --git a/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json b/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json new file mode 100644 index 00000000..72100ef3 --- /dev/null +++ b/orx-keyframer/src/demo/resources/demo-simple-repetitions-01.json @@ -0,0 +1,30 @@ +[ + { + "time": 0.0, + "x": 320.0, + "y": 240.0, + "radius": 0.0 + }, + { + "time": 3.0, + "repeat": { + "count": 5, + "keys": [ + { + "duration": 1.0, + "easing": "cubic-in-out", + "x": 10.0, + "y": 4.0, + "radius": 400 + }, + { + "duration": 1.0, + "easing": "cubic-in-out", + "x": 630.0, + "y": 470.0, + "radius": 40 + } + ] + } + } +] \ No newline at end of file diff --git a/orx-keyframer/src/main/antlr/KeyLangLexer.g4 b/orx-keyframer/src/main/antlr/KeyLangLexer.g4 new file mode 100644 index 00000000..d4e35840 --- /dev/null +++ b/orx-keyframer/src/main/antlr/KeyLangLexer.g4 @@ -0,0 +1,90 @@ +lexer grammar KeyLangLexer; + +@header { +package org.openrndr.extra.keyframer.antlr; +} + +channels { WHITESPACE } + +// Whitespace +NEWLINE : '\r\n' | '\r' | '\n' ; +WS : [\t ]+ -> channel(WHITESPACE) ; + +// Keywords +INPUT : 'input' ; +VAR : 'var' ; +PRINT : 'print'; +AS : 'as'; +INT : 'Int'; +DECIMAL : 'Decimal'; +STRING : 'String'; + +// Identifiers +ID : [_]*[a-zA-Z][A-Za-z0-9_]* ; +FUNCTION_ID : [_]*[a-z][A-Za-z0-9_]* ; + +// Literals + +DECLIT : [0-9][0-9]* '.' [0-9]+ ; +INTLIT : '0'|[0-9][0-9]* ; + +// Operators +PLUS : '+' ; +PERCENTAGE : '%' ; +MINUS : '-' ; +ASTERISK : '*' ; +DIVISION : '/' ; +ASSIGN : '=' ; +LPAREN : '(' ; +RPAREN : ')' ; + +COMMA : ',' ; + +STRING_OPEN : '"' -> pushMode(MODE_IN_STRING); + +UNMATCHED : . ; + +mode MODE_IN_STRING; + +ESCAPE_STRING_DELIMITER : '\\"' ; +ESCAPE_SLASH : '\\\\' ; +ESCAPE_NEWLINE : '\\n' ; +ESCAPE_SHARP : '\\#' ; +STRING_CLOSE : '"' -> popMode ; +INTERPOLATION_OPEN : '#{' -> pushMode(MODE_IN_INTERPOLATION) ; +STRING_CONTENT : ~["\n\r\t\\#]+ ; + +STR_UNMATCHED : . -> type(UNMATCHED) ; + +mode MODE_IN_INTERPOLATION; + +INTERPOLATION_CLOSE : '}' -> popMode ; + +INTERP_WS : [\t ]+ -> channel(WHITESPACE), type(WS) ; + +// Keywords +INTERP_AS : 'as'-> type(AS) ; +INTERP_INT : 'Int'-> type(INT) ; +INTERP_DECIMAL : 'Decimal'-> type(DECIMAL) ; +INTERP_STRING : 'String'-> type(STRING) ; + +// Literals +INTERP_INTLIT : ('0'|[1-9][0-9]*) -> type(INTLIT) ; +INTERP_DECLIT : ('0'|[1-9][0-9]*) '.' [0-9]+ -> type(DECLIT) ; + +// Operators +INTERP_PLUS : '+' -> type(PLUS) ; +INTERP_MINUS : '-' -> type(MINUS) ; +INTERP_ASTERISK : '*' -> type(ASTERISK) ; +INTERP_DIVISION : '/' -> type(DIVISION) ; +INTERP_PERCENTAGE : '%' -> type(PERCENTAGE) ; +INTERP_ASSIGN : '=' -> type(ASSIGN) ; +INTERP_LPAREN : '(' -> type(LPAREN) ; +INTERP_RPAREN : ')' -> type(RPAREN) ; + +// Identifiers +INTERP_ID : [_]*[a-z][A-Za-z0-9_]* -> type(ID); + +INTERP_STRING_OPEN : '"' -> type(STRING_OPEN), pushMode(MODE_IN_STRING); + +INTERP_UNMATCHED : . -> type(UNMATCHED) ; diff --git a/orx-keyframer/src/main/antlr/KeyLangParser.g4 b/orx-keyframer/src/main/antlr/KeyLangParser.g4 new file mode 100644 index 00000000..93d5acb6 --- /dev/null +++ b/orx-keyframer/src/main/antlr/KeyLangParser.g4 @@ -0,0 +1,46 @@ + +parser grammar KeyLangParser; + +@header { +package org.openrndr.extra.keyframer.antlr; +} + +options { tokenVocab=KeyLangLexer; } + +miniCalcFile : lines=line+ ; + +line : statement (NEWLINE | EOF) ; + +statement : inputDeclaration # inputDeclarationStatement + | varDeclaration # varDeclarationStatement + | assignment # assignmentStatement + | print # printStatement + | expression # expressionStatement ; + +print : PRINT LPAREN expression RPAREN ; + +inputDeclaration : INPUT type name=ID ; + +varDeclaration : VAR assignment ; + +assignment : ID ASSIGN expression ; + +expression : INTLIT # intLiteral + | DECLIT # decimalLiteral + | ID LPAREN RPAREN # functionCall0Expression + | ID LPAREN expression RPAREN # functionCall1Expression + | ID LPAREN expression COMMA expression RPAREN # functionCall2Expression + | ID LPAREN expression COMMA expression COMMA expression RPAREN # functionCall3Expression + | ID LPAREN expression COMMA expression COMMA expression COMMA expression RPAREN # functionCall4Expression + | ID LPAREN expression COMMA expression COMMA expression COMMA expression COMMA expression RPAREN # functionCall5Expression + | ID # valueReference + | LPAREN expression RPAREN # parenExpression + | MINUS expression # minusExpression + | expression operator=(DIVISION|ASTERISK|PERCENTAGE) expression # binaryOperation1 + | expression operator=(PLUS|MINUS) expression # binaryOperation2; + +type : DECIMAL # decimal + | INT # integer + | STRING # string ; + + diff --git a/orx-keyframer/src/main/kotlin/Expressions.kt b/orx-keyframer/src/main/kotlin/Expressions.kt new file mode 100644 index 00000000..60d09784 --- /dev/null +++ b/orx-keyframer/src/main/kotlin/Expressions.kt @@ -0,0 +1,398 @@ +package org.openrndr.extra.keyframer + +import org.antlr.v4.runtime.* +import org.antlr.v4.runtime.tree.ParseTreeWalker +import org.antlr.v4.runtime.tree.TerminalNode +import org.openrndr.extra.keyframer.antlr.* +import org.openrndr.extra.noise.uniform +import org.openrndr.math.map +import org.openrndr.math.mix +import org.openrndr.math.mod +import org.openrndr.math.smoothstep +import java.util.* +import kotlin.math.* + +typealias Function0 = () -> Double +typealias Function1 = (Double) -> Double +typealias Function2 = (Double, Double) -> Double +typealias Function3 = (Double, Double, Double) -> Double +typealias Function4 = (Double, Double, Double, Double) -> Double +typealias Function5 = (Double, Double, Double, Double, Double) -> Double + +class FunctionExtensions( + val functions0: Map = emptyMap(), + val functions1: Map = emptyMap(), + val functions2: Map = emptyMap(), + val functions3: Map = emptyMap(), + val functions4: Map = emptyMap(), + val functions5: Map = emptyMap() +) { + companion object { + val EMPTY = FunctionExtensions() + } +} + +internal enum class IDType { + VARIABLE, + FUNCTION0, + FUNCTION1, + FUNCTION2, + FUNCTION3, + FUNCTION4, + FUNCTION5 +} + +internal class ExpressionListener(val functions: FunctionExtensions = FunctionExtensions.EMPTY) : + KeyLangParserBaseListener() { + val doubleStack = Stack() + val functionStack = Stack<(DoubleArray) -> Double>() + val variables = mutableMapOf() + + val idTypeStack = Stack() + var lastExpressionResult: Double? = null + + val exceptionStack = Stack() + + + override fun exitExpressionStatement(ctx: KeyLangParser.ExpressionStatementContext) { + ifError { + throw ExpressionException("error in evaluation of '${ctx.text}': ${it.message ?: ""}") + } + val result = doubleStack.pop() + lastExpressionResult = result + } + + override fun exitAssignment(ctx: KeyLangParser.AssignmentContext) { + val value = doubleStack.pop() + variables[ctx.ID()?.text ?: error("buh")] = value + } + + override fun exitMinusExpression(ctx: KeyLangParser.MinusExpressionContext) { + super.exitMinusExpression(ctx) + } + + override fun exitBinaryOperation1(ctx: KeyLangParser.BinaryOperation1Context) { + ifError { + pushError(it.message ?: "") + return + } + + val right = doubleStack.pop() + val left = doubleStack.pop() + val result = when (val operator = ctx.operator?.type) { + KeyLangParser.PLUS -> left + right + KeyLangParser.MINUS -> left - right + KeyLangParser.ASTERISK -> left * right + KeyLangParser.DIVISION -> left / right + KeyLangParser.PERCENTAGE -> mod(left, right) + else -> error("operator '$operator' not implemented") + } + doubleStack.push(result) + } + + override fun exitBinaryOperation2(ctx: KeyLangParser.BinaryOperation2Context) { + ifError { + pushError(it.message ?: "") + return + } + + val left = doubleStack.pop() + val right = doubleStack.pop() + val result = when (val operator = ctx.operator?.type) { + KeyLangParser.PLUS -> left + right + KeyLangParser.MINUS -> right - left + KeyLangParser.ASTERISK -> left * right + KeyLangParser.DIVISION -> left / right + else -> error("operator '$operator' not implemented") + } + doubleStack.push(result) + } + + override fun enterValueReference(ctx: KeyLangParser.ValueReferenceContext) { + idTypeStack.push(IDType.VARIABLE) + } + + override fun enterFunctionCall0Expression(ctx: KeyLangParser.FunctionCall0ExpressionContext) { + idTypeStack.push(IDType.FUNCTION0) + } + + override fun exitFunctionCall0Expression(ctx: KeyLangParser.FunctionCall0ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val result = function.invoke(doubleArrayOf()) + doubleStack.push(result) + } + + override fun enterFunctionCall1Expression(ctx: KeyLangParser.FunctionCall1ExpressionContext) { + idTypeStack.push(IDType.FUNCTION1) + } + + override fun exitFunctionCall1Expression(ctx: KeyLangParser.FunctionCall1ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val argument = doubleStack.pop() + + val result = function.invoke(doubleArrayOf(argument)) + doubleStack.push(result) + } + + override fun enterFunctionCall2Expression(ctx: KeyLangParser.FunctionCall2ExpressionContext) { + idTypeStack.push(IDType.FUNCTION2) + } + + override fun exitFunctionCall2Expression(ctx: KeyLangParser.FunctionCall2ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val argument1 = doubleStack.pop() + val argument0 = doubleStack.pop() + + val result = function.invoke(doubleArrayOf(argument0, argument1)) + doubleStack.push(result) + } + + override fun enterFunctionCall3Expression(ctx: KeyLangParser.FunctionCall3ExpressionContext) { + idTypeStack.push(IDType.FUNCTION3) + } + + override fun exitFunctionCall3Expression(ctx: KeyLangParser.FunctionCall3ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val argument2 = doubleStack.pop() + val argument1 = doubleStack.pop() + val argument0 = doubleStack.pop() + + val result = function.invoke(doubleArrayOf(argument0, argument1, argument2)) + doubleStack.push(result) + } + + override fun enterFunctionCall4Expression(ctx: KeyLangParser.FunctionCall4ExpressionContext) { + idTypeStack.push(IDType.FUNCTION4) + } + + override fun exitFunctionCall4Expression(ctx: KeyLangParser.FunctionCall4ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val argument3 = doubleStack.pop() + val argument2 = doubleStack.pop() + val argument1 = doubleStack.pop() + val argument0 = doubleStack.pop() + + val result = function.invoke(doubleArrayOf(argument0, argument1, argument2, argument3)) + doubleStack.push(result) + } + + + override fun enterFunctionCall5Expression(ctx: KeyLangParser.FunctionCall5ExpressionContext) { + idTypeStack.push(IDType.FUNCTION5) + } + + override fun exitFunctionCall5Expression(ctx: KeyLangParser.FunctionCall5ExpressionContext) { + ifError { + pushError(it.message ?: "") + return + } + + val function = functionStack.pop() + val argument4 = doubleStack.pop() + val argument3 = doubleStack.pop() + val argument2 = doubleStack.pop() + val argument1 = doubleStack.pop() + val argument0 = doubleStack.pop() + + val result = function.invoke(doubleArrayOf(argument0, argument1, argument2, argument3, argument4)) + doubleStack.push(result) + } + + private fun errorValue(message: String, value: T): T { + pushError(message) + return value + } + + private fun pushError(message: String) { + exceptionStack.push(ExpressionException(message)) + } + + private inline fun ifError(f: (e: Throwable) -> Unit) { + if (exceptionStack.isNotEmpty()) { + val e = exceptionStack.pop() + f(e) + } + } + + override fun visitTerminal(node: TerminalNode) { + val type = node.symbol?.type + if (type == KeyLangParser.INTLIT) { + doubleStack.push(node.text.toDouble()) + } + if (type == KeyLangParser.DECLIT) { + doubleStack.push(node.text.toDouble()) + } + if (type == KeyLangParser.ID) { + + @Suppress("DIVISION_BY_ZERO") + when (val idType = idTypeStack.pop()) { + IDType.VARIABLE -> doubleStack.push( + when (val name = node.text) { + "PI" -> PI + else -> variables[name] ?: errorValue("unresolved variable: '${name}'", 0.0 / 0.0) + } + ) + + IDType.FUNCTION0 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + "random" -> { _ -> Double.uniform(0.0, 1.0) } + else -> functions.functions0[candidate]?.let { { _:DoubleArray -> it.invoke() } } + ?: errorValue( + "unresolved function: '${candidate}()'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + + IDType.FUNCTION1 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + "sqrt" -> { x -> sqrt(x[0]) } + "radians" -> { x -> Math.toRadians(x[0]) } + "degrees" -> { x -> Math.toDegrees(x[0]) } + "cos" -> { x -> cos(x[0]) } + "sin" -> { x -> sin(x[0]) } + "tan" -> { x -> tan(x[0]) } + "atan" -> { x -> atan(x[0]) } + "acos" -> { x -> acos(x[0]) } + "asin" -> { x -> asin(x[0]) } + "exp" -> { x -> exp(x[0]) } + "abs" -> { x -> abs(x[0]) } + "floor" -> { x -> floor(x[0]) } + "ceil" -> { x -> ceil(x[0]) } + "saturate" -> { x -> x[0].coerceIn(0.0, 1.0) } + else -> functions.functions1[candidate]?.let { { x:DoubleArray -> it.invoke(x[0]) } } + ?: errorValue( + "unresolved function: '${candidate}(x0)'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + IDType.FUNCTION2 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + "max" -> { x -> max(x[0], x[1]) } + "min" -> { x -> min(x[0], x[1]) } + "pow" -> { x -> x[0].pow(x[1]) } + "atan2" -> { x -> atan2(x[0], x[1]) } + "random" -> { x -> Double.uniform(x[0], x[1]) } + else -> functions.functions2[candidate]?.let { { x:DoubleArray -> it.invoke(x[0], x[1]) } } + ?: errorValue( + "unresolved function: '${candidate}(x0, x1)'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + IDType.FUNCTION3 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + "mix" -> { x -> mix(x[0], x[1], x[2]) } + "smoothstep" -> { x -> smoothstep(x[0], x[1], x[2]) } + else -> functions.functions3[candidate]?.let { { x:DoubleArray -> it.invoke(x[0], x[1], x[2]) } } + ?: errorValue( + "unresolved function: '${candidate}(x0, x1, x2)'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + IDType.FUNCTION4 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + else -> functions.functions4[candidate]?.let { { x:DoubleArray -> it.invoke(x[0], x[1], x[2], x[3]) } } + ?: errorValue( + "unresolved function: '${candidate}(x0, x1, x2, x3)'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + + IDType.FUNCTION5 -> { + val function: (DoubleArray) -> Double = + when (val candidate = node.text) { + "map" -> { x -> map(x[0], x[1], x[2], x[3], x[4]) } + else -> functions.functions5[candidate]?.let { { x:DoubleArray -> it.invoke(x[0], x[1], x[2], x[3], x[4]) } } + ?: errorValue( + "unresolved function: '${candidate}(x0, x1, x2, x3, x4)'" + ) { _ -> error("this is the error function") } + } + functionStack.push(function) + } + else -> error("unsupported id-type $idType") + } + } + } +} + +class ExpressionException(message: String) : RuntimeException(message) + +fun evaluateExpression( + input: String, + variables: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY +): Double? { + val lexer = KeyLangLexer(CharStreams.fromString(input)) +// +// lexer.removeErrorListeners() +// lexer.addErrorListener(object : BaseErrorListener() { +// override fun syntaxError( +// recognizer: Recognizer<*, *>?, +// offendingSymbol: Any?, +// line: Int, +// charPositionInLine: Int, +// msg: String?, +// e: RecognitionException? +// ) { +// println("syntax error!") +// } +// }) + val parser = KeyLangParser(CommonTokenStream(lexer)) + parser.removeErrorListeners() + parser.addErrorListener(object : BaseErrorListener() { + override fun syntaxError( + recognizer: Recognizer<*, *>?, + offendingSymbol: Any?, + line: Int, + charPositionInLine: Int, + msg: String?, + e: RecognitionException? + ) { + throw ExpressionException("parser error in expression: '$input'; [line: $line, character: $charPositionInLine ${offendingSymbol?.let { ", near: $it" } ?: ""} ]") + } + }) + + val root = parser.miniCalcFile() + val listener = ExpressionListener(functions) + listener.variables.putAll(variables) + try { + ParseTreeWalker.DEFAULT.walk(listener, root) + } catch (e: ExpressionException) { + throw ExpressionException(e.message ?: "") + } + return listener.lastExpressionResult +} \ No newline at end of file diff --git a/orx-keyframer/src/main/kotlin/Key.kt b/orx-keyframer/src/main/kotlin/Key.kt new file mode 100644 index 00000000..6569c40a --- /dev/null +++ b/orx-keyframer/src/main/kotlin/Key.kt @@ -0,0 +1,68 @@ +package org.openrndr.extra.keyframer + +import org.openrndr.extras.easing.Easing +import org.openrndr.extras.easing.EasingFunction + +class Key(val time: Double, val value: Double, val easing: EasingFunction) + +enum class Hold { + HoldNone, + HoldSet, + HoldAll +} + +class KeyframerChannel { + val keys = mutableListOf() + + operator fun invoke() : Double { + return 0.0 + } + + fun add(time: Double, value: Double?, easing: EasingFunction = Easing.Linear.function, jump: Hold = Hold.HoldNone) { + if (jump == Hold.HoldAll || (jump == Hold.HoldSet && value != null)) { + lastValue()?.let { + keys.add(Key(time, it, Easing.Linear.function)) + } + } + value?.let { + keys.add(Key(time, it, easing)) + } + } + + fun lastValue(): Double? { + return keys.lastOrNull()?.value + } + + fun duration(): Double { + return keys.last().time + } + + fun value(time: Double): Double? { + if (keys.size == 0) { + return null + } + if (keys.size == 1) { + return if (time < keys.first().time) { + null + } else { + keys[0].value + } + } + + if (time < keys.first().time) { + return null + } + + val rightIndex = keys.indexOfFirst { it.time > time } + return if (rightIndex == -1) { + keys.last().value + } else { + val leftIndex = (rightIndex - 1).coerceAtLeast(0) + val rightKey = keys[rightIndex] + val leftKey = keys[leftIndex] + val t0 = (time - leftKey.time) / (rightKey.time - leftKey.time) + val e0 = rightKey.easing(t0, 0.0, 1.0, 1.0) + leftKey.value * (1.0 - e0) + rightKey.value * (e0) + } + } +} \ No newline at end of file diff --git a/orx-keyframer/src/main/kotlin/Keyframer.kt b/orx-keyframer/src/main/kotlin/Keyframer.kt new file mode 100644 index 00000000..1d3a2528 --- /dev/null +++ b/orx-keyframer/src/main/kotlin/Keyframer.kt @@ -0,0 +1,375 @@ +package org.openrndr.extra.keyframer + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import org.openrndr.color.ColorRGBa +import org.openrndr.extras.easing.Easing +import org.openrndr.math.Vector2 +import org.openrndr.math.Vector3 +import org.openrndr.math.Vector4 +import java.io.File +import java.lang.IllegalStateException +import java.net.URL +import kotlin.math.roundToInt +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + + +enum class KeyframerFormat { + SIMPLE, + FULL +} + + + +open class Keyframer { + private var currentTime = 0.0 + operator fun invoke(time: Double) { + currentTime = time + } + + open inner class CompoundChannel(val keys: Array, private val defaultValues: Array) { + private var channelTimes: Array = Array(keys.size) { Double.NEGATIVE_INFINITY } + private var compoundChannels: Array = Array(keys.size) { null } + private var cachedValues: Array = Array(keys.size) { null } + + open fun reset() { + for (i in channelTimes.indices) { + channelTimes[i] = Double.NEGATIVE_INFINITY + } + } + + fun getValue(compound: Int): Double { + if (compoundChannels[compound] == null) { + compoundChannels[compound] = channels[keys[compound]] + } + return if (compoundChannels[compound] != null) { + if (channelTimes[compound] == currentTime && cachedValues[compound] != null) { + cachedValues[compound] ?: defaultValues[compound] + } else { + val value = compoundChannels[compound]?.value(currentTime) ?: defaultValues[compound] + cachedValues[compound] = value + value + } + } else { + defaultValues[compound] + } + } + } + + val duration: Double + get() = channels.values.maxBy { it.duration() }?.duration() ?: 0.0 + + + inner class DoubleChannel(key: String, defaultValue: Double = 0.0) : + CompoundChannel(arrayOf(key), arrayOf(defaultValue)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): Double = getValue(0) + } + + inner class Vector2Channel(keys: Array, defaultValue: Vector2 = Vector2.ZERO) : + CompoundChannel(keys, arrayOf(defaultValue.x, defaultValue.y)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): Vector2 = Vector2(getValue(0), getValue(1)) + } + + inner class Vector3Channel(keys: Array, defaultValue: Vector3 = Vector3.ZERO) : + CompoundChannel(keys, arrayOf(defaultValue.x, defaultValue.y, defaultValue.z)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): Vector3 = + Vector3(getValue(0), getValue(1), getValue(2)) + } + + inner class Vector4Channel(keys: Array, defaultValue: Vector4 = Vector4.ZERO) : + CompoundChannel(keys, arrayOf(defaultValue.x, defaultValue.y, defaultValue.z, defaultValue.w)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): Vector4 = + Vector4(getValue(0), getValue(1), getValue(2), getValue(3)) + } + + inner class RGBaChannel(keys: Array, defaultValue: ColorRGBa = ColorRGBa.WHITE) : + CompoundChannel(keys, arrayOf(defaultValue.r, defaultValue.g, defaultValue.b, defaultValue.a)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): ColorRGBa = + ColorRGBa(getValue(0), getValue(1), getValue(2), getValue(3)) + } + + inner class RGBChannel(keys: Array, defaultValue: ColorRGBa = ColorRGBa.WHITE) : + CompoundChannel(keys, arrayOf(defaultValue.r, defaultValue.g, defaultValue.b)) { + operator fun getValue(keyframer: Keyframer, property: KProperty<*>): ColorRGBa = + ColorRGBa(getValue(0), getValue(1), getValue(2)) + } + + val channels = mutableMapOf() + + fun loadFromJson( + file: File, + format: KeyframerFormat = KeyframerFormat.SIMPLE, + parameters: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY + ) { + require(file.exists()) { + "failed to load keyframer from json: '${file.absolutePath}' does not exist." + } + try { + loadFromJsonString(file.readText(), format, parameters, functions) + } catch (e: ExpressionException) { + throw ExpressionException("Error loading from '${file.path}': ${e.message ?: ""}") + } + } + + fun loadFromJson( + url: URL, + format: KeyframerFormat = KeyframerFormat.SIMPLE, + parameters: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY + ) { + try { + loadFromJsonString(url.readText(), format, parameters, functions) + } catch (e: ExpressionException) { + throw ExpressionException("Error loading $format from '${url}': ${e.message ?: ""}") + } catch(e: IllegalStateException) { + throw ExpressionException("Error loading $format from '${url}': ${e.message ?: ""}") + } + } + + fun loadFromJsonString( + json: String, + format: KeyframerFormat = KeyframerFormat.SIMPLE, + parameters: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY + ) { + when (format) { + KeyframerFormat.SIMPLE -> { + try { + val type = object : TypeToken>>() {}.type + val keys: List> = Gson().fromJson(json, type) + loadFromKeyObjects(keys, parameters, functions) + } catch (e: JsonSyntaxException) { + error("Error parsing simple Keyframer data: ${e.cause?.message}") + } + } + KeyframerFormat.FULL -> { + try { + val type = object : TypeToken>() {}.type + val keys: Map = Gson().fromJson(json, type) + loadFromObjects(keys, parameters, functions) + } catch (e: JsonSyntaxException) { + error("Error parsing full Keyframer data: ${e.cause?.message}") + } + } + } + } + + private val parameters = mutableMapOf() + private val prototypes = mutableMapOf>() + + fun loadFromObjects( + dict: Map, + externalParameters: Map = emptyMap(), + functions: FunctionExtensions = FunctionExtensions.EMPTY + ) { + this.parameters.clear() + this.parameters.putAll(externalParameters) + + prototypes.clear() + @Suppress("UNCHECKED_CAST") + (dict["parameters"] as? Map)?.let { lp -> + for (entry in lp) { + this.parameters[entry.key] = try { + when (val candidate = entry.value) { + is Double -> candidate + is String -> evaluateExpression(candidate, parameters, functions) + ?: error("could not evaluate expression: '$candidate'") + is Int -> candidate.toDouble() + is Float -> candidate.toDouble() + else -> error("unknown type for parameter '${entry.key}'") + } + } catch (e: ExpressionException) { + throw ExpressionException("error in 'parameters': ${e.message ?: ""} ") + } + } + } + this.parameters.putAll(externalParameters) + + @Suppress("UNCHECKED_CAST") + (dict["prototypes"] as? Map>)?.let { + prototypes.putAll(it) + } + + @Suppress("UNCHECKED_CAST") + (dict["keys"] as? List>)?.let { keys -> + loadFromKeyObjects(keys, parameters, functions) + } + } + + private fun resolvePrototype(prototypeNames: String): Map { + val prototypeTokens = prototypeNames.split(" ").map { it.trim() }.filter { it.isNotBlank() } + val prototypeRefs = prototypeTokens.mapNotNull { prototypes[it] } + + val computed = mutableMapOf() + for (ref in prototypeRefs) { + computed.putAll(ref) + } + return computed + } + + fun loadFromKeyObjects( + keys: List>, + externalParameters: Map, + functions: FunctionExtensions + ) { + if (externalParameters !== parameters) { + parameters.clear() + parameters.putAll(externalParameters) + } + + var lastTime = 0.0 + + val channelDelegates = this::class.memberProperties + .mapNotNull { + @Suppress("UNCHECKED_CAST") + it as? KProperty1 + } + .filter { it.isAccessible = true; it.getDelegate(this) is CompoundChannel } + .associate { Pair(it.name, it.getDelegate(this) as CompoundChannel) } + + val channelKeys = channelDelegates.values.flatMap { channel -> + channel.keys.map { it } + }.toSet() + + for (delegate in channelDelegates.values) { + delegate.reset() + } + + val expressionContext = mutableMapOf() + expressionContext.putAll(parameters) + expressionContext["t"] = 0.0 + + + fun handleKey(key: Map, path: String) { + + val prototype = (key["prototypes"] as? String)?.let { + resolvePrototype(it) + } ?: emptyMap() + + val computed = mutableMapOf() + computed.putAll(prototype) + computed.putAll(key) + + val time = try { + when (val candidate = computed["time"]) { + null -> lastTime + is String -> evaluateExpression(candidate, expressionContext, functions) + ?: error { "unknown value format for time : $candidate" } + is Double -> candidate + is Int -> candidate.toDouble() + is Float -> candidate.toDouble() + else -> error("unknown time format for '$candidate'") + } + } catch (e: ExpressionException) { + throw ExpressionException("error in $path.'time': ${e.message ?: ""}") + } + + val duration = try { + when (val candidate = computed["duration"]) { + null -> 0.0 + is String -> evaluateExpression(candidate, expressionContext, functions) + ?: error { "unknown value format for time : $candidate" } + is Int -> candidate.toDouble() + is Float -> candidate.toDouble() + is Double -> candidate + else -> error("unknown duration type for '$candidate") + } + } catch (e: ExpressionException) { + throw ExpressionException("error in $path.'duration': ${e.message ?: ""}") + } + + val easing = try { + when (val easingCandidate = computed["easing"]) { + null -> Easing.Linear.function + is String -> when (easingCandidate) { + "linear" -> Easing.Linear.function + "cubic-in" -> Easing.CubicIn.function + "cubic-out" -> Easing.CubicOut.function + "cubic-in-out" -> Easing.CubicInOut.function + "quad-in" -> Easing.QuadIn.function + "quad-out" -> Easing.QuadOut.function + "quad-in-out" -> Easing.QuadInOut.function + "quart-in" -> Easing.QuartIn.function + "quart-out" -> Easing.QuartOut.function + "quart-in-out" -> Easing.QuartInOut.function + "quint-in" -> Easing.QuintIn.function + "quint-out" -> Easing.QuintOut.function + "quint-in-out" -> Easing.QuintInOut.function + "expo-in" -> Easing.ExpoIn.function + "expo-out" -> Easing.ExpoOut.function + "expo-in-out" -> Easing.ExpoInOut.function + "one" -> Easing.One.function + "zero" -> Easing.Zero.function + else -> error("unknown easing name '$easingCandidate'") + } + else -> error("unknown easing for '$easingCandidate'") + } + } catch (e: IllegalStateException) { + throw ExpressionException("error in $path.'easing': ${e.message ?: ""}") + } + + val hold = Hold.HoldNone + + val reservedKeys = setOf("time", "easing", "hold") + + for (channelCandidate in computed.filter { it.key !in reservedKeys }) { + if (channelCandidate.key in channelKeys) { + val channel = channels.getOrPut(channelCandidate.key) { + KeyframerChannel() + } + expressionContext["v"] = channel.lastValue() ?: 0.0 + val value = try { + when (val candidate = channelCandidate.value) { + is Double -> candidate + is String -> evaluateExpression(candidate, expressionContext, functions) + ?: error("unknown value format for key '${channelCandidate.key}' : $candidate") + is Int -> candidate.toDouble() + else -> error("unknown value type for key '${channelCandidate.key}' : $candidate") + } + } catch (e: ExpressionException) { + throw ExpressionException("error in $path.'${channelCandidate.key}': ${e.message ?: ""}") + } + channel.add(time, value, easing, hold) + } + } + lastTime = time + duration + expressionContext["t"] = lastTime + + if (computed.containsKey("repeat")) { + @Suppress("UNCHECKED_CAST") + val repeatObject = computed["repeat"] as? Map ?: error("'repeat' should be a map") + val count = try { + when (val candidate = repeatObject["count"]) { + null -> 1 + is Int -> candidate + is Double -> candidate.toInt() + is String -> evaluateExpression(candidate, expressionContext, functions)?.roundToInt() + ?: error("cannot evaluate expression for count: '$candidate'") + else -> error("unknown value type for count: '$candidate") + } + } catch (e: ExpressionException) { + throw ExpressionException("error in $path.repeat.'count': ${e.message ?: ""}") + } + + @Suppress("UNCHECKED_CAST") + val repeatKeys = repeatObject["keys"] as? List> ?: error("no repeat keys") + + for (i in 0 until count) { + expressionContext["rep"] = i.toDouble() + for (repeatKey in repeatKeys) { + handleKey(repeatKey, "$path.repeat") + } + } + } + } + + for ((index, key) in keys.withIndex()) { + handleKey(key, "keys[$index]") + } + } +} diff --git a/orx-keyframer/src/test/kotlin/TestExpressionErrors.kt b/orx-keyframer/src/test/kotlin/TestExpressionErrors.kt new file mode 100644 index 00000000..5dd68919 --- /dev/null +++ b/orx-keyframer/src/test/kotlin/TestExpressionErrors.kt @@ -0,0 +1,57 @@ +import org.amshove.kluent.`should throw` +import org.amshove.kluent.`with message` +import org.amshove.kluent.invoking +import org.openrndr.extra.keyframer.ExpressionException +import org.openrndr.extra.keyframer.evaluateExpression +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe +import java.lang.IllegalStateException + +object TestExpressionErrors : Spek({ + + describe("an expression with non-sensible writing") { + val expression = ")(" + it("should cause an exception to be thrown when evaluated") { + invoking { + evaluateExpression(expression) + } `should throw` ExpressionException::class `with message` "parser error in expression: ')('; [line: 1, character: 0 , near: [@0,0:0=')',<21>,1:0] ]" + } + } + + describe("an expression with equality instead of assign") { + val expression = "a == 5" + it("should cause an exception to be thrown when evaluated") { + invoking { + evaluateExpression(expression) + } `should throw` ExpressionException::class `with message` "parser error in expression: 'a == 5'; [line: 1, character: 3 , near: [@3,3:3='=',<19>,1:3] ]" + } + } + + describe("an expression trying to reassign a number") { + val expression = "3 = 5" + it("should cause an exception to be thrown when evaluated") { + invoking { + evaluateExpression(expression) + } `should throw` ExpressionException::class `with message` "parser error in expression: '3 = 5'; [line: 1, character: 2 , near: [@2,2:2='=',<19>,1:2] ]" + } + } + + describe("an expression that uses non-existing functions") { + val expression = "notExisting(5)" + it("should cause an exception to be thrown when evaluated") { + invoking { + evaluateExpression(expression) + } `should throw` ExpressionException::class `with message` "error in evaluation of 'notExisting(5)': unresolved function: 'notExisting(x0)'" + } + } + + describe("an expression that uses non-existing variables") { + val expression = "notExisting + 4" + it("should cause an exception to be thrown when evaluated") { + invoking { + evaluateExpression(expression) + } `should throw` ExpressionException::class `with message` "error in evaluation of 'notExisting+4': unresolved variable: 'notExisting'" + } + } + +}) diff --git a/orx-keyframer/src/test/kotlin/TestFunctionCall.kt b/orx-keyframer/src/test/kotlin/TestFunctionCall.kt new file mode 100644 index 00000000..2bfb6455 --- /dev/null +++ b/orx-keyframer/src/test/kotlin/TestFunctionCall.kt @@ -0,0 +1,92 @@ +import org.amshove.kluent.shouldBeNear +import org.openrndr.extra.keyframer.FunctionExtensions +import org.openrndr.extra.keyframer.evaluateExpression +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object TestFunctionCall : Spek({ + describe("a function call") { + val expression = "sqrt(4.0)" + val result = evaluateExpression(expression) + result?.shouldBeNear(2.0, 10E-6) + } + + describe("two function calls") { + val expression = "sqrt(4.0) * sqrt(4.0)" + val result = evaluateExpression(expression) + result?.shouldBeNear(4.0, 10E-6) + } + + describe("two argument function call") { + val expression = "max(0.0, 4.0)" + val result = evaluateExpression(expression) + result?.shouldBeNear(4.0, 10E-6) + } + + describe("two argument function call") { + val expression = "min(8.0, 4.0)" + val result = evaluateExpression(expression) + result?.shouldBeNear(4.0, 10E-6) + } + + describe("three argument function call") { + val expression = "mix(8.0, 4.0, 0.5)" + val result = evaluateExpression(expression) + result?.shouldBeNear(6.0, 10E-6) + } + + describe("five argument function call") { + val expression = "map(0.0, 1.0, 0.0, 8.0, 0.5)" + val result = evaluateExpression(expression) + result?.shouldBeNear(4.0, 10E-6) + } + + describe("two argument function call, where argument order matters") { + val expression = "pow(2.0, 3.0)" + val result = evaluateExpression(expression) + result?.shouldBeNear(8.0, 10E-6) + } + + describe("nested function call") { + val expression = "sqrt(min(8.0, 4.0))" + val result = evaluateExpression(expression) + result?.shouldBeNear(2.0, 10E-6) + } + + describe("extension function0 call") { + val expression = "extension()" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions0 = mapOf("extension" to { 2.0 }))) + result?.shouldBeNear(2.0, 10E-6) + } + + describe("extension function1 call") { + val expression = "extension(1.0)" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions1 = mapOf("extension" to { x -> x * 2.0 }))) + result?.shouldBeNear(2.0, 10E-6) + } + + describe("extension function2 call") { + val expression = "extension(1.0, 1.0)" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions2 = mapOf("extension" to { x, y -> x + y }))) + result?.shouldBeNear(2.0, 10E-6) + } + + describe("extension function3 call") { + val expression = "extension(1.0, 1.0, 1.0)" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions3 = mapOf("extension" to { x, y, z -> x + y + z}))) + result?.shouldBeNear(3.0, 10E-6) + } + + describe("extension function4 call") { + val expression = "extension(1.0, 1.0, 1.0, 1.0)" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions4 = mapOf("extension" to { x, y, z, w -> x + y + z + w}))) + result?.shouldBeNear(4.0, 10E-6) + } + + describe("extension function5 call") { + val expression = "extension(1.0, 1.0, 1.0, 1.0, 1.0)" + val result = evaluateExpression(expression, functions = FunctionExtensions(functions5 = mapOf("extension" to { x, y, z, w, u -> x + y + z + w + u}))) + result?.shouldBeNear(5.0, 10E-6) + } + +}) \ No newline at end of file diff --git a/orx-keyframer/src/test/kotlin/TestKeyframerChannel.kt b/orx-keyframer/src/test/kotlin/TestKeyframerChannel.kt new file mode 100644 index 00000000..0977adec --- /dev/null +++ b/orx-keyframer/src/test/kotlin/TestKeyframerChannel.kt @@ -0,0 +1,35 @@ +import org.amshove.kluent.`should be` +import org.amshove.kluent.shouldBeNear +import org.openrndr.extra.keyframer.KeyframerChannel +import org.openrndr.extras.easing.Easing +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object TestKeyframerChannel : Spek({ + + describe("a keyframer channel without keys") { + val kfc = KeyframerChannel() + it ("should return null when asking for value before first key time") { + kfc.value(0.0) `should be` null + } + } + describe("a keyframer channel with a single key") { + val kfc = KeyframerChannel() + kfc.add(0.0, 1.0, Easing.Linear.function) + kfc.value(0.0)?.shouldBeNear(1.0, 10E-6) + + it ("should return null when asking for value before first key time") { + kfc.value(-1.0) `should be` null + } + } + describe("a keyframer channel with two keys") { + val kfc = KeyframerChannel() + kfc.add(0.0, 1.0, Easing.Linear.function) + kfc.add(1.0, 2.0, Easing.Linear.function) + kfc.value(0.0)?.shouldBeNear(1.0, 10E-6) + + it ("should return null when asking for value before first key time") { + kfc.value(-1.0) `should be` null + } + } +}) \ No newline at end of file diff --git a/orx-keyframer/src/test/kotlin/TestKeyframerErrors.kt b/orx-keyframer/src/test/kotlin/TestKeyframerErrors.kt new file mode 100644 index 00000000..e1e6e87b --- /dev/null +++ b/orx-keyframer/src/test/kotlin/TestKeyframerErrors.kt @@ -0,0 +1,105 @@ +import org.amshove.kluent.`should throw` +import org.amshove.kluent.`with message` +import org.amshove.kluent.invoking +import org.openrndr.extra.keyframer.ExpressionException +import org.openrndr.extra.keyframer.Keyframer +import org.openrndr.extra.keyframer.KeyframerFormat +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe +import java.io.File +import kotlin.IllegalStateException + + +private fun testFile(path: String) : File { + val test = File(".") + return if (test.absolutePath.endsWith("orx-keyframer/.")) { + File(path) + } else { + File("orx-keyframer/$path") + } +} +private fun testName(path: String) : String { + val test = File(".") + return if (test.absolutePath.endsWith("orx-keyframer/.")) { + path + } else { + "orx-keyframer/$path" + } +} + + +object TestKeyframerErrors : Spek({ + class Animation : Keyframer() { + val position by Vector2Channel(arrayOf("x", "y")) + } + + describe("loading a faulty json") { + val animation = Animation() + val json = """ + """ + it("should throw an exception") { + invoking { animation.loadFromJsonString(json) } `should throw` (IllegalStateException::class) + } + } + + describe("loading a non existing json") { + val animation = Animation() + it("should throw an exception") { + invoking { animation.loadFromJson(testFile("this-does-not-exist")) } `should throw` (IllegalArgumentException::class) + } + } + + describe("loading a json with a faulty time expression (1)") { + + File(".").apply { + println(this.absolutePath) + } + + val animation = Animation() + it("should throw an exception") { + invoking { + animation.loadFromJson( + testFile("src/test/resources/error-reporting/time-01.json"), + format = KeyframerFormat.SIMPLE + ) + } `should throw` ExpressionException::class `with message` "Error loading from '${testName("src/test/resources/error-reporting/time-01.json")}': error in keys[0].'time': parser error in expression: ')('; [line: 1, character: 0 , near: [@0,0:0=')',<21>,1:0] ]" + } + } + + describe("loading a json with a faulty time expression (2) ") { + val animation = Animation() + it("should throw an exception") { + invoking { + animation.loadFromJson( + testFile("src/test/resources/error-reporting/time-02.json"), + format = KeyframerFormat.SIMPLE + ) + } `should throw` ExpressionException::class `with message` "Error loading from '${testName("src/test/resources/error-reporting/time-02.json")}': error in keys[0].'time': error in evaluation of 'doesNotExist': unresolved variable: 'doesNotExist'" + } + } + + describe("loading a json with a non-existing easing") { + val animation = Animation() + it("should throw an exception") { + invoking { + animation.loadFromJson( + testFile("src/test/resources/error-reporting/easing.json"), + format = KeyframerFormat.SIMPLE + ) + } `should throw` ExpressionException::class `with message` "Error loading from '${testName("src/test/resources/error-reporting/easing.json")}': error in keys[0].'easing': unknown easing name 'garble'" + } + } + + describe("loading a json with a faulty value (1)") { + val animation = Animation() + + it("should throw an exception") { + invoking { + animation.loadFromJson( + testFile("src/test/resources/error-reporting/value-01.json"), + format = KeyframerFormat.SIMPLE + ) + } `should throw` ExpressionException::class `with message` "Error loading from '${testName("src/test/resources/error-reporting/value-01.json")}': error in keys[0].'x': error in evaluation of 'garble': unresolved variable: 'garble'" + } + } +}) \ No newline at end of file diff --git a/orx-keyframer/src/test/kotlin/TestOperators.kt b/orx-keyframer/src/test/kotlin/TestOperators.kt new file mode 100644 index 00000000..bf7e40a8 --- /dev/null +++ b/orx-keyframer/src/test/kotlin/TestOperators.kt @@ -0,0 +1,35 @@ +import org.amshove.kluent.shouldBeNear +import org.openrndr.extra.keyframer.evaluateExpression +import org.spekframework.spek2.Spek +import org.spekframework.spek2.style.specification.describe + +object TestOperators : Spek({ + describe("an addition operation") { + val result = evaluateExpression("1 + 2") + result?.shouldBeNear(3.0, 10E-6) + } + describe("a subtraction operation") { + val result = evaluateExpression("1 - 2") + result?.shouldBeNear(-1.0, 10E-6) + } + describe("a modulus operation") { + val result = evaluateExpression("4 % 2") + result?.shouldBeNear(0.0, 10E-6) + } + describe("a multiplication operation") { + val result = evaluateExpression("4 * 2") + result?.shouldBeNear(8.0, 10E-6) + } + describe("a division operation") { + val result = evaluateExpression("4 / 2") + result?.shouldBeNear(2.0, 10E-6) + } + describe("a multiplication/addition operation") { + val result = evaluateExpression("4 * 2 + 1") + result?.shouldBeNear(9.0, 10E-6) + } + describe("an addition/multiplication") { + val result = evaluateExpression("4 + 2 * 3") + result?.shouldBeNear(10.0, 10E-6) + } +}) diff --git a/orx-keyframer/src/test/resources/error-reporting/easing.json b/orx-keyframer/src/test/resources/error-reporting/easing.json new file mode 100644 index 00000000..4a8f64ca --- /dev/null +++ b/orx-keyframer/src/test/resources/error-reporting/easing.json @@ -0,0 +1,5 @@ +[ + { + "easing": "garble" + } +] \ No newline at end of file diff --git a/orx-keyframer/src/test/resources/error-reporting/time-01.json b/orx-keyframer/src/test/resources/error-reporting/time-01.json new file mode 100644 index 00000000..15abf1f2 --- /dev/null +++ b/orx-keyframer/src/test/resources/error-reporting/time-01.json @@ -0,0 +1,5 @@ +[ + { + "time": ")(" + } +] \ No newline at end of file diff --git a/orx-keyframer/src/test/resources/error-reporting/time-02.json b/orx-keyframer/src/test/resources/error-reporting/time-02.json new file mode 100644 index 00000000..5c5c4cdb --- /dev/null +++ b/orx-keyframer/src/test/resources/error-reporting/time-02.json @@ -0,0 +1,5 @@ +[ + { + "time": "doesNotExist" + } +] \ No newline at end of file diff --git a/orx-keyframer/src/test/resources/error-reporting/value-01.json b/orx-keyframer/src/test/resources/error-reporting/value-01.json new file mode 100644 index 00000000..aae93639 --- /dev/null +++ b/orx-keyframer/src/test/resources/error-reporting/value-01.json @@ -0,0 +1,7 @@ +[ + { + "time": "0.0", + "x": "garble", + "y": "garble" + } +] \ No newline at end of file