Graphing calculator | Parsing string expressions into equations and visualizing them

Note: This method of parsing expressions uses loadstring(). If you don’t feel comfortable using it, look away!

USE THIS AT YOUR OWN RISK. BE AWARE OF THE POSSIBLE EXPLOITS INVOLVING LOADSTRINGS.

Not exactly sure what you guys would use this for, but hey I think it’s pretty cool

1. Introduction

Introduction

I know there are many different ways to write linear equations (and some of them have multiple solutions), but for simplicity’s sake, I’ll focus on the most common format: the y = mx + b format. The key thing to note is that you are finding the variable y, which is already isolated on the other side of the equation.

Let’s take a look at an example:

y = sin(x)

This equation is valid in actual graphing calculators. But if you pay attention, you’ll notice that it can also be valid in Lua!

But how do we use it?

In order to find out what y is, we need to make sure there are no other unknowns in the equation. The only other variable in the equation is x, which we can substitute for any number (duh, that’s how graphing calculators work)
We can start by wrapping that equation around a for loop:

for x = -10, 10 do
  y = sin(x)
end

BUT WAIT!! sin() isn’t a default function! Trying to use this would cause an error because the script doesn’t know what sin() is yet. We will address this later; for now, we can just use the math library.

for x = -10, 10 do
  y = math.sin(x)
  print(x, y)
end

If you try running this, it will print out the ordered pairs for each x integer in the range -10 to 10.
image
You can try plotting these points on a graph, and it will vaguely look like a typical sine wave. This is the basis for what we will do, where we use the ordered pairs to literally graph the equation!

2. Parsing expressions

Parsing expressions

So, the current example we have can only graph a single equation (the sine wave). What if we want to graph whatever equation we want, but without needing to constantly edit the script to do so?

As you know, it is quite tricky to have a script manipulate its own or other script’s source code. Roblox restricts this for security reasons to keep your game safe. But since we’re gonna use loadstring anyways, it doesn’t matter lol

loadstring() takes a string, compiles it, and returns a function that houses the compiled code. (It literally loads a string)

loadstring([[print('hi!')]])() --> hi!

And we can use this to compile a string with the equation into proper Lua code!

local equation = 'math.sin(x)'
local func = loadstring('return '..equation)

Note that we appended the keyword return (WITH A SPACE AFTER IT) in front of the expression. That’s how the expression is gonna give the answer, which is the variable y. You can also alternatively append y= instead and the compiled function won’t need to return a value.

local equation = 'math.sin(x)'
local func = loadstring('return '..equation)

x = 10
y = func()
print(x, y) --> 10 -0.54402111088937

Now let’s try putting this in a loop!

local equation = 'math.sin(x)'
local func = loadstring('return '..equation)

for x = -10, 10 do
  y = func() --> [string "return math.sin(x)"]:1: invalid argument #1 to 'sin' (number expected, got nil)
  print(x, y)
end

That’s weird, how come it didn’t work?
This is because the compiled function runs in the context of the global environment. And the x variable provided by the loop is a local variable. As such, the function simply doesn’t recognize the x and it becomes nil. Doing math with nil is a big no-no!

We can fix this by turning x into a global variable so that it gets recognized by the function:

local equation = 'math.sin(x)'
local func = loadstring('return '..equation)

for localx = -10, 10 do --the "x" in the loop gets renamed so it doesn't conflict with the actual x variable
  x = localx
  y = func()
  print(x, y)
end

This works, however, keep in mind that the expression is being compiled into Lua code. As such, the expression will need to be written as if we’re writing Lua code directly. This means that orders of operations apply (Programming in Lua : 3.5) but it also means that the typical algebra format you see in math class wouldn’t work sadly.

y = 3x + 5 will need to turn into y = 3 * x + 5
y = sqrt(|x|) will need to turn into y = math.sqrt(math.abs(x))

Because I’m a bit lazy, I won’t go through the hassle of using things like string.gsub and string.format to concoct a function that automatically converts algebra into valid Lua statements. That is something you can figure out yourself :slight_smile:

In the meantime, there is something we can do to make it slightly more familiar to algebra (next section)

3. Using getfenv() to simplify the math library

Using getfenv() to simplify the math library (and your life)

There are many other resources out there that explain getfenv far better than I can. You should definitely check those out before continuing. But in sort, this function returns a metatable which is linked to the environment of a function, the entire script, etc.
The metatable returned by this function allows you to access and modify the environment of your script or function, such as by inserting global variables (which is what we’re gonna do)

In the first section, we came across this problem when trying to parse an equation:

y = sin(x)

If the script doesn’t know what sin is, it would error!
A solution to this would be to just use the math library, but doesn’t that sound like a lot of work to type out math. in front of each math function in your equation?

Let’s pretend that it does

Ok, so why don’t we just create a global variable for each math function, as we discussed in section 2? But that also sounds like a lot of work!

Here’s a pro-life-hack you can do with all of your Lua 5.1 scripts:

local env = getfenv()
for k, v in pairs(math) do
  env[k] = v
end

Just put it at the top of your script!
Not only is this completely unnecessary and redundant, it does the job perfectly! You can now do things like

local env = getfenv()
for k, v in pairs(math) do
	env[k] = v
end

local equation = 'tan(atan(tan(atan(tan(atan(tan(atan(tan(atan(tan(x)))))))))))'
local func = loadstring('return '..equation)

for localx = -10, 10 do
	x = localx
	y = func()
	print(x, y)
end

without needing to type out math. 11 times, which is just absolutely amazing! This also gives you greater freedom because you can easily access the entirety of the Roblox math library! And not to mention, it updates itself whenever Roblox adds a new math function!!

In addition to plugging in the math library, you can also implement some custom mathematical functions that can be used by the equation. For example, you can write your own sigma notation and integral method to amp up the capabilities of your graph.

4. Expanding to other dimensions

Expanding to other dimensions

The examples I’ve been showing to you are all 2D graphs. You have an unknown y, and a range of x values. You can easily expand this to 3 dimensions by also taking into account a range of z values.

...--(the getfenv math library thing from section 3)

local equation = 'cos(x) * sin(z)' --there is now "z" in the equation
local func = loadstring('return '..equation)

for localx = -10, 10 do
  for localz = -10, 10 do --nested in the X loop
    x = localx
    z = localz
    y = func()
    print(x, y, z)
  end
end

The example above is one of those ocean wave-looking thing.

And you can go even further than that by adding even more dimensions like w, but doing so will exponentially increase the number of calculations the script will have to do.

5. Module-ifying, sanity check, and safety

Module-ifying and sanity check (and safety)

Let’s try turning all of this into a simple module! It is also a good idea to isolate this code so that the global variables doesn’t interfere with anything else. (I will contradict myself on this point later on lol)

--inside a ModuleScript
return function(eqn, fromX, toX, fromZ, toZ, increment)
	local func = loadstring('return '..eqn)
	local fenv = getfenv(func)
	
	for k, v in pairs(math) do
		fenv[k] = v
	end
	
	local points = {}
	for _x = fromX, toX, increment do
		points[_x] = {}
		for _z = fromZ, toZ, increment do
			fenv.x = _x
			fenv.z = _z
			points[_x][_z] = func()
		end
	end
	return points --an organized table containing points
end

Key thing to note:
Remember how in section 2 we talked about loadstring running in the context of the global environment? Well, because this is now a ModuleScript, by running this module through require() at a different script, loadstring will run in the context of the environment of that script, not at the context of the ModuleScript itself anymore.
Therefore, using global variables isn’t viable anymore since the module runs in a different environment.
And so, to work around this, we will now use getfenv like a real man. We have to directly manipulate the environment of the compiled function as demonstrated above (and below):

...
local fenv = getfenv(func) --get the environment of the compiled function

for k, v in pairs(math) do --put the math library in that environment
	fenv[k] = v
end

...
--sets the variables accordingly for each loop
fenv.x = _x
fenv.z = _z
...

So, instead of creating the variables in the global environment of the ModuleScript, we are creating them in the environment of whichever script that called the graphing function.
But wouldn’t this cause interference by injecting variables into the script that called the function?
Yes, it would; and if it’s a major problem for you, you can set up some sort of proxy system so that getfenv never touches the actual script that is calling the module. Really the only variables it’s implicating are x, z(if your graph is 3D), and the names of all of the functions in the math library (cos, rad, abs, noise, etc.)


Great, so we got the basis of the module set up. Now lets also add some safety and security checks. Why is this important? So that your module doesn’t error if the equation is malformed!

The first thing we can do is to check return value of the loadstring. Nil means that the expression is malformed. We can also perform a very simple test on the equation to see if it’s valid. This is done by initializing the compiled function with random numbers and seeing if it works and returns a number. Here’s an example:

return function(eqn, fromX, toX, fromZ, toZ, increment)
	local func = loadstring('return '..eqn)
	if not func then return end --returns nil if the function itself is malformed
	local fenv = getfenv(func)
	
	for k, v in pairs(math) do
        fenv[k] = v
	end
	--equation is tested with: x = 0 and z = 0
	fenv.x = 0
	fenv.z = 0
	local pass, result = pcall(func) --compiled function sent through pcall
	if pass and type(result) == 'number' then --the actual calculation only runs if the test passed	
		local points = {}
		for _x = fromX, toX, increment do
			points[_x] = {}
			for _z = fromZ, toZ, increment do
				fenv.x = _x
				fenv.z = _z
				points[_x][_z] = func()
			end
		end
		return points
	end
	return nil --returns nil if the test failed
end

Of course, the equation can’t be the only thing that is susceptible to errors. Obviously, all of the other parameters in the module function needs to be checked but you can do that yourself :slight_smile:

Important mention:
A key weakness with this method would be the execution of lambda functions, id est:

loadstring('
  (function()
    print("hi")
  end)()
')()

To help mitigate this to a certain extent, you can sandbox the compiled function by using setfenv (not getfenv) to wipe out everything else in its environment so that it can not excess certain global functions.

--outside of the function
local mathLib = {} --put all of the math functions you want to use in here, including your custom functions if you made them
for k, v in pairs(math) do
  mathLib[k] = v
end
...
--before calculation
setfenv(f, mathLib)
local env = getfenv(f)
...

However, this will not guard you from keyword-based functions such as infinite loops which can crash the parser. So if you want to, you can use string matching functions to exclude certain keywords from the parser.

If you found a way to exploit the parser to execute malicious code despite the aforementioned, please let us know.

6. Visualization

Visualizing and actually using the graphing calculator

The module we made returns a massive table that documents all of the points of the equation. The table itself has multiple tables (each table here represents an X value), and each of those nested tables has the Z value (the key) and the Y value (the value). In case you haven’t figured it out yet, here’s an example of how to actually use the module:

local graph = require(script.Grapher) --module in a ModuleScript named "Grapher" parented to this script
local points = graph('cos(x) * sin(z) + 5', -10, 10, -5, 5, 1)

So we have a table called points. How do we actually graph them?

By far the most common method would be to create a part for each point. This is simple but very inefficient. The part count can easily exceed a million for large graphs, not to mention the amount of memory and time it needs to graph! But still, let’s try it out:

local graph = require(script.Grapher)
local points = graph('cos(x) * sin(z) + 5', -10, 10, -5, 5, 1)

local partSize = Vector3.new(1, 1, 1)
for x, row in pairs(points) do
  for z, y in pairs(row) do
    local part = Instance.new('Part')
    part.Anchored = true
    part.Position = Vector3.new(x, y, z)
    part.Size = partSize
    part.Parent = workspace
  end
end

Looks rather blocky. We can increase the physical size of the graph and also scale up the equation:

local points = graph('cos(x / 5) * sin(z / 5) * 5 + 5', -50, 50, -50, 50, 1)

That looks better! Let’s try another equation:

local points = graph('sqrt(500 - x^2 + 500 - z^2)', -50, 50, -50, 50, 1)

You definitely might’ve noticed the horrendous gaps in that hemisphere graph. That’s one of the disadvantages of simply just plotting points.
There are many other different ways to visualize graphs (such as using mesh deformation, particles, beams), some of them definitely have better results than this. As a bonus example, I used EgoMoose’s 3D triangle tool to “graph” this instance of Perlin noise:

Demo:
graphingCalculatorDemo.rbxl (33.0 KB)

Hopefully one of you will find this useful. If you have any questions please let me know and I’ll try to get back to you!

8 Likes

Nice! I believe there is a module by boatbomber to do this already though? Still nice though! Good job!

1 Like

I wasn’t aware of that, it looks pretty cool!
This is more of an explanation on graphing in general than it is a proper module for other works.

2 Likes