JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Fixed Interval Path

Jun 26, 2025 • [designluamoverstesting]


Let’s try a compact fast scheme to see what it’s like.

I think we can be confident that script memory will still be a limitation when SLua arrives on the main grid. We’re told that LL are allowing more memory in Alpha than they expect to when it goes live. And anyway, we’re here to try things and build up our chops for SLua, so we’ll try all kinds of weird things.

In general in a garbage-collected language like SLua, I was raised to feel pretty free to create small objects frequently. “Trust your garbage collector”, someone said. but even if that’s generally good advice, creating large collections of small objects can be undesirable.

It’s tricky to be sure. We may have to come up with some code that helps measure the sizes of things, Or, we can study the literature, ask our friends, and make our best guesses.

Our Destination

A path has the ability to accept a distance input and to provide a position vector, and a rotation (quaternion), telling a vehicle how to move that distance and position itself. I think it is best to think of the path itself as consisting of a data structure that only the path understands, and an associated function, at(distance), or some longer name, that returns the vector and rotation for the provided distance.

If all this thinking comes together as I expect it to, we’ll end up with a handful of ways to represent path data, each one implemented as a data type (class or equivalent) with a method at: (or some other consistent name that we might pick). Each of those path objects will then be able to be plugged in and used underneath any kind of vehicle, hiding most of the complexity away from other parts of the intricate machinery that makes up a vehicle’s scripts.

today’s Problem

Today I want to experiment with paths where we have the path expressed in terms of a fixed distance between a point/rotation pair, with linear interpolation between the locations.

I propose to represent these paths in SLua arrays, packed up in pairs, position/rotation/position/rotation/etc. The object will know the fixed distance between those pairs, and that distance need not be an integer. We typically use 0.5 meters as the interval in our current systems.

Note

One possibly important optimization could be to initialize our object to the exact length of the array it contains. SLua allocates arrays dynamically, and adding a lot of items creates and destroys increasingly large amount of storage and generally leaves the array with extra space in case of more adds. We’ll leave that alone for now but it could be important

Let’s write a test. Here’s a try:

function Tests:test_fixed_interval()
    local p,r
    data = {1,10, 2,20, 3,30, 4,40}
    path = FixedIntervalPath(0.5, data)
    p, r = path:at(0)
    self:assert_equals(p, 1)
    self:assert_equals(r, 10)
end

I’m trying to make my job easy but not stupidly easy. Here’s a class that with a fake calculation, just to get the test to pass:

FixedIntervalPath = class()
function FixedIntervalPath:init(interval, array)
    self._interval = interval
    self._data = array
end

function FixedIntervalPath:at(distance)
    local index = 0
    local at = 2*index+1
    return self._data[at], self._data[at+1]
end

I’m glad I picked something easy and decided to fake it. The indexing of these things is going to be a bit odd, because of SLua starting table indices at 1. We could force our array to be zero-based, I suppose, but I think that only leads to confusion later. Anyway that test passes.

Let’s take a few more very small tests. First let’s find a different value in at an exact position, then work on the interpolation separately.

    local data = {1,10, 2,20, 3,30, 4,40}
    --
    p, r = path:at(1)
    self:assert_equals(p, 3)
    self:assert_equals(r, 30)

The result should be 3,30, because our interval is 0.5, so zero is the 1 cell, 0.5 the 2 cell and 1.0 the 3 cell. This of course fails now since we only know one answer. Enhance the class:

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local at = 2*index+1
    return self._data[at], self._data[at+1]
end

The cell we want is the integer part of distance/interval, so there we are.

Now an interpolation. (And then we need to think about the end of the list and wrapping around and negative indexes oh my.) I’m pretty sure this is right:

    local data = {1,10, 2,20, 3,30, 4,40}
    --
    p, r = path:at(0.75)
    self:assert_equals(p, 2.5)
    self:assert_equals(r, 25)

We need the fraction and to use it in interpolation.

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = 2*index+1
    local p1,r1 = self._data[at], self._data[at+1]
    local p2,r2 = self._data[at+2], self._data[at+3]
    local p = p1*(1-frac) + p2*frac
    local r = r1*(1-frac) + r2*frac
    return p, r
end

We just get the remainder, and the tests are all passing. So far so good.

I am concerned about what would happen if we gave this thing a distance less than zero or greater than whatever the outer limit is. There are a number of things we could do, including:

  1. Throw an error and crash the vehicle. This seems undesirable.
  2. Clamp the input distance to the limits implied by the interval and length of the array. (Low numbers zero, high ones at the max.)
  3. Use modular arithmetic to make the path wrap around in both directions,

Since we often make closed paths, and since my sisters have a tendency to make trains that can back up into negative distance, I think option #3 is the right one for us.

So we want to force index into the range zero to the half-length of the array (since we have two items per entry. (Should we not make that a parameter on the constructor? Possibly.))

Add a couple of tests. First this:

function Tests:test_fixed_interval_range()
    local p,r
    local data = {1,10, 2,20, 3,30, 4,40}
    --            0     0.5   1.0   1.5
    --            2.0   2.5
    local path = FixedIntervalPath(0.5, data)
    p, r = path:at(2.25)
    self:assert_equals(p, 1.5)
    self:assert_equals(r, 15)
end

I put this in a separate Tests function both because it is a different kind of test than the preceding in-range ones, and because when it failed at first, I got a clear message about what test was failing.

Many of my betters would limit the number of assertions, or assertion cases, to one per test. I do not follow that guideline as well as I might.

This is the code that is passing all the tests:

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index)%(#self._data)+1
    local p1,r1 = self._data[at], self._data[at+1]
    local p2,r2 = self._data[at+2], self._data[at+3]
    local p = p1*(1-frac) + p2*frac
    local r = r1*(1-frac) + r2*frac
    return p, r
end

It’s getting kind of complicated isn’t it? We’ll see what we can do about that when we have it working. I want to test a negative distance next.

    local data = {1,10, 2,20, 3,30, 4,40}
    --            0     0.5   1.0   1.5
    --            2.0   2.5   3.0   3.5
    --           -2.0  -1.5  -1.0  -0,5
    p, r = path:at(-1.5)
    self:assert_equals(p, 2)
    self:assert_equals(r, 20)

Those indices under the data? Those are not there for you. They are for me. They are the equivalent of counting on my fingers, I guess. That test passes. Let’s try a fractional one,

    p, r = path:at(-1.75)
    self:assert_equals(p, 1.5)
    self:assert_equals(r, 15)

That also passes. This is aided greatly by SLua’s sensible mod operator, %, which always returns a positive result.

One thing to be concerned about here is the length function in SLua, #. In Lua, # is not performant: it counts through the array. (I can’t believe they’d do this but all the documents say that they do.) So let’s save the length in the init.

Here’s our class now:

FixedIntervalPath = class()
function FixedIntervalPath:init(interval, array)
    self._interval = interval
    self._data = array
    self._length = #array
end

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index)%(self._length)+1
    local p1,r1 = self._data[at], self._data[at+1]
    local p2,r2 = self._data[at+2], self._data[at+3]
    local p = p1*(1-frac) + p2*frac
    local r = r1*(1-frac) + r2*frac
    return p, r
end

Let’s see if we can refactor that for a bit of clarity.

Add a lerp function to do the interpolation:

function FixedIntervalPath:lerp(v1, v2, f)
    return v1*(1-f) + v2*f
end

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index)%(self._length)+1
    local p1,r1 = self._data[at], self._data[at+1]
    local p2,r2 = self._data[at+2], self._data[at+3]
    local p = self:lerp(p1, p2, frac)
    local r = self:lerp(r1, r2, frac)
    return p, r
end

Inline the last three statements:

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index) % (self._length)+1
    local p1,r1 = self._data[at], self._data[at+1]
    local p2,r2 = self._data[at+2], self._data[at+3]
    return self:lerp(p1, p2, frac), self:lerp(r1, r2, frac)
end

What else? I think I don’t like the name at for the index into data, especially inside a method named at. I think we might be able to handle the % differently, possibly earlier? Maybe not. We can return all four data values in one go, like this:

function FixedIntervalPath:data(index)
    local d = self._data
    return d[index+1], d[index+2], d[index+3], d[index+4]
end

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index) % (self._length)
    local p1,r1, p2,r2 = self:data(at)
    return self:lerp(p1, p2, frac), self:lerp(r1, r2, frac)
end

Notice that I packaged the irritating +1 inside the data method.

Having done that, I am inclined to do the lerp inside the data method. Why bring them out individually only to use them in pairs? That was a bit tricky: I had to reorder the indexing:

function FixedIntervalPath:lerp_pair(index, frac)
    local d = self._data
    return self:lerp(d[index+1], d[index+3], frac), self:lerp(d[index+2], d[index+4], frac)
end

function FixedIntervalPath:at(distance)
    local index = (distance/self._interval)//1
    local frac = distance/self._interval - index
    local at = (2*index) % (self._length)
    return self:lerp_pair(at, frac)
end

It remains to see about clarifying those first three lines in at, and maybe to rename the other methods with underbar to signify private methods.

I’ll try some renaming:

function FixedIntervalPath:at(distance)
    local interval_index = (distance/self._interval)//1
    local fractional_interval = distance/self._interval - interval_index
    local strided_index = (2*interval_index) % (self._length)
    return self:lerp_pair(strided_index, fractional_interval)
end

Let’s extract that whole mess to a method returning just what the final line wants.

function FixedIntervalPath:position_at(distance)
    local interval_index = (distance/self._interval)//1
    local fractional_interval = distance/self._interval - interval_index
    local strided_index = (2*interval_index) % (self._length)
    return strided_index, fractional_interval
end

function FixedIntervalPath:at(distance)
    local data_index, fraction = self:position_at(distance)
    return self:lerp_pair(data_index, fraction)
end

Tests have remained green throughout all this. Now at least we have all the badness in a single method, position_at.

I want to do one more thing: rename all the private methods to be private.

FixedIntervalPath = class()
function FixedIntervalPath:init(interval, array)
    self._interval = interval
    self._data = array
    self._length = #array
end

function FixedIntervalPath:at(distance)
    local data_index, fraction = self:_position_at(distance)
    return self:_lerp_pair(data_index, fraction)
end

function FixedIntervalPath:_position_at(distance)
    local interval_index = (distance/self._interval)//1
    local fractional_interval = distance/self._interval - interval_index
    local strided_index = (2*interval_index) % (self._length)
    return strided_index, fractional_interval
end

function FixedIntervalPath:_lerp_pair(index, frac)
    local d = self._data
    return self:_lerp(d[index+1], d[index+3], frac), self:_lerp(d[index+2], d[index+4], frac)
end

function FixedIntervalPath:_lerp(v1, v2, f)
    return v1*(1-f) + v2*f
end

One more tiny thing comes to mind. We’re computing 1-f twice. We could reduce that to once if we were to return two fractions from _position_at. Let’s do it to see how it feels:

function FixedIntervalPath:_lerp_pair(index, frac)
    local d = self._data
    local one_f = 1 - frac
    return self:_lerp(d[index+1], d[index+3], frac, one_f), self:_lerp(d[index+2], d[index+4], frac, one_f)
end

function FixedIntervalPath:_lerp(v1, v2, f, one_f)
    return v1*one_f + v2*f
end

That may have been two far but it does save half a zillion subtract operations as we follow the path.

Time to stop. Let’s assess:

Assessment

We have a nice little object. We give it a fixed distance and an array of pairs and it maps distance values into those pairs, allowing wrap-around and negative distances. It has an easy setup and a single method at in use. It hides all the nastiness of subscripting and interpolating inside itself, and if and when we have to change its indexing or calculations, they are all in one place.

The position_at method is scary but, I am confident, correct. I’d like to make it less scary but at least now all the scary bits are in one method. Hm. I think this will help reduce the scary:

function FixedIntervalPath:_position_at(distance)
    local interval_index = (distance/self._interval)//1
    local strided_index = (2*interval_index) % (self._length)
    local fractional_interval = distance/self._interval - interval_index
    return strided_index, fractional_interval
end

I moved the strided_index calculation up one line. I think those first two lines are pretty clear. The third line is still weird. I just noticed the duplication in it. Let’s pull that out.

function FixedIntervalPath:_position_at(distance)
    local intervals = distance/self._interval -- float
    local interval_index = intervals//1       -- integer
    local fractional_interval = intervals - interval_index 
    -- 0 <= f <= 1
    local strided_index = (2*interval_index) % (self._length)
    return strided_index, fractional_interval
end

I think that’s slightly better but I am not satisfied. I am, however, tired and done for the morning. I think we have the makings of a nice little object here.

Safe paths!