JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Linkages - Design Evolution

Aug 16, 2025 • [designlinkagesluamoverstesting]


Today, we’ll consolidate some of our learning into the code. The idea is to have a system that we can use readily.

When I write a program intended for one-off use, I try to follow good practice, but I’m pretty casual about it. If the program really is only used once, that is probably OK, but more often I seem to revisit the code, to improve it, share it, or just solve a very similar problem. ANd, often, I regret not having taken a bit more care.

In the current case, my intention is to produce a program that we can use often, to calculate the different linkages for lots of locomotives. A program like deserves more care in its testing, in its internal design, and in its interface. That will inevitably require some re-doing, re-vamping, and re-deciding how to do things. That’s what we’re at today.

I’ve already noticed that my three linkage elements have similar but different constructors:

function ConnectingRod:init(rod_length, parent, rod_radius)
function CouplingRod:init(rod_length, wheel, rod_radius)
function DriveWheel:init(x, y, r)

I’m not certain of what we’ll need, but I am pretty sure we’ll have more parameters in the constructors, some of them perhaps optional, and it’s only going to get more complicated.

It would help if, like Python, Luau allowed us to specify parameters by name, like this:

local wheel = DriveWheel(x=10, y=2, r=2)

The Luau language does not provide that kind of thing out of the box, but we can build it. This idea comes from SuzannaLinn’s implementation of SVG.

Thing one: naturally, we can pass a table as a parameter:

local wheel = DriveWheel({x=10, y=2, r=2})

Thing two: when the only parameter to a function is a table, Luau permits us to leave off the outer parentheses:

local wheel = DriveWheel{x=10, y=2, r=2}

Thing three: we can then process the table in our init:

DriveWheel = class()
-- assumes right side, forward is +x
function DriveWheel:init(parms)
    self._x = parms.x or error('expected x')
    self._y = parms.y or error('expected y')
    self._r = parms.r or parms.y
    self._angle = parms.angle or 0
end

And this will error until I fix up my wheel definitions. That is tedious but easy, and now I have DriveWheel done.

Might as well commit the code, all the tests are running.

I’ll just tick through the other two classes, shall I?

Here’s the only usage I had to change for ConnectingRod:

local wheel
local con_rod
_:setup(function()
    wheel = DriveWheel{x=10, y=2}
    local con_rod_radius = 1
    local con_rod_length = 6
    con_rod = ConnectingRod{length=con_rod_length, parent=wheel, radius=con_rod_radius}
end)

Lesson learned for the thousandth time: use test setup to your personal advantage in making fewer changes later.

We can improve the readability now, since we have the named parameters:

local wheel
local con_rod
_:setup(function()
    wheel = DriveWheel{x=10, y=2}
    con_rod = ConnectingRod{parent=wheel, length=6, radius=1}
end)

Nice!

Now for CouplingRod. A couple of quick edits and we’re all done.

I notice one bit of inconsistency:

ConnectingRod = class()
function ConnectingRod:init(parms)
    self._parent = parms.parent or error('expected parent')
    self._length = parms.length  or error('expected length')
    self._offset = vector(parms.radius or error('expected radius'), 0)
    self._position = vector(0,0)
    self:calculate()
end

CouplingRod = class()
function CouplingRod:init(parms)
    local len = parms.length or error('expected length')
    self._half_length = vector(len / 2, 0)
    self._parent = parms.parent or error('expected parent')
    local radius = parms.radius or error('expected radius')
    self._offset = vector(radius, 0)
    self._position = vector(0,0)
    self:calculate()
end

DriveWheel = class()
-- assumes right side, forward is +x
function DriveWheel:init(parms)
    self._x = parms.x or error('expected x')
    self._y = parms.y or error('expected y')
    self._r = parms.r or parms.y
    self._angle = parms.angle or 0
end

In DriveWheel we call the parameter r, not radius. Let’s change that for consistency. We’d rather have consistent, readable code than cryptic short names.

function DriveWheel:init(parms)
    self._x = parms.x or error('expected x')
    self._y = parms.y or error('expected y')
    self._r = parms.radius or parms.y
    self._angle = parms.angle or 0
end

Find and fix all the calls one more time … all done. Commit.

Looking Back

So that was easy, didn’t take as long to do as it did to write it up. Now, I think, when we use these linkage classes, we’ll find it easier to set them up. And, should that not be the case … well, we can change them again.

For me, changes like these are worth doing just because I like to be proud of my work. If I were in it for the money, I’d still do most of it, because good code is easier to change, and I’d be being paid to change existing code more often than for writing new, because that seems to be the nature of programming. Thus, good code pays off in faster work later, even if it takes a bit longer the first time around.

And anyway, if I have to do a thing all day, I’d rather to it well than poorly.

Another Concern

In Second Life, angles are typically provided in degrees. Inside our objects, we’ll want them in radians. It seems to me that our interface—the methods the users call—should provide for degrees in and degrees out, while using radians internally.

Let’s do that.

Change is simple:

local DEG_TO_RAD = math.pi/180
local RAD_TO_DEG = 180/math.pi

DriveWheel = class()
-- assumes right side, forward is +x
function DriveWheel:init(parms)
    self._x = parms.x or error('expected x')
    self._y = parms.y or error('expected y')
    self._r = parms.radius or parms.y
    self._angle = (parms.angle or 0)*DEG_TO_RAD
end

function DriveWheel:angle()
    return self._angle*RAD_TO_DEG
end

A few tests become more clear, like this:

_:test('size 2 wheel move 1.5 turns_cw', function()
    local radius = 2
    local wheel = DriveWheel{x=1,y=1,radius=radius}
    local circumference = 2*math.pi*radius
    local distance = circumference*1.5
    wheel:move(distance)
    _:expect(wheel:angle()).is(180, 0.01)
end)

I am a little concerned that we may have issues inside these classes, due to the conversion, which I’ll surely forget at least once, but degrees are much more convenient when editing SL objects.

What Else?

In testing a wheel, I had to compute a distance to move to get the angles I wanted to test. That’s always some pi-diameter thing. We might want some help with that, but for now, I’ll leave it. If I notice a need, help can be provided at that time.

So …

There we are. A much more communicative, less error-prone interface to our objects, through providing degrees in and out, and with named parameters using Luau tables. I’m pleased with both these changes.

I might not use the named parameter trick everywhere. Take vector for example. If we use our own vector class, we could give it a constructor like vector{x=2,y=3,z=4} instead of vector(2,3,4). But we probably won’t, for two reasons: first of all, the parameter order for vectors is well known, and second, we’ll be creating a lot of them and we don’t want to spend the extra time decoding the table if we don’t have to.

That second concern may be spurious: we do not know how fast or slow the two different forms of parameter uses are. We could, of course, find out with a test or two, and someday we might. But I do suspect that the table form is somewhat slower.

In any case, that is for another day. For today, we’ve done some good.

Safe paths!