-- 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