Skip to main content

concinnity_dev/editor/
mod.rs

1// src/editor/mod.rs
2//
3// The `cn editor` run path. Unlike `cn debug` (which compiles world.jsonl fully
4// in memory and stands up a WebSocket command channel), the editor reads the
5// already-compiled blobs on startup, overlays an injected editor HUD, and
6// persists edits by recompiling on SAVE. An optional debug port reuses the
7// existing debug server so `cn debug send` / `screenshot` can inspect a session.
8
9mod asset_list;
10mod asset_tree;
11mod axes;
12mod behavior;
13mod behavior_chart;
14mod behavior_panel;
15mod billboards;
16mod character_shape;
17mod character_shape_panel;
18mod console;
19mod console_panel;
20mod content_panel;
21mod create_menu;
22mod cursor;
23mod file_dialog;
24mod filter;
25mod form;
26mod form_panel;
27mod framing;
28mod gizmo;
29mod gltf_export;
30mod group_transform;
31mod health;
32mod health_panel;
33mod highlight;
34mod history;
35mod hook;
36mod hud;
37mod import_panel;
38mod inject;
39mod lighting;
40mod lighting_panel;
41mod list_panel;
42mod live;
43mod marquee;
44pub(crate) mod notify;
45mod orbit;
46mod outlines;
47mod overrides;
48mod palette;
49mod palette_panel;
50mod panel;
51mod preview;
52mod registry;
53mod resize;
54mod select_related;
55mod selection;
56mod session_store;
57mod sim;
58mod snap;
59mod story;
60mod story_panel;
61mod template_panel;
62mod templates;
63mod theme;
64mod thumbs;
65mod toast_overlay;
66mod variables;
67mod variables_panel;
68mod view;
69mod view_menu;
70mod visibility;
71mod widget;
72mod widget_slider;
73
74use crate::app::state::App;
75use crate::debug_hook::DebugHook;
76use crate::ecs::World;
77use crate::world::{WORLD_JSONL, find_world_jsonl};
78use concinnity_engine::shutdown::ShutdownToken;
79use hook::EditorHook;
80
81// A minimal renderable world: a lone GraphicsConfig, which the cook pipeline
82// expands into a Window plus default shaders. Booted in memory when there is
83// nothing renderable to load (no world file, or an authored world with no
84// render marker), so the editor still opens a window over a black scene. Named
85// distinctively so it never collides with an authored asset, and it is never
86// added to the authored entry list, so it can never leak into the user's
87// world.jsonl on SAVE.
88const SEED_GRAPHICS_CONFIG: &str =
89    "{\"name\":\"editor_default_gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}";
90
91/// Editor entry point (`cn editor`). Brings up a renderable world -- building the
92/// blobs first if they are missing, and falling back to an empty in-memory world
93/// when there is nothing to load -- injects the editor HUD, and runs the world
94/// loop driven by the editor hook (plus the debug server when a port is given).
95pub fn run_editor(json_path: Option<&str>, debug_port: Option<u16>) -> std::io::Result<()> {
96    // Instead of the engine's plain `init_logging`: the same stderr formatter
97    // plus a layer mirroring this crate's events into the Console panel's log.
98    // The sink exists first so even boot-time errors reach the panel.
99    let console_sink = console::ConsoleSink::default();
100    console::install_tracing(console_sink.clone());
101
102    // Resolve the edit target -- the world.jsonl where readable names live and
103    // where SAVE writes. A missing file is not an error: the editor opens an
104    // empty world and creates the file on the first SAVE.
105    let (world_path, world_exists) = resolve_edit_target(json_path);
106
107    // Hand the resolved path to the engine so the hot-reload watcher
108    // subscribes to this world.jsonl. The engine no longer discovers it;
109    // world.jsonl lookup is authoring I/O in concinnity-cook.
110    concinnity_engine::app::dev_flags::set_world_jsonl_path(Some(world_path.clone()));
111
112    // Parse the authored entry list up front so edits patch it directly (empty
113    // when the file does not exist yet).
114    let entries = if world_exists {
115        let content = std::fs::read_to_string(&world_path)?;
116        crate::world::parse_world_jsonl(&content)
117            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?
118    } else {
119        Vec::new()
120    };
121
122    // Bring up a renderable world: build the blobs if needed, load them, and
123    // seed an empty world when there is nothing renderable to show.
124    let mut app = App::new();
125    boot_world(&mut app, &world_path, world_exists, &entries)?;
126
127    // Inject the editor HUD elements before start (this also drops the world's
128    // DebugHud, whose F1 role the editor takes over); the editor's DebugHook
129    // tick drives them each frame.
130    inject::editor_hud(app.world_mut());
131
132    // Every editor session hot-reloads file-backed assets; with a debug port
133    // the DebugServer owns the reload driver (so the WS `reload-assets`
134    // command reaches its flag), without one the driver runs as its own hook.
135    // Either way the session holds exactly one driver, so a reload is never
136    // applied twice.
137    let editor_hook = EditorHook::new(world_path, entries).with_console_sink(console_sink);
138    let hook: Box<dyn DebugHook> = match debug_port {
139        Some(port) => {
140            let server =
141                crate::debug::DebugServer::start(port)?.with_notifier(editor_hook.notifier());
142            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(server)])
143        }
144        None => {
145            let reload = crate::debug::hot_reload::HotReloadDriver::new()
146                .with_notifier(editor_hook.notifier());
147            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(reload)])
148        }
149    };
150
151    crate::run::start_app(app, Some(hook))
152}
153
154// Resolve the world.jsonl the editor edits, and whether it exists yet. An
155// explicit path is taken as-is (present or not, so a brand-new file can be
156// named); with no path, the most-recent world is used, falling back to the
157// default `world.jsonl` name for a fresh, not-yet-saved world.
158fn resolve_edit_target(json_path: Option<&str>) -> (String, bool) {
159    match json_path {
160        Some(p) => (p.to_string(), std::path::Path::new(p).exists()),
161        None => match find_world_jsonl(None) {
162            Ok(p) => (p, true),
163            Err(_) => (WORLD_JSONL.to_string(), false),
164        },
165    }
166}
167
168// Populate `app` with a renderable world for editing:
169//   * build the blobs first if the world has content but has not been compiled
170//     yet (`cn build` as a library call);
171//   * load the compiled blobs when present;
172//   * if there is still nothing renderable (no world file, an authored world
173//     with no render marker, or an empty build), boot a minimal in-memory world
174//     seeded with a GraphicsConfig so the editor still opens a window.
175fn boot_world(
176    app: &mut App,
177    world_path: &str,
178    world_exists: bool,
179    entries: &[serde_json::Value],
180) -> std::io::Result<()> {
181    let blobs_present =
182        || concinnity_host::store::paths::data_dir().is_some_and(|d| d.join("0").exists());
183
184    // Build if the world has content the compiled blobs do not reflect yet.
185    if world_exists && !blobs_present() {
186        crate::build_world_to_disk(world_path)?;
187    }
188
189    // Load the compiled blobs when they exist; the primary render source.
190    if blobs_present() {
191        app.load_blob().map_err(|e| {
192            std::io::Error::new(
193                std::io::ErrorKind::InvalidData,
194                format!("failed to load compiled world data: {e:?}"),
195            )
196        })?;
197        // The blobs carry only interned ids; a boot without an in-process cook
198        // has an empty name table, which kills name-keyed picking until the
199        // first edit. Restore it from the lock the build wrote. Best effort: a
200        // missing lock only means the pre-existing degraded behavior.
201        match crate::authoring::name_table::prime_from_lock_file() {
202            Ok(n) if n > 0 => tracing::info!("editor: primed {n} asset names from the build lock"),
203            Ok(_) => {}
204            Err(e) => tracing::warn!("editor: could not prime asset names: {e}"),
205        }
206        // The in-memory build installs the hot-reload source catalogues as it
207        // compiles; a blob boot reconstructs them from the lock + the authored
208        // entries so file-backed assets reload here too. Best effort, like the
209        // name priming above.
210        match crate::authoring::reload_sources::install_from_lock(app.world_mut(), entries) {
211            Ok(n) if n > 0 => {
212                tracing::info!("editor: recovered {n} hot-reload source(s) from the build lock");
213            }
214            Ok(_) => {}
215            Err(e) => tracing::warn!("editor: could not recover hot-reload sources: {e}"),
216        }
217    }
218
219    // Fall back to an in-memory seed when nothing renderable was loaded, so a
220    // window still opens over a black scene.
221    if !concinnity_engine::ecs::renders(app.world()) {
222        let base = if world_exists {
223            std::fs::read_to_string(world_path)?
224        } else {
225            String::new()
226        };
227        let world = crate::build_world_from_str(&seeded_content(&base))?;
228        app.load_world(world);
229    }
230
231    Ok(())
232}
233
234// Guarantee a render marker: append the seed GraphicsConfig to the authored
235// content (only reached when the world does not otherwise render, so there is
236// no existing GraphicsConfig to collide with).
237fn seeded_content(base: &str) -> String {
238    if base.trim().is_empty() {
239        SEED_GRAPHICS_CONFIG.to_string()
240    } else {
241        format!("{base}\n{SEED_GRAPHICS_CONFIG}")
242    }
243}
244
245// Fan a single per-frame drive out to several hooks. Lets the editor run its own
246// hook and the debug server side by side without either owning the other.
247struct MultiHook {
248    hooks: Vec<Box<dyn DebugHook>>,
249}
250
251impl MultiHook {
252    fn boxed(hooks: Vec<Box<dyn DebugHook>>) -> Box<dyn DebugHook> {
253        Box::new(Self { hooks })
254    }
255}
256
257impl DebugHook for MultiHook {
258    fn tick(&mut self, world: &mut World) {
259        for hook in &mut self.hooks {
260            hook.tick(world);
261        }
262    }
263
264    fn apply_world_swap(&mut self, app: &mut crate::app::state::App) {
265        for hook in &mut self.hooks {
266            hook.apply_world_swap(app);
267        }
268    }
269
270    fn attach_shutdown(&mut self, shutdown: ShutdownToken) {
271        for hook in &mut self.hooks {
272            hook.attach_shutdown(shutdown.clone());
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    // An empty (or whitespace-only) world seeds to just the render marker, so an
282    // empty session still opens a window.
283    #[test]
284    fn seeded_content_of_empty_is_the_render_marker() {
285        assert_eq!(seeded_content(""), SEED_GRAPHICS_CONFIG);
286        assert_eq!(seeded_content("   \n"), SEED_GRAPHICS_CONFIG);
287    }
288
289    // Authored content keeps its entries and gains the render marker on its own
290    // line, so the combined string still parses as one asset per line.
291    #[test]
292    fn seeded_content_appends_marker_to_authored_content() {
293        let base = "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}";
294        let seeded = seeded_content(base);
295        let parsed = crate::world::parse_world_jsonl(&seeded).unwrap();
296        assert_eq!(parsed.len(), 2, "authored entry plus the seed marker");
297        assert_eq!(parsed[0]["name"], "phys");
298        assert_eq!(parsed[1]["type"], "GraphicsConfig");
299    }
300
301    // The seed marker is itself a well-formed, renderable asset line.
302    #[test]
303    fn seed_marker_is_a_graphics_config() {
304        let parsed = crate::world::parse_world_jsonl(SEED_GRAPHICS_CONFIG).unwrap();
305        assert_eq!(parsed.len(), 1);
306        assert_eq!(parsed[0]["type"], "GraphicsConfig");
307    }
308
309    // An explicit path is taken verbatim; its existence is reported so a
310    // brand-new (not-yet-saved) file boots as an empty world rather than erroring.
311    #[test]
312    fn resolve_edit_target_honors_an_explicit_path() {
313        let (path, exists) = resolve_edit_target(Some("/no/such/cn-editor-world.jsonl"));
314        assert_eq!(path, "/no/such/cn-editor-world.jsonl");
315        assert!(
316            !exists,
317            "a missing explicit path is reported absent, not an error"
318        );
319    }
320}