There is a school of thought in object-oriented programming that tends to create many very small and simple objects. And there are the other folks.
There are a lot of things that we need from our vehicle’s ability to translate distance along the track to a point and rotation for the Second Life object representing the vehicle. Offhand, there are at least these issues:
d
;d
to a value of the Bezier parameter t
;d
to t
that is “good enough”;d
;It would be possible to do all of this in ope code: we have in fact done it. It would be possible to build this out of arrays and dictionaries embedded in some kind of BrilliantPath object. Today, we’re not going to do that. Today I want to try a different idea. It goes like this:
We’ll build a number of very small, very simple, single-purpose objects. We’ll build a sort of chain of those objects, such that when we ask the one at the end of the chain for the information we need, providing a distance value. The end of the chain object will make any decisions that it needs to make and ask the next object in the chain, If we get to the bottom of the chain, we’ll find a Bezier calculation that will turn out to be the right one.
I think this will turn out to be very simple and nice. If not, well, I’ll put a mark at the beginning of this article saying that it describes a bad idea and move on.
I need to do this thinking in the presence of my code. I’m starting with the version of my tests and code from two days ago, since I worked on other things yesterday.
As I look at those tests and code, I see that there is already more going on in the Bezier class than I expect to be there when we’re done. It is tempting to start over and just import or recreate a minimal Bezier. But no. My mentors would suggest that we should always move forward when we can, so we’ll see if we can just bring this code into the shape that I have in mind.
It’s fair to say “that I Very Vaguely have in mind”.
The current Bezier class has only four methods, so let’s review it:
Bezier = class()
function Bezier:init(p0, p1, p2, p3)
self.p0 = p0
self.p1 = p1
self.p2 = p2
self.p3 = p3
self.p1mp0 = self.p1 - self.p0
self.p2mp1 = self.p2 - self.p1
self.p3mp2 = self.p3 - self.p2
end
function Bezier:at(t)
local q0 = self.p1mp0*t + self.p0
local q1 = self.p2mp1*t + self.p1
local q2 = self.p3mp2*t + self.p2
local r0 = (q1-q0)*t + q0
local r1 = (q2-q1)*t + q1
return (r1-r0)*t + r0
end
function Bezier:compute_length(intervals)
local v0 = self.p0
local total = 0
local v1
local t
local max = intervals
for i = 1,max do
t = i/max
v1 = self:at(t)
d = vector.magnitude(v1 - v0)
total = total + d
v0 = v1
end
return total
end
function Bezier:compute_way_points(intervals)
local wp0 = {len=0, pos=self:at(0)}
local wps = {wp0}
local v0 = self.p0
local total = 0
local v1
local t
local max = intervals
for i = 1,max do
t = i/max
v1 = self:at(t)
d = vector.magnitude(v1 - v0)
total = total + d
local wp = {len=total, pos=v1}
table.insert(wps, wp)
v0 = v1
end
self.way_points = wps
end
The point of this object is contained in the method at(t)
, which computes the position along the curve of at the parameter value t
, which is between zero and one.
The other two methods really have nothing to do with Bezier curves at all. One returns an approximation of the curve’s length.From the viewpoint of a Bezier, it has no length. It is just a function from t to a point. (Later, we’ll return an indicator of the direction the curve is heading at that point, which we’ll use to get the vehicle’s rotation. But it’s still just a function from t to some numbers.)
The other function returns a collection of “way points”, which are pairs containing len
, a straight-line approximation to the length from zero to the waypoint, and pos, the position on the curve of that waypoint. Waypoints are calculated at equal t-intervals, including t=0
and t=1
and spaced equally (in t
) in between.
Neither of these has anything to do with Bezier per se, but they are definitely interesting to us as we develop our vehicles. Let’s think for a moment about why we care about overall length and these equal-t values.
We are going to make a long path out of many Bezier curves. So given a distance along that path, we’ll need to select the Bezier that covers that distance. So we’ll need the overall length of each Bezier, so that we can find the one that contains the distance we’re looking for.
And given a Bezier, we’ll want the point at a given distance along it. But the Bezier wants a t-value as its input. So we’ll need a way to approximate the t value that we should use. the waypoints provide a way to do that. If for each one we know the (approximate) distances along the curve it covers, we can pick the one that covers the distance we want interpolate the t
value between that waypoint`s starting and ending lengths, and evaluate at that t value.
We have tentatively identified two objects we might want, one that finds a suitable Bezier that covers some distance, and the other that finds a suitable t value inside a given Bezier, given distance along it.
From the outside, all we really care about is providing a distance along the path and getting back a position. So whatever object we talk to, we want to ask questions in terms of distance, and get answers in vectors (and rotations, later).
So I envision, vaguely still, at least three objects. I’ll make up names for them but don’t promise to stick with these names.
d
, which sliver to ask for the position at d
;t
to give to the Bezier when asked for a position at a distance d
;pos
in terms of t
.Lets call the slicer … a D_Bezier, D for “distance”. It really just needs one public method at(distance)
. I’ll try to write a test for it.
function Tests:test_d_bezier()
local p0 = vector(400, 400, 0)
local p1 = vector(500, 500, 0)
local p2 = vector(600, 500, 0)
local p3 = vector(700, 400, 0)
local b = Bezier(p0, p1, p2, p3)
local len = b:compute_length(8)
local db = D_Bezier(b)
self:assert_equals(db:at(0), b:at(0))
self:assert_equals(db:at(len), b:at(1))
end
Here I’m just creating the D_Bezier and asking it for the bezier values at distance zero and distance equal to the length of the bezier, expecting that we’ll get back the zero and 1 values of the Bezier. We’ll need more testing but this is more than enough, I think.
We could go two ways to make this work. We could create a simple object and fake these to answers. Or we could go the whole way and try to make it actually work. I feel lucky, so let’s go for it.
No. Let’s fake it, to show how we might do that.
D_Bezier = class()
function D_Bezier:init(bezier)
self.bezier = bezier
end
function D_Bezier:at(distance)
return self.b:at(0)
end
I expect this to pass my first assertion but not the second.I did not expect this:
Bezier 2025-05-23 [script:bezier-2025-05-23] Script run-time error
runtime error
lua_script:259: attempt to index nil with 'at'
lua_script:259 function at
lua_script:189 function test_d_bezier
Perhaps I should have said this:
function D_Bezier:at(distance)
return self.bezier:at(0)
end
See why I take tiny steps and test as soon as I can? It makes things like that much easier to find. Now I get what I expected:
[05:51] Bezier 2025-05-23: test_d_bezier: <400.00000, 400.00000, 0.00000> was expected to be <700.00000, 400.00000, 0.00000> ()
[05:51] Bezier 2025-05-23: Tests: Pass 16, Fail 1
Now we could fake this with an if
but let’s see if we have enough of a platform now to do the real work.
at
. Now it will be just improving how things work, with the basic structure in place. Two easy steps are better than one somewhat tricky step.I might not even go the whole way in this next step: I haven’t decided. What I have decided not to do is to fake the second answer with an “if”. It won’t teach me anything. I might have done well to have just tested the zero case, not the one, but no harm done.
We want to set up some waypoints, like the method compute_way_points
does in Bezier. I think we can sketch how the D_Bezier will work, like this:
D_Bezier = class()
function D_Bezier:init(bezier)
self.bezier = bezier
self.waypoints = self:make_waypoints()
end
function D_Bezier:at(distance)
local t = self:find_t(distance)
return self.bezier:at(t)
end
function D_Bezier:make_waypoints()
end
function D_Bezier:find_t(distance)
return 0
end
We’ll compute some waypoints in init
and we’ll find the required value of t in at
and then use it. I faked the make_waypoints
to do nothing, and faked the return from find_t
to zero. That means I can save this much and should get the same error as before. Yes, still good.
Now I have code that creates waypoints in some form (len,pos) but I am not sure that’s what we really want, so let’s write find_t
to figure out what would be nice to have.
In find_t
, we would like to search the waypoints to find the one that contains the distance we’re looking for. So that makes me think that it would be convenient if each waypoint contained a low distance and a high distance so it would be easy to find the matching one. Let’s write the beginning of find-t
as if that information is in the waypoints. Like this:
function D_Bezier:find_t(distance)
for _, wp in ipairs(self.waypoints) do
if wp.low <= distance and distance <= wp.high then
return self:interpolated_t(wp, distance)
end
end
return 0
end
This is already telling me that quite possibly WayPoint would like to be an object. this code is pulling values out of our so far imaginary waypoint and manipulating them. Things go better with objects when they have behavior, not just data values. If there was a class WayPoint, we might be able to use it like this:
function D_Bezier:find_t(distance)
for _, wp in ipairs(self.waypoints) do
if wp:contains(distance) then
return wp:evaluate(distance)
end
end
return 0
end
This is a bit tricky, because I haven’t got D_Bezier stable and now I’m introducing another object? I think we’ll be OK. If not, I can roll back.
function WayPoint:contains(distance)
return self.min_d <= distance and distance <= self.max_d
end
function WayPoint:evaluate(distance)
local frac_d = (distance - self.low)/self.length
return self.min_t + self.delta_t*frac_d
end
I think evaluate
isn’t quite the right name but it’ll hold water for now. But that calculation took me a bit to work out and I’m not sure that I trust it. Therefore, a test. I’ll mark my current failing test to be ignored and write one or more tests for WayPoint.
function Tests:test_waypoint()
wp = WayPoint(100, 200, 0.4, 0.5)
self:assert_equals(wp:evaluate(100), 0.4)
self:assert_equals(wp:evaluate(200), 0.5)
self:assert_equals(wp:evaluate(150), 0.45)
end
The good news is that this told me what I’d like to have Waypoint init take, min distance, max distance, min t max t. We may have to use our assert_nearly_equal
on one or more of these. Let’s find out. Save runs the tests.
I made several silly mistakes along the way to this, forgetting self:
on my asserts (thanks PyTest) and saying wp.evaluate
instead of wp:evaluate
and such. Here’s WayPoint now.
WayPoint = class()
function WayPoint:init(min_d, max_d, min_t, max_t)
self.min_d = min_d
self.max_d = max_d
self.min_t = min_t
self.max_t = max_t
self.length = max_d - min_d
self.delta_t = max_t - min_t
end
function WayPoint:contains(distance)
return self.min_d <= distance and distance <= self.max_d
end
function WayPoint:evaluate(distance)
local frac_d = (distance - self.min_d)/self.length
return self.min_t + self.delta_t*frac_d
end
I went beyond my remit and computed and cached length
and delta_t
, more as a convenience than an optimization, although it serves as both.
I want to take a break real soon now, as I’ve been at this for two hours and am in need of caffeine and sustenance. We are green now, with this ignored test:
function Tests:ignore_test_d_bezier()
local p0 = vector(400, 400, 0)
local p1 = vector(500, 500, 0)
local p2 = vector(600, 500, 0)
local p3 = vector(700, 400, 0)
local b = Bezier(p0, p1, p2, p3)
local len = b:compute_length(8)
local db = D_Bezier(b)
self:assert_equals(db:at(0), b:at(0))
self:assert_equals(db:at(len), b:at(1))
end
I think what I’ll do is unignore it, if that’s a word, and leave it broken, coming back later. We have more than enough to take in here already. I add this method to get the failure I want instead of a script error:
function D_Bezier:make_waypoints()
return {}
end
Now I have this error. I’ll take my SL object into inventory and when I resume work and bring it out, I’ll be reminded of what’s next:
[06:55] Bezier 2025-05-23: test_d_bezier: <400.00000, 400.00000, 0.00000> was expected to be <700.00000, 400.00000, 0.00000> ()
[06:55] Bezier 2025-05-23: Tests: Pass 19, Fail 1
I still make those common mistakes, not remembering self
on assertions and typing dots when i need colons. I don’t have a better idea than “try harder”, which isn’t much help. It might be possible to re-rig the testing framework not to need the self
on assertions, but I don’t think that’s the right thing to do.
I learned that the production classes want to be placed before the tests. Maybe the real rule is that the tests should be last in the file.
Why? Because when I declared my classes local, which one should do, the tests failed in a really obscure way because their references to the classes occurred before the local declarations, so they referred to globals that never existed.
Anyway, we didn’t get quite as far as I had imagined, but we never do. We have a solid start at the D-Bezier, just needing the creation of the Waypoints, and we have a WayPoint class … should that be named Waypoint, if we’re going to use waypoint as a word? Probably.
Next time I feel sure we’ll complete D_Bezier and probably get a good start on the multiple bezier container thing as well.
I like how it’s going. I think we’re going to wind up with a Bezier that just takes t
and calculates the values we want. We’ll have a D_Bezier that translates distance to t
. We’ll have a Waypoint that D_Bezier uses to get the t
it needs.
It is possible that D_Bezier isn’t properly named: it doesn’t really know it’s dealing with a Bezier, it just knows that it has to translate from distance to t and call whatever object it contains. So we might have a more generic name for it.
Bezier will have just one public method. So will D_Bezier, though it seems right now to need a couple of internal methods. Waypoint has just two pubic methods, one to find the waypoint that holds our desired distance, and one to get the t value.
In a well-factored script without objects, we’d have some weird data structures, horrendous lists in LSL, and more than a handful of functions at the top level all of which would be in our face as we try to figure out the code. In this scheme, the functions will be associated with a sensible object that just handles one kind of responsibility.
I think it’ll be quite nice. We’ll find out. Safe paths!