Samples an animation at time t. Wraps t for loops. Finds the keyframe segment containing t and linearly interpolates per-bone angles. Returns frame with angles in degrees (intuitive for tests and callers; internal storage uses radians). Edge cases handled: - t = 0 returns first-keyframe values - t = duration returns last-keyframe values (no overshoot) - t > duration with loop=true wraps via modulo - Keyframes with disjoint bone-sets: each bone interpolated where available, snapped where only one side has it.
236 lines
8.4 KiB
Lua
236 lines
8.4 KiB
Lua
-- lib-core.puppet v0.1.0
|
|
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
|
|
-- + procedural + look-at constraint. No footplant in v0.1.
|
|
|
|
local M = {}
|
|
|
|
-- ====================================================================
|
|
-- Module state (lib-singleton)
|
|
-- ====================================================================
|
|
local all_puppets = {} -- {[handle] = puppet_instance}
|
|
local next_handle = 1
|
|
|
|
-- ====================================================================
|
|
-- Utility helpers
|
|
-- ====================================================================
|
|
local function deep_copy(t)
|
|
if type(t) ~= "table" then return t end
|
|
local copy = {}
|
|
for k, v in pairs(t) do copy[k] = deep_copy(v) end
|
|
return copy
|
|
end
|
|
|
|
local function clamp_angle_rad(a)
|
|
-- Normalize to [-pi, pi]
|
|
local pi = math.pi
|
|
while a > pi do a = a - 2 * pi end
|
|
while a < -pi do a = a + 2 * pi end
|
|
return a
|
|
end
|
|
|
|
-- Subsequent tasks add: sample_animation,
|
|
-- spawn, despawn, update, render, set_look_target, clear_look_target,
|
|
-- bone_angle, set_procedural, clear_procedural, write_bone, play, stop,
|
|
-- stop_all, is_playing, position, facing, move_to, register_animation,
|
|
-- load_rig, load_animation.
|
|
|
|
-- ====================================================================
|
|
-- Rig validation + build
|
|
-- ====================================================================
|
|
function M.build_rig(rig_table)
|
|
if type(rig_table) ~= "table" then
|
|
error("puppet.build_rig: rig_table must be a table")
|
|
end
|
|
if type(rig_table.bones) ~= "table" or #rig_table.bones == 0 then
|
|
error("puppet.build_rig: rig.bones must be a non-empty array")
|
|
end
|
|
if type(rig_table.tracks) ~= "table" then
|
|
error("puppet.build_rig: rig.tracks must be an array")
|
|
end
|
|
|
|
-- Build bones_by_id with shallow copies of rest pose.
|
|
local bones_by_id = {}
|
|
for _, b in ipairs(rig_table.bones) do
|
|
if type(b.id) ~= "string" then
|
|
error("puppet.build_rig: bone.id must be string")
|
|
end
|
|
if bones_by_id[b.id] ~= nil then
|
|
error("puppet.build_rig: duplicate bone id: " .. b.id)
|
|
end
|
|
bones_by_id[b.id] = {
|
|
id = b.id,
|
|
parent = nil, -- resolved below
|
|
rest = {
|
|
x = b.rest.x, y = b.rest.y,
|
|
-- JSON angles in degrees; convert to radians once.
|
|
angle = math.rad(b.rest.angle or 0),
|
|
},
|
|
look_at = (b.look_at == true),
|
|
color = b.color or { 200, 200, 200 },
|
|
}
|
|
end
|
|
|
|
-- Resolve parents (object references).
|
|
for _, b_src in ipairs(rig_table.bones) do
|
|
local b = bones_by_id[b_src.id]
|
|
if b_src.parent ~= nil then
|
|
local parent_obj = bones_by_id[b_src.parent]
|
|
if parent_obj == nil then
|
|
error("puppet.build_rig: parent '" .. tostring(b_src.parent)
|
|
.. "' of bone '" .. b.id .. "' not found")
|
|
end
|
|
b.parent = parent_obj
|
|
end
|
|
end
|
|
|
|
-- Build tracks + validate each bone is in EXACTLY ONE track.
|
|
local bone_to_track = {}
|
|
local tracks_by_id = {}
|
|
for _, t in ipairs(rig_table.tracks) do
|
|
if type(t.id) ~= "string" then
|
|
error("puppet.build_rig: track.id must be string")
|
|
end
|
|
if tracks_by_id[t.id] ~= nil then
|
|
error("puppet.build_rig: duplicate track id: " .. t.id)
|
|
end
|
|
local bones_in_track = {}
|
|
for _, bid in ipairs(t.bones) do
|
|
if bones_by_id[bid] == nil then
|
|
error("puppet.build_rig: track '" .. t.id
|
|
.. "' references unknown bone '" .. bid .. "'")
|
|
end
|
|
if bone_to_track[bid] ~= nil then
|
|
error("puppet.build_rig: bone '" .. bid
|
|
.. "' assigned to multiple tracks ('"
|
|
.. bone_to_track[bid] .. "' and '" .. t.id .. "')")
|
|
end
|
|
bone_to_track[bid] = t.id
|
|
bones_in_track[#bones_in_track + 1] = bid
|
|
end
|
|
tracks_by_id[t.id] = { id = t.id, bones = bones_in_track }
|
|
end
|
|
|
|
return {
|
|
id = rig_table.id or "anonymous",
|
|
bones = rig_table.bones, -- keep original-order array
|
|
bones_by_id = bones_by_id,
|
|
tracks_by_id = tracks_by_id,
|
|
bone_to_track = bone_to_track,
|
|
}
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Animation validation + build
|
|
-- ====================================================================
|
|
function M.build_animation(anim_table, rig)
|
|
if type(anim_table) ~= "table" then
|
|
error("puppet.build_animation: anim_table must be a table")
|
|
end
|
|
if type(anim_table.track) ~= "string" then
|
|
error("puppet.build_animation: animation.track must be string")
|
|
end
|
|
if rig.tracks_by_id[anim_table.track] == nil then
|
|
error("puppet.build_animation: animation.track '"
|
|
.. anim_table.track .. "' not found in rig")
|
|
end
|
|
if type(anim_table.duration) ~= "number" or anim_table.duration <= 0 then
|
|
error("puppet.build_animation: animation.duration must be positive number")
|
|
end
|
|
if type(anim_table.keyframes) ~= "table" or #anim_table.keyframes == 0 then
|
|
error("puppet.build_animation: animation.keyframes must be non-empty array")
|
|
end
|
|
|
|
-- Validate each keyframe: t in [0, duration], bones referenced are in this animation's track.
|
|
local track_bones = {}
|
|
for _, bid in ipairs(rig.tracks_by_id[anim_table.track].bones) do
|
|
track_bones[bid] = true
|
|
end
|
|
|
|
local keyframes = {}
|
|
for i, kf in ipairs(anim_table.keyframes) do
|
|
if type(kf.t) ~= "number" or kf.t < 0 or kf.t > anim_table.duration + 1e-9 then
|
|
error("puppet.build_animation: keyframe[" .. i .. "].t out of [0, duration]")
|
|
end
|
|
local cooked = { t = kf.t, bones = {} }
|
|
for k, v in pairs(kf) do
|
|
if k ~= "t" then
|
|
if rig.bones_by_id[k] == nil then
|
|
error("puppet.build_animation: keyframe references unknown bone '" .. k .. "'")
|
|
end
|
|
if not track_bones[k] then
|
|
error("puppet.build_animation: keyframe writes bone '" .. k
|
|
.. "' which is not in animation's track '" .. anim_table.track .. "'")
|
|
end
|
|
cooked.bones[k] = {
|
|
angle = math.rad(v.angle or 0), -- JSON degrees → radians
|
|
}
|
|
end
|
|
end
|
|
keyframes[i] = cooked
|
|
end
|
|
|
|
return {
|
|
id = anim_table.id or "anonymous",
|
|
track = anim_table.track,
|
|
duration = anim_table.duration,
|
|
loop = (anim_table.loop == true),
|
|
keyframes = keyframes,
|
|
}
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Animation sampling
|
|
-- ====================================================================
|
|
function M.sample_animation(anim, t)
|
|
-- Wrap t for loops.
|
|
if anim.loop and t > anim.duration then
|
|
t = t % anim.duration
|
|
end
|
|
if t < 0 then t = 0 end
|
|
if t > anim.duration then t = anim.duration end
|
|
|
|
local kfs = anim.keyframes
|
|
-- Find segment [kf_i, kf_{i+1}] containing t.
|
|
local prev_kf, next_kf = kfs[1], kfs[1]
|
|
for i = 1, #kfs - 1 do
|
|
if t >= kfs[i].t and t <= kfs[i + 1].t then
|
|
prev_kf = kfs[i]
|
|
next_kf = kfs[i + 1]
|
|
break
|
|
end
|
|
end
|
|
|
|
-- If t equals last keyframe's t (or beyond and not looping), snap to last.
|
|
if t >= kfs[#kfs].t then
|
|
prev_kf = kfs[#kfs]
|
|
next_kf = kfs[#kfs]
|
|
end
|
|
|
|
-- Linear interpolation per bone present in either keyframe.
|
|
local frame = {}
|
|
local span = next_kf.t - prev_kf.t
|
|
local alpha = (span > 0) and ((t - prev_kf.t) / span) or 0
|
|
|
|
local all_bones = {}
|
|
for bid, _ in pairs(prev_kf.bones) do all_bones[bid] = true end
|
|
for bid, _ in pairs(next_kf.bones) do all_bones[bid] = true end
|
|
|
|
for bid, _ in pairs(all_bones) do
|
|
local prev_v = prev_kf.bones[bid]
|
|
local next_v = next_kf.bones[bid]
|
|
if prev_v and next_v then
|
|
frame[bid] = {
|
|
angle = math.deg(prev_v.angle * (1 - alpha) + next_v.angle * alpha),
|
|
}
|
|
elseif prev_v then
|
|
frame[bid] = { angle = math.deg(prev_v.angle) }
|
|
elseif next_v then
|
|
frame[bid] = { angle = math.deg(next_v.angle) }
|
|
end
|
|
end
|
|
|
|
return frame
|
|
end
|
|
|
|
return M
|