concinnity-dev 0.19.119

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
//! Per-frame drive of the asset / shader / world.jsonl hot-reload passes,
//! shared by every dev session. A plain `cn editor` runs the driver as its own
//! per-frame hook; `cn debug` (and `cn editor --debug-port`) drive it from
//! inside `DebugServer::tick`, which layers the debug-server-only concerns
//! (runtime spawn commands, camera motion) around it. Each session constructs
//! exactly one driver, so a reload is never applied twice.

use concinnity_core::animation::skeleton;
use concinnity_core::components::SkeletonPose;
use concinnity_core::components::StoryReload;
use concinnity_core::ecs::World;
use concinnity_core::gfx::render_types::SkinnedIndex;
use concinnity_engine::live_edit;
use std::sync::Arc;

use super::report::ReloadReports;
use super::signals::ReloadSignals;
use super::state::{AssetHotReloadState, FrameHotReloadEffects, run_frame};
use super::world_path::WorldPathHandle;
use crate::frame_hook::FrameHook;

pub(crate) struct HotReloadDriver {
    // Reload catalog + filesystem watcher + in-flight decode handles. Armed
    // from the sources graphics init parked, on the first tick that
    // finds them, and re-armed whenever a fresh capture appears (the editor's
    // live preview rebuild re-runs init), so the catalog never goes stale
    // against the current backend slots.
    pub(super) state: Option<AssetHotReloadState>,
    // The reload requests every armed state's watcher and the `reload-assets`
    // tool call raise. One per driver, kept across re-arms.
    signals: Arc<ReloadSignals>,
    // The editor's toast queue, when driving inside an editor session: the
    // reload passes report apply results through it. `None` under a bare
    // `cn debug`, which has no toast surface.
    notifier: Option<crate::editor::notify::Notifier>,
    // The session's world.jsonl path, read at every arm so a world switch is
    // watched from the next rebuild on. `None` watches no world file.
    world_path: Option<WorldPathHandle>,
    // Where each Shader's and SdfVolume's latest reload outcome is published
    // for an editor session's panels. `None` outside an editor session.
    reload_reports: Option<ReloadReports>,
}

impl HotReloadDriver {
    pub(crate) fn new() -> Self {
        Self {
            state: None,
            signals: Arc::default(),
            notifier: None,
            world_path: None,
            reload_reports: None,
        }
    }

    // Report reload results through an editor session's toast queue as well
    // as the log.
    pub(crate) fn with_notifier(mut self, notifier: crate::editor::notify::Notifier) -> Self {
        self.notifier = Some(notifier);
        self
    }

    // Watch the world.jsonl the handle names, as of each arm.
    pub(crate) fn with_world_path(mut self, handle: WorldPathHandle) -> Self {
        self.world_path = Some(handle);
        self
    }

    // Publish each subject's latest reload outcome to `reports`.
    pub(crate) fn with_reload_reports(mut self, reports: ReloadReports) -> Self {
        self.reload_reports = Some(reports);
        self
    }

    // The driver's reload signals, for the `reload-assets` debug tool call.
    // `None` until a tick arms the state; the same signals across re-arms.
    pub(crate) fn signals(&self) -> Option<Arc<ReloadSignals>> {
        self.state.as_ref().map(|_| Arc::clone(&self.signals))
    }

    // Rebuild the reload state from a freshly captured source catalog.
    // Dropping the previous state stops its watcher and abandons any
    // in-flight decode aimed at the replaced world's slots.
    pub(crate) fn arm(&mut self, sources: live_edit::hot_reload_sources::HotReloadSources) {
        let world_jsonl_path = self.world_path.as_ref().map(WorldPathHandle::get);
        let state =
            AssetHotReloadState::from_sources(sources, world_jsonl_path, Arc::clone(&self.signals));
        if let Some(reports) = &self.reload_reports {
            reports.arm(state.shaders.subjects().chain(state.sdf_fields.subjects()));
        }
        self.state = Some(state);
    }

    // Run the reload passes once for this frame and apply their ECS
    // side-effects. A world with no parked backend (or no captured sources)
    // is a cheap no-op.
    pub(crate) fn drive(&mut self, world: &mut World) {
        // Arm (or re-arm after a world rebuild) from the init-parked sources.
        if let Some(sources) = concinnity_engine::live_edit::take_hot_reload_sources(world) {
            self.arm(sources);
        }
        if let Some(anim) = concinnity_engine::ecs::animation_system_mut(world) {
            super::animation::reload_clips_if_pending(anim, &self.signals);
        }
        let Some(state) = self.state.as_mut() else {
            return;
        };
        let handoff = concinnity_engine::live_edit::render_handoff(world);
        let (Some(backend), Some(fog)) = (handoff.backend, handoff.fog) else {
            return;
        };
        let effects = run_frame(state, backend, fog, self.notifier.as_ref());
        if let Some(reports) = &self.reload_reports {
            reports.publish(&effects.reload_reports);
        }
        apply_effects(world, effects);
    }
}

impl FrameHook for HotReloadDriver {
    fn tick(&mut self, world: &mut World) {
        self.drive(world);
    }
}

// Apply the ECS side-effects one reload pass produced, once the system borrow
// is released.
pub(crate) fn apply_effects(world: &mut World, effects: FrameHotReloadEffects) {
    // Splice any skeleton-shape changes into the ECS-owned `SkeletonPose`
    // components so `AnimationSystem` produces right-sized output going
    // forward.
    if !effects.skeleton_updates.is_empty() {
        let index_to_new: std::collections::HashMap<SkinnedIndex, skeleton::Skeleton> = effects
            .skeleton_updates
            .into_iter()
            .map(|u| (u.skinned_index, u.new_skeleton))
            .collect();
        let mut applied = 0usize;
        for pose in world.query_mut::<SkeletonPose>() {
            if let Some(new_skel) = index_to_new.get(&pose.skinned_index) {
                pose.skeleton = new_skel.clone();
                pose.joint_matrices = pose.skeleton.bind_skinning_matrices();
                pose.updated = true;
                applied += 1;
            }
        }
        tracing::info!(
            "asset hot-reload: applied skeleton-shape change to {} SkeletonPose component(s)",
            applied
        );
    }

    // Hand freshly re-compiled story graphs to the story system, which swaps
    // them in while keeping the play position. The drive runs before the
    // world step, so the swap lands the same frame.
    for story in effects.story_updates {
        world
            .events_mut::<StoryReload>()
            .send(StoryReload { story });
    }
}