Skip to content

Contributors

WannesMalfait edited this page Nov 1, 2024 · 5 revisions

This document provides a more technical documentation of the repository. It is aimed at people looking to contribute.

Creating a pull request

If it is a small change, you can edit the file through the GitHub interface and create a pull request that way. For bigger changes you will probably want to be able to edit files locally on your machine. For this, you can use the following steps.

  1. Clone the repository: git clone https://github.com/WannesMalfait/Blender-Add-ons.git
  2. Fork the repository using the GitHub website. Then set it to a remote: git remote add me https://github.com/<username>/Blender-Add-ons.git. Replace <username> with your GitHub username, and change me to the name you want your fork to have.
  3. Create and checkout a new branch for the changes you want to make: git checkout -b my-awesome-feature main.
  4. Make the changes you want and commit them git commit file1 file2
  5. Push changes to your fork: git push me my-awesome-feature.
  6. On the GitHub website you should now be able to see a button to create a pull request.

Updating a pull request

If you later want to make some additional changes you can do so as follows:

git pull                         # Get the latest changes from the upstream repository
git checkout my-awesome-feature
git merge main                   # Merge with main to ensure you get the latest changes 
git commit file3 file4           # Add new commit
git push me my-feature           

Math Formula

Since the math formula add-on is quite big, I have set up some CI (continuous integration) and tests to try and prevent things from just breaking.

Formatting

The add-on is formatted using the black formatter with default settings. Set it up for your editor of choice to ensure the CI will accept your PR.

Tests

Once you are done with your changes, you can run

/path/to/blender -b -P /path/to/Blender-Add-ons/math_formula/tests/run_tests.py --factory-startup

from the repository to run all tests.

  • The first part is the path to the location of your blender executable/binary. See here for more details on how to run blender from the command line on your OS.
  • The -b tells blender to run in the background.
  • The -P tells blender to execute the following Python script.
  • The next part is the path to run_tests.py in the repository.
  • Finally, we add --factory-startup to ensure that the test is run in the default configuration of blender.

If all tests pass, you are good to go 🙂

What is tested?

  1. All the custom_implementations are reloaded.
  2. For each file in the tests directory, the code is run both for the shader and the geometry nodes editor.
  3. The output node tree is compared against the known "correct" output node tree. In this way, regressions can be quickly spotted.

How the add-on works

To understand how the add-on works let us run through everything that happens under the hood, in a typical use-case.

  1. The user presses Alt + F .
  2. This causes the invoke method of MF_OT_type_math_formula_then_add_nodes in main.py to be called.
  3. This function sets up the editor, tells blender that it should start drawing the editor on screen, and tells blender that this operator will be running modal, i.e. the operator isn't done yet at the end of this function.
  4. We now enter the modal method of the same class. This one is responsible for handling all the keyboard inputs and handing them over to the editor, or starting other logic. This method is only run whenever an "event" happens. So, if the user doesn't move the mouse or use the keyboard, this method will not run.
  5. For example, if the user now presses "0" this will be passed to the editor via the add_char_after_cursor method. The editor code is located in editor.py.
  6. Let us assume that the user ended up typing x = sin(4 + 5 * 6);, and now confirms this by pressing Ctrl + Enter .
  7. At this point we try and compile the formula, and show any errors to the user if compilation failed.
  8. To compile the formula the following "pipeline" is followed.
graph LR;
Source{Source} --> S[Scanner];
S -->|tokens| P[Parser];
P -->|AST| T[Type Checker];
T -->|Typed AST| C[Compiler];
C -->|operations| I[Interpreter];
I -->|Node Tree| NP[Node Positioner];
NP --> FNT[Final Node Tree];
Loading

Let us go over each of these in more detail.

Scanner

The scanner is located in scanner.py. The scanner is responsible for turning raw source code into a series of predefined tokens. Each token corresponds to a specific part of the source code. In order to pass this stage, the code can't contain any invalid characters. In our case the scanner would produce the following list of tokens from the source x = sin(4 + 5 * 6);:

[[x, IDENTIFIER], [=, EQUAL], [sin, IDENTIFIER], [(, LEFT_PAREN], [4, INT], [+, PLUS], [5, INT], [*, STAR], [6, INT], [), RIGHT_PAREN], [;, SEMICOLON]]

Parser

The parser is located in mf_parser.py (parser is a built-in python module). The parser is responsible for turning the stream of tokens into an AST (abstract syntax tree). This means that the code needs to have correct syntax to pass this stage. For our example, the stream of tokens from our example would be turned into the following AST:

Module(
.body=[
..Assign(
...targets=[
....Name(id='x')],
...value=Call(
....func=Name(id='sin'),
....pos_args=[
.....BinOp(
......left=Constant(value=4, type=<DataType.INT: 3>),
......op=Add(),
......right=BinOp(
.......left=Constant(value=5, type=<DataType.INT: 3>),
.......op=Mult(),
.......right=Constant(value=6, type=<DataType.INT: 3>)))],
....keyword_args=[]))])

To make it a bit easier to understand, here is a graphical representation of the AST:

graph TD;
    Module -->|body| Assign;
    Assign -->|targets| Name;
    Name -->|id| 'x';
    Assign -->|value| Call;
    Call -->|func| fName[Name];
    fName -->|id| 'sin';
    Call -->|pos_args| BinOp;
    BinOp -->|left| Constant4[Constant];
    BinOp -->|op| Add["Add()"];
    Constant4 -->|value| 4;
    Constant4 -->|type| int4[int];
    BinOp -->|right| BinOp2[BinOp];
    BinOp2 -->|left| Constant5[Constant];
    Constant5 -->|value| 5;
    Constant5 -->|type| int5[int];
    BinOp2 -->|op| Mult["Mult()"];
    BinOp2 -->|right| Constant6[Constant];
    Constant6 -->|value| 6;
    Constant6 -->|type| int6[int];
Loading

As you can see, the AST contains a lot more information on how to run the program, than the stream of tokens does. It has used correct operator precedence for the expression 4 + 5 * 6, i.e. it knows that multiplication comes before addition.

Type Checker

The type checker is located in type_checking.py. The goal of the type checker is to assign types to each node in the AST, and resolve any nodes whose values depend on the types of the inputs. It does this recursively: for each node, once it has type checked the inputs, it uses those types to determine the output type of this node. To determine which types are valid, the type checker refers to a back-end. For now there is only a shading nodes and a geometry nodes back-end. In the future, a compositor node back-end might get added. The back-end implementation can be found in the backends folder. In our example, when type checking with the geometry nodes back-end, the type checker produces the following typed AST:

TyRepr(
.body=[
..TyAssign(
...targets=[
....Var(
.....stype=<StackType.SOCKET: 1>,
.....dtype=[
......<DataType.FLOAT: 4>],
.....out_names=[],
.....id='x',
.....needs_instantion=False)],
...value=NodeCall(
....stype=<StackType.SOCKET: 1>,
....dtype=[
.....<DataType.FLOAT: 4>],
....out_names=[
.....'value'],
....node=NodeInstance(key='ShaderNodeMath', inputs=[0], outputs=[0], props=[('operation', 'SINE')]),
....args=[
.....NodeCall(
......stype=<StackType.SOCKET: 1>,
......dtype=[
.......<DataType.FLOAT: 4>],
......out_names=[
.......'value'],
......node=NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'ADD')]),
......args=[
.......Const(
........stype=<StackType.VALUE: 0>,
........dtype=[
.........<DataType.FLOAT: 4>],
........out_names=[],
........value=4.0),
.......NodeCall(
........stype=<StackType.SOCKET: 1>,
........dtype=[
.........<DataType.FLOAT: 4>],
........out_names=[
.........'value'],
........node=NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'MULTIPLY')]),
........args=[
.........Const(
..........stype=<StackType.VALUE: 0>,
..........dtype=[
...........<DataType.FLOAT: 4>],
..........out_names=[],
..........value=5.0),
.........Const(
..........stype=<StackType.VALUE: 0>,
..........dtype=[
...........<DataType.FLOAT: 4>],
..........out_names=[],
..........value=6.0)])])]))])

As you can see, this contains a lot more information than the AST did. Notice how the variable x has been assigned the type float. This is because the expression on the right-hand side was deduced to return something of type float. Another thing to note is that all the BinOp AST nodes were resolved into actual blender nodes. Let us look at a small portion of the tree in more detail to understand how that works. The expression 5 * 6 resulted in the following portion of the AST:

BinOp(
      left = Constant(value=5, type=<DataType.INT: 3>),
      op = Mult(),
      right = Constant(value=6, type=<DataType.INT: 3>)
)

which can be represented graphically as:

graph TD;
    BinOp2[BinOp];
    BinOp2 -->|left| Constant5[Constant];
    Constant5 -->|value| 5;
    Constant5 -->|type| int5[int];
    BinOp2 -->|op| Mult["Mult()"];
    BinOp2 -->|right| Constant6[Constant];
    Constant6 -->|value| 6;
    Constant6 -->|type| int6[int];
Loading

When the type checker reaches this portion of the tree, it checks if the left and right inputs of the binary operation already have types assigned, which they do in this case. Since it knows the types of all the inputs, it will now try to resolve the type of its output. For this it asks the back-end to find the "best match" to a function named "mult" that takes two integers as its inputs. The geometry nodes back-end has multiple possible implementations for "mult". In this case it will determine that the best function is ShaderNodeMath set to MULTIPLY (because there is no node yet in geometry nodes to multiply two integers). Since this node takes two floats as inputs, the corresponding inputs are converted from integers to floats. This is why the resulting typed AST has two floats as inputs instead of integers.

.......NodeCall(
........stype=<StackType.SOCKET: 1>,
........dtype=[
.........<DataType.FLOAT: 4>],
........out_names=[
.........'value'],
........node=NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'MULTIPLY')]),
........args=[
.........Const(
..........stype=<StackType.VALUE: 0>,
..........dtype=[
...........<DataType.FLOAT: 4>],
..........out_names=[],
..........value=5.0),
.........Const(
..........stype=<StackType.VALUE: 0>,
..........dtype=[
...........<DataType.FLOAT: 4>],
..........out_names=[],
..........value=6.0)])

Once the type checking is done, the input source code is deemed to be correct. After this phase, any errors that occur are mistakes in the logic of the compiler, and not in the source code itself.

Compiler

The compiler is located in compiler.py. Its job is to turn the typed AST into a bunch of easy to understand operations for the interpreter. The possible operations are listed in the OpType enum located in type_defs.py. It discards a lot information which is not needed anymore, like the types or the name of the outputs of a node. The result of compiling the previous type AST are the following operations:

(PUSH_VALUE, 4.0)
(PUSH_VALUE, 5.0)
(PUSH_VALUE, 6.0)
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'MULTIPLY')]))
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'ADD')]))
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0], outputs=[0], props=[('operation', 'SINE')]))
(CREATE_VAR, x)
(END_OF_STATEMENT, None)

This is a lot simpler than the typed AST! To understand how these instructions lead to the final node tree, we must look at the interpreter.

Interpreter

The interpreter is located in interpreter.py. It's job is to execute the operations given by the compiler one by one, and as such produce the final node tree. To do this, it internally creates a stack which can be accessed and modified by the various instructions. Here's what the stack would look like while executing the above program:

# stack = []
(PUSH_VALUE, 4.0)
# stack = [4.0]
(PUSH_VALUE, 5.0)
# stack = [4.0, 5.0]
(PUSH_VALUE, 6.0)
# stack = [4.0, 5.0, 6.0]
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'MULTIPLY')]))
# stack = [4.0, NodeSocketFloat(multiply node output socket)]
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0, 1], outputs=[0], props=[('operation', 'ADD')]))
# stack = [NodeSocketFloat(add node output socket)]
(CALL_BUILTIN, NodeInstance(key='ShaderNodeMath', inputs=[0], outputs=[0], props=[('operation', 'SINE')]))
# stack = [NodeSocketFloat(sin node output socket)]
(CREATE_VAR, x)
# stack = []
(END_OF_STATEMENT, None)
# stack = []

Additionally, the interpreter also has a dictionary of variables, indexed by their name. The CREATE_VAR operation creates an entry for the variable x and assigns it to the output socket of the sin node in this case.

As a final step in the pipeline, the resulting nodes are given to the node positioner.

Node positioning

The "Tree Positioner" is implemented in positioning.py. Its job is simply to try and arrange the nodes created in a nice way. The code is basically an implementation of the algorithm described in this paper ("A Node-Positioning Algorithm for General Trees" by John Q. Walker II). Although this algorithm produces great results for graphs which looks very tree like, for other types of graphs the resulting layout is often not as great. Implementation of a better algorithm should be looked into in the future (see this one for example).