JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Back to Linkages

Aug 15, 2025 • [linkagesluamoverstesting]


The linkage-to-SVG yak is sufficiently shaved. Its supporting yak, the SVG library Group object is also sufficiently shaved. Now can we get back to the reason we came here?

I’m not saying we won’t do more work on creating SVG: we probably will. And I’m not saying we won’t need more enhancements to SuzannaLinn’s SVG library: we might. But for now, we get back to the linkages.

We have the DriveWheel class, which will be the “root” of our linkage assembly. We’ll turn the drive wheel and things depending on it will move appropriately. Then the things depending on those … until finally the last thing has moved.

This is, of course, backward from how a real world locomotive would work, where the pistons would be the drivers. We do it our way because the locomotive linkage in SL isn’t driving the vehicle: it’s just there for verisimilitude: making it look right.

It’s also good news, because a linkage that works the other way is harder to set up and harder to calculate. I, for one, do not know how to do it, though I’m fairly confident that I could do enough study and learning to finally crack it. In the direction we’re going here, I expect most of it to be quite straightforward, at least for quite a while. And don’t even whisper “Walschaerts”. I don’t want to do that linkage.

Here are a few items that I do expect to do:

  1. Piston or PistonRod. The visible part of this is typically just a straight push-rod extending from inside the steam cylinder. As the piston, um pistons, the rod pushes back and forth, pushing and pulling …
  2. ConnectingRod. This rod connects the PistonRod to the DriveWheel, translating the back-and-forth of the Piston to the round-and-round of the wheel. One end moves on a line, and the other end in a circle.
  3. DrivenWheel. These are the other large-sized wheels that drive the locomotive. We’ll just slave them to the DriveWheel. And if we want smaller bogie wheels, we can adapt the DriveWheel to handle different radii.

For now, these are all that I plan to do, but I’m sure that my sisters will suggest others.

I propose to start with the connecting rod, because I plan to drive it from the wheel and to drive the piston rod from the connecting rod. Somewhere here in my notes, I think I worked out the essential math for this object. Rather than dig through a pile of index cards, let’s do it again.

We’ll start by assuming a horizontal piston cylinder, although there are locomotives with slanted ones. In every case, the piston rod must point directly at the center of the drive wheel. So our ConnectingRod object will have one end at some point on a circle that is concentric with the DriveWheel. We know how to calculate that, because the ConnectingRod does it. The other end of the connecting rod must have its y coordinate equal to the y coordinate of the DriveWheel. If the length of the connecting rod is l, then there will be a triangle from the wheel end to the piston end, with hypotenuse l and altitude y (or -y), the y coordinate of the wheel end. So x, the position along the piston rod line will satisfy the equations:

x^2 + y^2 = l^2
x^2 = l^2 - y^2
x = sqrt(l^2 - y^2)

For now, that’ll do. We can imagine additional twists, and our practice is to do the simple thing and then improve it on demand, not speculatively. Especially when we’re just trying to make it work for the Very First Time. I decide to do a new feature, not sure why. Just feels right to do one. Feature per object is probably a good rule of thumb.\

I’ll of course begin with a little test, though I’m not sure about the numbers we’ll put in. We figure that out as we go also.

function _:featureConnectingRod()
    _:describe("connecting rod", function()

        _:test("initial position", function()
            local wheel = DriveWheel(10, 2, 2) -- x, y, r
            local con_rod_radius = 1
            local con_rod_length = 6
            local con_rod = ConnectingRod(con_rod_length, wheel, con_rod_radius)
            con_rod:calculate()
            _:expect(con_rod:position()).is({8, 2})
        end)
    end)
end

I followed the rough form of the preceding tests. One thing that I don’t like is putting the parent object as the second parameter. It should be first or last. Remind me to work on that, but now is not the time.

How did I get {8, 2}, you ask? At rest the wheel angle zero points toward positive x, so the rotating end of the rod will be at the wheel’s x 10, plus 1, the radius for that end’s rotation, or 11. ANd because the rod is of length 6 the center will be at x = 11 - 3 or 8. Unless I’m wrong. Anyway we can make this run trivially.

Curiously, but not terribly worrying, I get this result from my first code:

Actual: table[1]=14, Expected: 8. 
Test: 'initial position'.

Right. Added instead of subtracted. In my little diagram, the piston is to the left (-) of the wheel. Should be easy to fix.

Note
There is more here than meets the eye, however. We certainly could have a piston setup that was not behind the drive wheel but in front. In fact, on some locomotives, we do have that. We’ll need to provide for that somehow. But for now, I’ll be happy to hard code all that. Let’s try to generalize code that works, not code that doesn’t work. We don’t need code that is even more wrong.

The fix is easy and the code as written is this:

function ConnectingRod:calculate()
    local angle = self._parent:angle()
    local px, py = table.unpack(self._parent:position())
    local wheel_end_x = px + self._radius*math.cos(angle)
    local wheel_end_y = py + self._radius*math.sin(angle)
    local delta_y = py - wheel_end_y
    local delta_x = math.sqrt(self._length*self._length - delta_y*delta_y)
    local piston_x = wheel_end_x - delta_x
    local piston_y = wheel_end_y - delta_y
    local center_x = (wheel_end_x + piston_x)/2
    local center_y = (wheel_end_y + piston_y)/2
    self._position = {center_x, center_y}
end

We see a whole raft of x this y this x that y that. This would be far more compact with vectors. We should work on that Real Soon Now, but let’s at least get this object finished first.

I think I can do another test and expect it to work. In fact I am almost confident, but I want to see what happens at 90 and 270. I’ll do 180 first, should be easier to write the test:

local wheel
local con_rod
_:setup(function()
    wheel = DriveWheel(10, 2, 2) -- x, y, r
    local con_rod_radius = 1
    local con_rod_length = 6
    con_rod = ConnectingRod(con_rod_length, wheel, con_rod_radius)
end)

_:test("180", function() 
    wheel:move(2*math.pi)
    con_rod:calculate()
    _:expect(con_rod:position()).is({6, 2})
end)

Note that I took the time to write a setup. It makes the tests shorter and keeps them consistent. A good idea which I do not always follow, but generally wish that I would do so more often.

This passes. With the wheel having rotated by an angle of pi radians, the rod moves two meters back. Perfect. What about 90 and 270? With 90 I want y to be above 2 and at 270, below. I’m going to just put in incorrect values and see what prints. Lazy, I know.

Tests, just checking for zero so as to fail and print:

_:test("90", function() 
    wheel:move(2*math.pi/2)
    con_rod:calculate()
    _:expect(con_rod:position()).is({0, 0})
end)

_:test("270", function() 
    wheel:move(3*2*math.pi/2)
    con_rod:calculate()
    _:expect(con_rod:position()).is({0, 0})
end)

Results:


Actual: table[1]=7.041960108450192, Expected: 0. 
Test: '90'.
Actual: table[2]=0.5, Expected: 0. 
Test: '90'.
Actual: table[1]=7.041960108450192, Expected: 0. 
Test: '90'.
Actual: table[2]=3.5, Expected: 0. 
Test: '270'.

At first, I kind of liked the y values but now I am not entirely sure of them. I draw a picture. At rotation of 90, the wheel end y will be 1 (because we’re rotating clockwise like an east-bound wheel). SO the right triangle is y = 1 and h = 6, so x = square root of 6^2 - 1^2, square root of 35. 5.91. so the x at the left end is 10 - 5.91, about 4.08. And the average of 4.08 and 10 is … 7.0419!

And as for y, it’s the average of 2, the high end, and 1, the low end so it should be 1.5 and the 270 should average 3 and 2 to get 2.5. So something is wrong there. I correct the sign of a calculation but I do not like what we have:

function ConnectingRod:calculate()
    local angle = self._parent:angle()
    local px, py = table.unpack(self._parent:position())
    local wheel_end_x = px + self._radius*math.cos(angle)
    local wheel_end_y = py + self._radius*math.sin(angle)
    local delta_y = py - wheel_end_y
    local delta_x = math.sqrt(self._length*self._length - delta_y*delta_y)
    local piston_x = wheel_end_x - delta_x
    local piston_y = wheel_end_y + delta_y
    local center_x = (wheel_end_x + piston_x)/2
    local center_y = (wheel_end_y + piston_y)/2
    self._position = {center_x, center_y}
end

I changed the piston_y operation from - to + to fix the bug. If one were to read that code, either one would miss that detail entirely (and make a mistake later) or one would wonder ??? is going on.

I think the “real” issue is the - delta_x, not the + delta_y, because it is the former that is accommodating the fact that our rod points in the negative x direction.

Also, even though the center position of the rod is the one we surely want in our running Second Life code, it is hard to test. So I think we should make the end points of the rod explicit and compute the center from that. And we should probably do that for the CouplingRod as well.

All this is for next time. Today, we have the basic ConnectingRod working. I’ll draw the pictures next time: just now I need a break. See you in the next article.

Safe paths!