diff --git a/init.lua b/init.lua index 57597d8..5707329 100644 --- a/init.lua +++ b/init.lua @@ -260,7 +260,10 @@ function M.spawn(rig, pos) -- Per-bone live state (current angle, current world-pos cache). local bone_state = {} for bid, b in pairs(rig.bones_by_id) do - bone_state[bid] = { angle = b.rest.angle, world_x = 0, world_y = 0 } + bone_state[bid] = { + angle = b.rest.angle, + world = { x = 0, y = 0, angle = b.rest.angle }, + } end all_puppets[handle] = { @@ -387,56 +390,155 @@ function M.write_bone(handle, bone_id, values) end -- ==================================================================== --- Update pipeline (per-frame) +-- World-transform query (public API; reads from cached bone_state.world) +-- ==================================================================== +function M.bone_world_transform(handle, bone_id) + local p = all_puppets[handle] + if p == nil then + error("puppet.bone_world_transform: handle " .. tostring(handle) .. " not spawned") + end + local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world + if ws == nil then + error("puppet.bone_world_transform: bone '" .. tostring(bone_id) .. "' has no cached world-transform") + end + return ws.x, ws.y, ws.angle +end + +-- ==================================================================== +-- Test-only: direct bone-angle write bypassing track-conflict guard. +-- Not for production module use. +-- ==================================================================== +function M.write_bone_test_only(handle, bone_id, angle_rad) + local p = all_puppets[handle] + if p == nil then + error("puppet.write_bone_test_only: handle " .. tostring(handle) .. " not spawned") + end + if p.bone_state[bone_id] == nil then + error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig") + end + p.bone_state[bone_id].angle = angle_rad +end + +-- ==================================================================== +-- Forward-declared recursive helper (forward decl required for child-recursion). +-- ==================================================================== +local update_bone_recursive + +-- ==================================================================== +-- Update pipeline (per-frame, depth-first traversal). +-- +-- For each puppet: +-- 1. Reset all bones to rest pose +-- 2. Advance keyframe-anim sample-times +-- 3. Depth-first traversal root -> children: +-- a. Sample keyframe for bone if on active track +-- b. Apply look-at if bone has look_at=true (uses parent's already-cascaded world) +-- c. Run procedural callbacks (once per puppet at root-visit) +-- d. Compute world-transform from parent.world + own rest + own angle -- ==================================================================== function M.update(dt) for handle, p in pairs(all_puppets) do - -- 1. Reset bones to rest pose. + -- 1. Reset to rest pose. for bid, b in pairs(p.rig.bones_by_id) do p.bone_state[bid].angle = b.rest.angle end - -- 2. Apply look-at constraint (writes to bones marked look_at: true). - if p.look_target ~= nil then - for bid, b in pairs(p.rig.bones_by_id) do - if b.look_at then - -- Compute bone's world-pos by chaining parents. - -- For minimal v0.1: assume bone's parent is at puppet origin (torso). - -- Simplified: bone world-pos = puppet pos + bone.rest offset. - local bone_world_x = p.x + b.rest.x - local bone_world_y = p.y + b.rest.y - local dx = p.look_target.x - bone_world_x - local dy = p.look_target.y - bone_world_y - p.bone_state[bid].angle = math.atan(dy, dx) - end - end - end - - -- 3. Advance + apply keyframe animations. + -- 2. Advance keyframe sample-times. for anim_id, play_state in pairs(p.playing) do local anim = p.animations[anim_id] play_state.t = play_state.t + dt * play_state.speed if play_state.loop and play_state.t > anim.duration then play_state.t = play_state.t % anim.duration end - local frame = M.sample_animation(anim, play_state.t) - for bid, bone_kf in pairs(frame) do - p.bone_state[bid].angle = bone_kf.angle - end end - -- 4. Apply procedural callbacks (via write_bone -- auto-validated). - -- Use pcall per callback so that a track-conflict from one callback - -- removes only that offending procedural and re-raises, rather than - -- leaving other puppets' callbacks un-executed on subsequent calls. + -- 3. Depth-first traversal: roots first, then children, recursively. + for _, b_src in ipairs(p.rig.bones) do + local b = p.rig.bones_by_id[b_src.id] + if b.parent == nil then + update_bone_recursive(p, b, dt) + end + end + end +end + +-- ==================================================================== +-- Depth-first update of one bone, then its children. +-- ==================================================================== +update_bone_recursive = function(p, b, dt) + local bid = b.id + + -- 3.a Sample keyframe for this bone from any playing anim on its track. + local track_id = p.rig.bone_to_track[bid] + if track_id then + for anim_id, play_state in pairs(p.playing) do + local anim = p.animations[anim_id] + if anim and anim.track == track_id then + local frame = M.sample_animation(anim, play_state.t) + local bone_kf = frame[bid] + if bone_kf and bone_kf.angle ~= nil then + p.bone_state[bid].angle = bone_kf.angle -- already radians per sample_animation + end + end + end + end + + -- 3.b Apply look-at constraint if this bone is marked look_at. + if b.look_at and p.look_target ~= nil then + -- Compute this bone's world-pos from parent's world + own rest offset + -- (parent's world has been computed earlier in the traversal). + local px, py, pa + if b.parent then + local pw = p.bone_state[b.parent.id].world + px, py, pa = pw.x, pw.y, pw.angle + else + px, py, pa = p.x, p.y, 0 + end + local cos_a, sin_a = math.cos(pa), math.sin(pa) + local lx, ly = b.rest.x, b.rest.y + local bone_world_x = px + lx * cos_a - ly * sin_a + local bone_world_y = py + lx * sin_a + ly * cos_a + -- Direction to target in world-space. + local dx = p.look_target.x - bone_world_x + local dy = p.look_target.y - bone_world_y + -- World-angle for look-at = atan2(dy, dx). Convert to local (subtract parent's accumulated). + local world_angle = math.atan(dy, dx) + p.bone_state[bid].angle = world_angle - pa + end + + -- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute). + if b.parent == nil then for name, cb in pairs(p.procedural) do - local ok, err = pcall(cb, handle, dt) + local ok, err = pcall(cb, p.handle, dt) if not ok then p.procedural[name] = nil -- disarm offending callback to avoid repeat errors error(err, 0) -- re-raise so caller (or test pcall) sees the conflict end end end + + -- 3.d Compute this bone's world-transform from parent + own. + local px, py, pa + if b.parent then + local pw = p.bone_state[b.parent.id].world + px, py, pa = pw.x, pw.y, pw.angle + else + px, py, pa = p.x, p.y, 0 + end + local cos_a, sin_a = math.cos(pa), math.sin(pa) + local lx, ly = b.rest.x, b.rest.y + local wx = px + lx * cos_a - ly * sin_a + local wy = py + lx * sin_a + ly * cos_a + local wa = pa + p.bone_state[bid].angle + p.bone_state[bid].world = { x = wx, y = wy, angle = wa } + + -- Recurse to children. + for _, child_src in ipairs(p.rig.bones) do + local child = p.rig.bones_by_id[child_src.id] + if child.parent == b then + update_bone_recursive(p, child, dt) + end + end end -- ====================================================================