JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Transforming the Point

Sep 9, 2025 • [designlinkagesluatesting]


We’re in the middle of working on the idea of “constrained points”, which seem to me to be a smaller object that may serve as components that we can combine to make linkages. Today I plan to get a solution by transforming the problem.

There is a common trick in math where, given a problem, we transform it to a simpler problem, solve the simpler problem, and then transform the answer back, thus solving the original problem.

Today we’re working on finding the point along a line where the working end of a connecting rod will reach, given the angle of rotation of the crank end (and whatever other parameters we need). We’ve simplified the problem down to defining a function that calculates the point we need, and testing that. The idea is to determine what information that function needs and then build the Point object with that in mind.

Here’s what we have so far:

_:describe("point on line finder", function()
    local function point_on_line_at_distance(wheel_center, radius, angle, distance)
        local theta = angle*DEG_TO_RAD
        local crank_pos = vector(radius,0):rotate_2d(theta)
        local y = crank_pos.y
        local dx = math.sqrt(distance*distance - y*y)
        local result_x = crank_pos.x + dx
        local answer = vector(wheel_center.x + result_x, 0)
        print(answer)
        return answer
    end

    _:test("all angles zero", function()
        local wheel_pos = vector(0, 0)
        local r = 1
        local crank_pos = vector(r, 0)
        local d = 5
        local pt = point_on_line_at_distance(wheel_pos, r, 0, d)
        _:expect(crank_pos:dist(pt)).is(d)
    end)

    _:test("net angle 90", function()
        local wheel_pos = vector(0, 0)
        local r = 1
        local angle = 90
        local crank_pos = vector(0, r)
        local d = 5
        local pt = point_on_line_at_distance(wheel_pos, r, angle, d)
        _:expect(crank_pos:dist(pt)).is(d)
    end)
end)

I’m printing the answer because I haven’t quite figured out how to check whether the point is actually on the line we have in mind. It’s simple enough, I just haven’t done it: my mind is on other things.

I mentioned transforming the problem above. Here’s what I mean. The main use of this constraint is to constrain the piston end of a connecting rod from a crank to a piston. The crank end goes in a circle, and the piston end goes in a straight line.

The code we have now assumes that the piston is at angle zero relative to the crank, that is, horizontally right in front of the crank. In practice, the piston can be at any angle around the crank center. In past code, I’ve called that the tilt_angle, but piston_angle might be better.

You can check me by drawing a picture, and I’ll put one in the article if I draw a decent one, but it seems clear that there is a correspondence between having the piston angle zero and having it at other angles. For example, the picture is the same between these:

  1. Piston angle zero, crank angle 90;
  2. Piston angle 45, crank angle 135;
  3. Piston angle 90, crank angle 180.

Each of these looks like a simple right triangle. The test net angle 90 above checks that case for piston angle zero, because that’s the only case we’ve coded.

But if we could take #2 or #3 above, and turn the picture to make the piston angle zero, this code would solve it, and then we could just turn the picture back and read off the coordinates.

That’s what I plan to do now. To do that, we need Yet Another Parameter in our function. I’ll try crank_angle and piston_angle as names.

Change the function to expect that value and change the tests to supply it. Write new test:

_:test("piston angle 90 straight shot", function()
    local wheel_pos = vector(0, 0)
    local r = 1
    local crank_angle = 90
    local piston_angle = 90
    local d = 5
    local pt = point_on_line_at_distance(wheel_pos, r, crank_angle, piston_angle, d)
    _:expect(pt.y).is(6, 0.001)
    _:expect(pt.x).is(0, 0.001)
end)

This should fail, with a 4.8989 kind of flavor, I think.

Actual: 0, Expected: 6 +/- 0.001. 
Test: 'piston angle 90 straight shot'.
Actual: 4.898979485566356, Expected: 0 +/- 0.001. 
Test: 'piston angle 90 straight shot'.

Perfect. We got the answer for 90, because we are not compensating for the piston_angle yet. Here’s the function now:

local function point_on_line_at_distance(
    wheel_center, radius, crank_angle, piston_angle, distance)
    local theta = crank_angle*DEG_TO_RAD
    local crank_pos = vector(radius,0):rotate_2d(theta)
    local y = crank_pos.y
    local dx = math.sqrt(distance*distance - y*y)
    local result_x = crank_pos.x + dx
    local answer = vector(wheel_center.x + result_x, 0)
    print(answer)
    return answer
end

We need to reduce theta by the piston angle … and then rotate the answer back by that angle. We’ll need to adjust the function fairly substantially. I’ll try it once, and if that doesn’t work, devise a smaller step. I think I can do it.

local function point_on_line_at_distance(
    wheel_center, radius, crank_angle, piston_angle, distance)
    local theta = (crank_angle - piston_angle)*DEG_TO_RAD
    local crank_pos = vector(radius,0):rotate_2d(theta)
    local y = crank_pos.y
    local dx = math.sqrt(distance*distance - y*y)
    local unrotated_x = crank_pos.x + dx
    local unrotated_answer = vector(unrotated_x, 0)
    local answer = unrotated_answer:rotate_2d(piston_angle*DEG_TO_RAD)
    print(answer)
    return answer
end

I adjust the angle theta that we use in the basic calculation, then compute the raw answer, then rotate it by the adjustment. This is nearly right. All the tests pass. However, we’re not adjusting for the wheel center. We need another test.

_:test("piston angle 90 with non-zero wheel pos", function()
    local wheel_pos = vector(7,3)
    local r = 1
    local crank_angle = 90
    local piston_angle = 90
    local d = 5
    local pt = point_on_line_at_distance(wheel_pos, r, crank_angle, piston_angle, d)
    _:expect(pt.y).is(6+3, 0.001)
    _:expect(pt.x).is(0+7, 0.001)
end)

This should fail, still (6,0). And it does. Fix the function:

local function point_on_line_at_distance(
    wheel_center, radius, crank_angle, piston_angle, distance)
    local theta = (crank_angle - piston_angle)*DEG_TO_RAD
    local crank_pos = vector(radius,0):rotate_2d(theta)
    local y = crank_pos.y
    local dx = math.sqrt(distance*distance - y*y)
    local unrotated_x = crank_pos.x + dx
    local unrotated_answer = vector(unrotated_x, 0)
    local local_answer = unrotated_answer:rotate_2d(piston_angle*DEG_TO_RAD)
    local answer = local_answer + wheel_center
    -- print(answer)
    return answer
end

And we have the answer we expect. I am satisfied that this function does the right thing. I do not love that it needs five parameters. But I think they’re all necessary.

Let’s refactor the function a bit and call it a morning.

local function point_on_line_at_distance(
    wheel_center, radius, crank_angle, piston_angle, distance)
    local theta = (crank_angle - piston_angle)*DEG_TO_RAD
    local crank_pos = vector(radius,0):rotate_2d(theta)
    local dy = crank_pos.y
    local dx = math.sqrt(distance*distance - dy*dy)
    local local_x = crank_pos.x + dx
    local local_pos = vector(local_x, 0):rotate_2d(piston_angle*DEG_TO_RAD)
    return local_pos + wheel_center
end

I think that’s better but not great. I think that better names would help make it more clear what’s going on. I feel that I owe a diagram for this article, so lI’ll work on that and see if it suggests a better naming convention.

Looking at the function as it stands, we see that we don’t use wheel_center until the very end. That suggests that we might have a useful four-parameter function That assumes it’s working at (0,0) and then adjust the answer outside. We might try that later as well.

For now, I think we have a general solution using a transformation, which frankly I think is rather nice.

Safe paths!