JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Linkages

Aug 10, 2025 • [linkagesluamoverstesting]


I have done the calculations to move the elements of a linkage, more than once, in LSL. It’s tedious and error-prone. Might SLua be more helpful? (Mistakes will be made below.)

The Problem

A locomotive is typically driven by a linkage of parts that push and pull and rotate. The pistons push and pull, pushing a connecting rod that pushes on the main drive wheel, causing it to rotate. Another rod couples that wheel to other driven wheels and they, too, rotate. Meanwhile, the piston goes back and forth, the coupling rod stays horizontal and rotates with a circular motion, and the connecting rod follows a very interesting path, because one end goes around in a circle, driving the wheel, and the other end goes back and forth in a straight line, because it is connected to the piston.

That’s about as simple a linkage as one would ever do, and you can be sure that my sisters create much more interesting and realistic linkages than that. In the past, we have produced the motion needed by carefully working out the geometry of how all the pieces move and writing a script that computes everything from scratch, starting with the drive wheel’s rotation.

LSL is not much help with this job. I am hoping that SLua will be more helpful.

The Idea

More like a quarter of an idea, maybe a tenth. But, before I open the box, I’m picturing a small collection of objects that represent whatever fundamental kinds of motion that we need. Wheels, rods, whatever kind of thing can represent a kind of motion.

The motion is more the point than what the thing looks like. We can draw any object in a plane, given only one point and the object’s rotation. We like for the point to be the root, but given any point and the rotation, the root is just a quick bit of math away. R-theta kind of thing.

So I’m thinking, vaguely so far, that the drive wheel will have fixed X and Y (because we don’t care about the train actually moving, that’s someone else’s job) and a rotation around X,Y, as it moves along the track. In reality, the rotation is driven from the piston somehow, but in our display, we control the whole shebang from the driven wheel’s rotation.

So the rod between the driving and driven wheels? One end of it will be offset from the driving wheel’s position by a radius r and the wheel’s angle theta. The value of r is the crank radius from the wheel’s center, and is always less than the wheel’s own radius.

So, still vaguely, if we have the drive wheel’s position and angle, we can get the position of the end of the coupling rod by a “simple” calculation, something like

coupling.end_pos = driver.pos + (wheel.coupling_radius:rotated_by(wheel.theta))

Because the coupling rod is horizontal, its root and other end are also fairly simply computed, something like this:

coupling.root = coupling.end_pos + coupling.root_offset
coupling.far_end = coupling.end_pos + coupling.length

Other parts will not be so simple. The connecting rod from piston to driven wheel is an example. The position of the wheel end is again roughly

connecting_rod.end_pos = driver.pos 
    + (wheel.connecting_radius:rotated_by(wheel.theta))

But the other end is tricky. It is somewhere along the line extending straight out from the piston (in the direction of piston travel, which is not necessarily horizontal). If the length of the rod is len, the position of the piston end is the point on the line extending outward from the piston, such that that point’s distance from the end_pos is len.

That’s not difficult to resolve. Assuming (without loss of generality because we can rotate) that the piston line is horizontal, we can readily compute the vertical distance between the wheel end and piston end: we have the position, and the vertical distance is piston.y - end_pos.y.

And we have the Pythagorean theorem

len^2 = dx^2 + dy^2
so
dy^2 = len^2 - dx^2
so
dy = math.sqrt(len*len - dx*dx)

given dx and dy we can determine the rod’s rotation (arctangent) and its root position and so we can draw the rod.

Collecting Our Thoughts

You can begin to see that “all we have to do” is figure out all those relationships and carefully do the math and pretty soon we have all the pieces and parts moving around just right. Prior to that time, we have a tendency to flail them around madly, resulting in weeping and gnashing of teeth because we forgot to save the initial locations.

What I’m hoping to accomplish with the work I’m starting today is to have a way to represent the relationships between the linkage pieces with objects. An object will know what other objects it connects to, the ones it is driven by, such as the wheel and piston for the connecting rod we just talked about.

We’ll have the objects in order “outward” from the base driven wheel, so that we can compute each one in turn, knowing that it refers only to objects ahead of it in the list and therefore already positioned.

The kind of object will determine the kind of calculation it does, but they’ll all wind up able to answer their position, rotation, and other values that other object need to know.

If this works, we'll dine like kings, no wait, we’ll just have to go down the list calling compute on each object in turn, and when an object is done computing its position and rotation are known and we can move it. Or we can wait to the end and do them al in one go, which is more likely what we’ll do.

Testing

I’d like to proceed driven by tests: this code will be tricky and it would be nice if I didn’t have to debug much. In addition, I would very much like to be able to see it working, and I’d like to do that without logging into Second Life and building an object.

I think I can at least draw static pictures using SuzannaLinn’s SVG code for SLua, and view them in a browser. Maybe there is something better to be figured out.

Obstacles

I do not have rotations / quaternions on my Mac, so I’ll either have to implement them or work around them. I’m thinking work around as indicated by rotate_by in the examples above. We’ll see.

All Tentative

All this is tentative until we get around to it. So let’s do that. I’ll create a new luau file and put a test in it.

local class = require('./class')
local _ = require('./expectations')
local vector = require('./vector')
local function tovector(s)
    return vector:tovector(s)
end

function _:featureLinkages()
    _:describe('linkages', function()

        _:test("tests are running", function()
            _:expect('hi').is('hello')
        end)
    end)
end

_:execute()

As expected. I forgot the execute. Once I put that in, the test fails as intended:

Feature: linkages
Actual: hi, Expected: hello. 
Test: 'tests are running'.
linkages, 2 Tests: Pass 1, Fail 1, Ignored 0.

Confident that my tests are working, I can get to work.

_:test('drive wheel', function()
    local wheel = DriveWheel(2, 3)
end)

That’s more than enough to get me started. I’m supposing that the input parameters are x, y, and radius. It seems to me that y and radius will usually be equal but maybe we’re building a gear or something. Anyway now I need a class so that the test can run.

DriveWheel = class()
function DriveWheel:init(x, y, r)
    self.x = x
    self.y = y
    self.r = r
    self.angle = 0
end

I added in angle gratuitously, because I plan to use it in just a moment, and I’m going to try calling it angle because theta is too effete. It is the angle of wheel rotation, probably in radians, but we’ll see. We want to move the wheel by a distance and get its new angle.

_:test('drive wheel', function()
    local wheel = DriveWheel(2, 3, 3)
    wheel:move(1)
    _:expect(wheel.angle).is(1 / TWO_PI)
    _:expect(wheel.angle).is(0.159, 0.01)
    wheel:move(2)
    _:expect(wheel.angle).is(3 / TWO_PI)
end)
Note
the test is wrong and so is the code that I’m about to write. I had bad algebra in my head and what follows, down to the next note, is mistaken. Sure glad I kept testing.

And some code:

local TWO_PI = 2*math.pi

function DriveWheel:move(distance)
    local radius_change = distance/TWO_PI
    self.angle += radius_change
end

Both tests pass. I put in the literal value 0.159 because it seemed possible to me that I could get a spurious result. There’s not enough difference between the test code and the implementation. I’m pretty sure it’s right. One more test just because I don’t trust my algebra:

_:test('drive all the way around', function()
    local wheel = DriveWheel(1,1,1)
    local d = TWO_PI
    wheel:move(d)
    _:expect(wheel.angle).is(0, 0.0001)
end)

It’s really good that I did this. My calculation is wrong. It doesn’t even consider the radius!

OK. given radius r, the circumference of the wheel is 2*pi*r. To move distance d, we have to change the angle by d/(2*pi*r). Angle is d over 2 pi r.

And why did I call that variable angle_change? Anyway, now I have this:

function DriveWheel:move(distance)
    local angle_change = distance/(TWO_PI*self.r)
    self.angle += angle_change
end
Note
Still wrong but now we’ll start to get it right!

People, I am losing it. I need to normalize the angle, and it still isn’t right.

I’m going to bag these tests, and the move method, and calculate some values by hand.

_:test('unit wheel move 90 degrees', function()
    local wheel = DriveWheel(1,1,1)
    local circumference = 2*math.pi*1
    local distance = circumference / 4
    wheel:move(distance)
    _:expect(wheel:angle()).is(math.pi/2, 0.0001)
end)

Notice that I’m going to assume a function to return angle, not a property. That’s because I think some of our objects will be computing their angle on the fly and it’s too soon to decide what external properties they expose, if any. I’ll change all the members of DriveWheel to be private.

I code this:

function DriveWheel:move(distance)
    local two_pi = 2*math.pi
    local circumference = two_pi*self._r
    local fraction_moved = distance / circumference
    local angle_change = two_pi*fraction_moved
    self._angle = (self._angle + angle_change) % two_pi
end

That could be more compact but since I’ve demonstrated that I can’t do algebra in my head before breakfast, I wanted to go slowly. Let’s do more tests.

_:test('size 2 wheel move 1.5 turns', function()
    local radius = 2
    local wheel = DriveWheel(1,1,radius)
    local circumference = 2*math.pi*radius
    local distance = circumference*1.5
    wheel:move(distance)
    _:expect(wheel:angle()).is(math.pi, 0.0001)
end)

One more thing. I thin we want to be able to tell the drive wheel to rotate by an angle. I’ll add in the method: the current tests should suffice:

function DriveWheel:move(distance)
    local two_pi = 2*math.pi
    local circumference = two_pi*self._r
    local fraction_moved = distance / circumference
    local angle_change = two_pi*fraction_moved
    self:rotate_by(angle_change)
end

function DriveWheel:rotate_by(angle_change)
    self._angle = (self._angle + angle_change) % TWO_PI
end

I think I can go back to my TWO_PI literal now.

function DriveWheel:move(distance)
    local circumference = TWO_PI*self._r
    local fraction_moved = distance / circumference
    local angle_change = TWO_PI*fraction_moved
    self:rotate_by(angle_change)
end

Can we cancel out those two occurrences of TWO_PI in move? We can, giving:

function DriveWheel:move(distance)
    local circumference = self._r
    local fraction_moved = distance / circumference
    local angle_change = fraction_moved
    self:rotate_by(angle_change)
end

The circumference variable is poorly named but we can refactor further:

function DriveWheel:move(distance)
    local fraction_moved = distance / self._r
    local angle_change = fraction_moved
    self:rotate_by(angle_change)
end

And further:

function DriveWheel:move(distance)
    local angle_change = distance / self._r
    self:rotate_by(angle_change)
end

And further:

function DriveWheel:move(distance)
    self:rotate_by(distance / self._r)
end

And the tests continue to pass. Are we confident? You may be, but I am not quite. One more.

_:test('big wheel moves 3/4 turns', function()
    local radius = 5
    local circumference = 31.415926535
    local wheel = DriveWheel(1,1,radius)
    local distance = circumference*0.75
    wheel:move(distance)
    _:expect(wheel:angle()).is(1.5*math.pi, 0.0001)
end)

The test passes. I am now confident above 0.85 that this is good. I’d be way up near certain had I not done so badly to begin with.

Lesson

Despite the fact that I have at least one degree in math, my mental algebra was seriously wrong for this simple calculation. Once I realized that I wasn’t just a constant away from correct, I kept my tests and removed my code. Then I was ridiculously careful with the code, and I also drew a picture on a card next to the keyboard.

Then, with the correct code in place, and the test running, I was able to reduce the original 5 line function to one line, in small single step refactorings, just inlining one expression at a time. After each move, the tests continued to work, assuring me that I had not yet made a fatal mistake.

Instead of debugging and thinking super hard while looking at code that was wrong, I removed the error and produced the result in a longer more careful form, checked the result, and then improved the form.

It felt like progress, where debugging feels most confusing and often, even when the code finally works, often leaves me uncertain and unsatisfied.

I am pleased with this tiny result. And now, can I make breakfast, please? Thanks!

Safe paths!