concinnity-dev 0.19.119

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! The `cn editor` run path. Like `cn debug`, the editor compiles world.jsonl in
//! memory: the session boots from the authored entries, not from the blobs a
//! build left under the state tree, so what opens is always what the world file
//! says. It overlays an injected editor HUD and persists edits by writing
//! world.jsonl. The blobs are refreshed only by an explicit build (`cn build`,
//! or the console's cook command). An optional debug port reuses the existing
//! debug server so an MCP client can inspect and drive a session.
//!
//! `cn editor -f <world>` opens that world. With no world named the session
//! opens an empty scene under the Worlds panel (`editor/worlds/`), which lists
//! the project's worlds and opens, creates, or deletes one.
//!
//! The subsystem splits in one place and splits there all the way down. `hook/`
//! is the editor as a single per-frame drive: it holds every piece of session
//! state, and it is the only module here that reads a frame's input or decides
//! that the world changes. Every module below is what it drives. The panels and
//! the viewport are pure -- geometry, a model, or math over state handed in --
//! which is what lets them be tested without a window; the few that do reach
//! outside (`session_store`, `file_dialog`, `thumbs`, `gltf_export/`, and
//! `live/` writing the running world) say so on their own line.
//!
//! Where a concern lives follows from how big it got. A panel is one or two
//! files under `panels/`; a panel whose model outgrew that keeps its own
//! directory (`behavior/`, `palette/`, `worlds/`). What each module holds is on
//! the line above its declaration.

// How the editor addresses one asset of the world it is editing: the currency
// of the selection, the open form, and every panel that follows it.
mod asset_handle;
// The Behavior panel's model half: one behavior's authored args as an editable
// node graph, plus the palette, outline and chart views over it.
mod behavior;
// The macOS menu bar: the application menu, and a View menu offering the
// Display chip's rows a second time.
#[cfg(target_os = "macos")]
mod app_menu;
// The monospace face code text draws with, baked once for the process.
mod code_font;
// The viewport's right-click "Create here" menu, anchored at the cursor.
mod create_menu;
// The authored entry list, with the session key every entry is addressed by.
mod entry_list;
// The native file picker behind Import's Browse, and the project-relative
// rewrite its result needs.
mod file_dialog;
// The ranked substring filter every pick list narrows through.
mod filter;
// glTF (.glb) export of a skinned mesh: geometry, skeleton and morph targets.
mod gltf_export;
// Bounded undo / redo stacks over the authored entry list. Pure data.
mod history;
// The editor itself: one per-frame drive holding all of the session state,
// and the only half here that reads input or writes the world.
mod hook;
// The top bar: the full-width strip holding SAVE, the panel chips and the
// transport.
mod hud;
// The reserved asset-id families every injected HUD element is declared in.
mod hud_ids;
// Runtime injection of the HUD's reserved assets into a compiled world,
// between the in-memory compile and `Runtime::start`.
mod inject;
// Applying an edit to the running preview world instead of rebuilding it.
mod live;
// The world map: its places and the moves between them, as a chart.
mod map;
// The confirmation dialog: message, optional name field, and its buttons.
mod modal;
// The toast queue the editor and its workers push into, and each message's
// pure lifetime.
pub(crate) mod notify;
// Extent outlines for assets with spatial reach but no geometry: trigger
// volumes, light ranges, frusta, probe bounds.
mod outlines;
// Copies of the files an entry owns, for a duplicate that must not share them.
mod owned_files;
// Per-field override state for a template-derived asset, whose authored line
// is a sparse patch over what the expansion generated.
mod overrides;
// The command palette's model: every actionable thing the editor can reach.
mod palette;
// Every floating panel: its layout half, its data half, and the registry
// each panel is one entry in.
mod panels;
// Pure resolution for the /select console command.
mod select_related;
// The viewport selection, an ordered set of `AssetHandle`s.
mod selection;
// Per-project session state, persisted as one small CBOR file.
mod session_store;
// The Play / Pause / Step / Stop transport over the preview world. Pure
// state; the hook drives it.
mod sim;
// A multi-line code text surface: its pure editing model and its layout.
mod text_area;
// The chrome's shared palette and metrics, so every surface reads as one.
mod theme;
// The editor's view of the thumbnail set a build baked into the cache.
mod thumbs;
// The toast stack's card geometry and draw.
mod toast_overlay;
// The Display menu: the viewport view mode and the per-session show flags.
mod view_menu;
// Interaction over the scene rather than over a panel: the gizmo and its
// snapping, box-select, the highlight, the world-space furniture, the
// camera math.
mod viewport;
// Pure composition of the two hide mechanisms, the manual set and an isolate.
mod visibility;
// The shared helpers every injected overlay element is placed through.
mod widget;
// A checkbox: box, caption, and an optional note.
mod widget_check;
// A list row's "..." dots and the menu of actions they open.
mod widget_menu;
// A drag slider: track, fill, handle and value label.
mod widget_slider;
// The Worlds panel and the start screen it becomes with no world open.
mod worlds;

use concinnity_cook::authoring::world::WORLD_JSONL;
use concinnity_core::ecs::World;
use concinnity_engine::app::run::LaunchRequest;
use concinnity_engine::app::runtime::Runtime;
use concinnity_engine::shutdown::ShutdownToken;
use hook::EditorHook;

use crate::frame_hook::FrameHook;

// A minimal renderable world: a lone Window, which the cook pipeline expands
// into default shaders around. Booted in memory when there is nothing
// renderable to load (no world file, or an authored world that draws nothing),
// so the editor still opens a window over a black scene. Named distinctively so
// it never collides with an authored asset, and it is never added to the
// authored entry list, so it can never leak into the user's world.jsonl on
// SAVE.
const SEED_WINDOW: &str = "[\"Window\",{\"$id\":\"editor_default_window\"}]";

/// Editor entry point (`cn editor`). Compiles the authored world in memory,
/// injects the editor HUD, and runs the world loop driven by the editor hook
/// (plus the debug server when a port is given).
pub fn run_editor(
    launch: LaunchRequest,
    json_path: Option<&str>,
    debug_port: Option<u16>,
) -> std::io::Result<()> {
    // Instead of the engine's plain `init_logging`: the same stderr formatter
    // plus a layer mirroring this crate's events into the Console panel's log.
    // The sink exists first so even boot-time errors reach the panel.
    let console_sink = panels::console::ConsoleSink::default();
    panels::console::install_tracing(console_sink.clone());

    // Resolve the edit target -- the world.jsonl where readable names live and
    // where SAVE writes -- and whether the session opens on the Worlds panel
    // instead of a world.
    let (world_path, pick_a_world) = resolve_edit_target(json_path);

    // Parse the authored entry list up front so edits patch it directly. A
    // session opening on the start screen edits nothing until a world is picked
    // there, and it boots on nothing: the window comes up on the screen's own
    // listing, and the project's most recent world is compiled behind it a few
    // frames later (`hook/worlds_start.rs`). A world that takes seconds to
    // compile is then waited out on a screen that is up and usable rather than
    // in front of no window at all.
    let (entries, previewing) = if pick_a_world {
        (entry_list::EntryList::default(), start_screen_pick())
    } else {
        let entries = worlds::files::read_entries(std::path::Path::new(&world_path))
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        (entries, None)
    };

    // Bring up a renderable world by compiling those entries, seeding a render
    // marker when they alone would not render.
    let mut runtime = crate::project::runtime().with_launch(launch);
    boot_world(&mut runtime, &entries)?;

    // Inject the editor HUD elements before start (this also drops the world's
    // DebugHud, whose F1 role the editor takes over); the editor's FrameHook
    // tick drives them each frame.
    inject::editor_hud(runtime.world_mut());

    // Every editor session hot-reloads file-backed assets; with a debug port
    // the DebugServer owns the reload driver (so the `reload-assets` debug
    // tool call reaches its flag), without one the driver runs as its own hook.
    // Either way the session holds exactly one driver, so a reload is never
    // applied twice.
    let mut editor_hook = EditorHook::new(world_path, entries)
        .with_console_sink(console_sink)
        .with_app_menu();
    if pick_a_world {
        editor_hook = editor_hook.with_start_screen(previewing);
    }
    let hook: Box<dyn FrameHook> = match debug_port {
        Some(port) => {
            let server = crate::debug::DebugServer::start(port)?
                .with_notifier(editor_hook.notifier())
                .with_world_path(editor_hook.world_path_handle())
                .with_reload_reports(editor_hook.reload_reports());
            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(server)])
        }
        None => {
            let reload = crate::debug::hot_reload::HotReloadDriver::new()
                .with_notifier(editor_hook.notifier())
                .with_world_path(editor_hook.world_path_handle())
                .with_reload_reports(editor_hook.reload_reports());
            MultiHook::boxed(vec![Box::new(editor_hook), Box::new(reload)])
        }
    };

    crate::run::start_app(runtime, Some(hook))
}

// Resolve the world the editor opens on, and whether it opens on the Worlds
// panel rather than on that world. An explicit path is taken as-is (present or
// not, so a brand-new file can be named) and loads straight away. With no path
// the session opens an empty scene and the Worlds panel, which picks the world
// to work on; the path stands in until it does, so a SAVE before any pick
// still lands in the project's `worlds/`.
fn resolve_edit_target(json_path: Option<&str>) -> (String, bool) {
    match json_path {
        Some(p) => (p.to_string(), false),
        None => (unsaved_world_path(), true),
    }
}

// The world the start screen preselects: the project's most recent one. Only
// its path -- reading and compiling it is the screen's own work, done once it
// has a window to show the result in. A project with no worlds preselects
// nothing, which is what the screen's empty listing already says.
fn start_screen_pick() -> Option<String> {
    let world = worlds::files::newest(
        crate::project::worlds_dir().as_deref(),
        crate::project::content_root().as_deref(),
    )?;
    Some(world.path.to_string_lossy().into_owned())
}

// Where the editor puts a world nobody has saved yet.
pub(crate) fn unsaved_world_path() -> String {
    crate::project::worlds_dir()
        .map(|dir| dir.join(WORLD_JSONL).to_string_lossy().into_owned())
        .unwrap_or_else(|| WORLD_JSONL.to_string())
}

// Populate `runtime` with a renderable world for editing, compiled from the
// authored entries in memory. Nothing under the build root is read: the blobs
// there are refreshed only by an explicit build, so they may lag the world file
// the editor is opening.
fn boot_world(runtime: &mut Runtime, entries: &[serde_json::Value]) -> std::io::Result<()> {
    let jsonl = entry_list::build_text(entries)?;
    let (world, _) = build_renderable(&jsonl)?;
    runtime.load_world(world);
    Ok(())
}

// Compile world.jsonl content into a ready-to-run world, plus the template
// baselines its expansion merged authored patches over. Content that would not
// render (an empty world, or authored entries that draw nothing) is recompiled
// with a seeded Window, so a session always opens one. Boot and every
// live-preview rebuild come through here, so what the editor shows never
// depends on which of the two produced it.
fn build_renderable(
    jsonl: &str,
) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
    match crate::authoring::build_world_and_shadows(jsonl) {
        Ok(built) if built.0.renders() => Ok(built),
        _ => crate::authoring::build_world_and_shadows(&seeded_content(jsonl)),
    }
}

// Guarantee something to draw: append the seed Window to the authored content
// (only reached when the world does not otherwise render, so there is no
// existing Window to collide with).
fn seeded_content(base: &str) -> String {
    if base.trim().is_empty() {
        SEED_WINDOW.to_string()
    } else {
        format!("{base}\n{SEED_WINDOW}")
    }
}

// Fan a single per-frame drive out to several hooks. Lets the editor run its own
// hook and the debug server side by side without either owning the other.
struct MultiHook {
    hooks: Vec<Box<dyn FrameHook>>,
}

impl MultiHook {
    fn boxed(hooks: Vec<Box<dyn FrameHook>>) -> Box<dyn FrameHook> {
        Box::new(Self { hooks })
    }
}

impl FrameHook for MultiHook {
    fn tick(&mut self, world: &mut World) {
        for hook in &mut self.hooks {
            hook.tick(world);
        }
    }

    fn apply_world_swap(&mut self, runtime: &mut Runtime) {
        for hook in &mut self.hooks {
            hook.apply_world_swap(runtime);
        }
    }

    fn attach_shutdown(&mut self, shutdown: ShutdownToken) {
        for hook in &mut self.hooks {
            hook.attach_shutdown(shutdown.clone());
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use concinnity_cook::authoring::world::{parse_world_jsonl, write_world_jsonl};
    use concinnity_core::components::TextLabel;

    // An empty (or whitespace-only) world seeds to just the render marker, so an
    // empty session still opens a window.
    #[test]
    fn seeded_content_of_empty_is_the_render_marker() {
        assert_eq!(seeded_content(""), SEED_WINDOW);
        assert_eq!(seeded_content("   \n"), SEED_WINDOW);
    }

    // Authored content keeps its entries and gains the render marker on its own
    // line, so the combined string still parses as one asset per line.
    #[test]
    fn seeded_content_appends_marker_to_authored_content() {
        let base = "[\"PhysicsConfig\",{\"$id\":\"phys\"}]";
        let seeded = seeded_content(base);
        let parsed = parse_world_jsonl(&seeded).unwrap();
        assert_eq!(parsed.len(), 2, "authored entry plus the seed marker");
        assert_eq!(parsed[0]["args"]["$id"], "phys");
        assert_eq!(parsed[1]["type"], "Window");
    }

    // The seed marker is itself a well-formed, renderable asset line.
    #[test]
    fn seed_marker_is_a_window() {
        let parsed = parse_world_jsonl(SEED_WINDOW).unwrap();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0]["type"], "Window");
    }

    // A project whose build root is a `.concinnity/` of its own, as `cn` opens
    // one, with the machine-wide cache so a boot compiles shaders once.
    fn open_project(dir: &std::path::Path) -> std::path::PathBuf {
        let build_root = dir.join(".concinnity");
        crate::project::open(
            concinnity_host::store::paths::StateTree::at(dir)
                .with_build(&build_root)
                .with_cache(concinnity_testing::shared_cache_dir(
                    "concinnity-dev-tests-cache",
                )),
        );
        build_root
    }

    fn entry(name: &str, ty: &str, args: serde_json::Value) -> serde_json::Value {
        serde_json::json!({"type": ty, "args": concinnity_cook::authoring::world::args_with_id(args, name)})
    }

    // A renderable authored world, plus a label whose content identifies which
    // compile a booted world came from.
    fn renderable_entries(label: &str) -> Vec<serde_json::Value> {
        vec![
            entry("cam", "Camera3D", serde_json::json!({})),
            entry("room", "Room", serde_json::json!({})),
            entry("hint", "TextLabel", serde_json::json!({"content": label})),
        ]
    }

    // The content of the booted world's only TextLabel.
    fn booted_label(runtime: &Runtime) -> String {
        runtime
            .world()
            .query::<TextLabel>()
            .next()
            .expect("the authored label is in the booted world")
            .content
            .clone()
    }

    // Boot compiles the authored entries in memory: the world it brings up is
    // the one the entry list describes, and no build output is read or written.
    #[test]
    fn boot_compiles_the_authored_entries_without_touching_the_build_root() {
        let _guard = crate::test_support::lock();
        let dir = concinnity_testing::TempTree::new();
        let build_root = open_project(dir.path());

        let mut runtime = crate::project::runtime();
        boot_world(&mut runtime, &renderable_entries("authored")).expect("the world builds");

        assert!(runtime.world().renders());
        assert_eq!(booted_label(&runtime), "authored");
        assert!(
            !build_root.join("data").exists() && !build_root.join("world-lock.json").exists(),
            "boot writes no blobs and no lock"
        );

        crate::test_support::isolate_state_dir();
    }

    // Blobs a build left behind are ignored: the session shows what the entry
    // list says even when the compiled output on disk says something else, and
    // that output is left exactly as the build wrote it.
    #[test]
    fn boot_ignores_blobs_that_no_longer_match_the_entries() {
        let _guard = crate::test_support::lock();
        let dir = concinnity_testing::TempTree::new();
        let build_root = open_project(dir.path());

        // An explicit build, as `cn build` runs it, over the stale world.
        let world_path = dir.path().join("worlds").join(WORLD_JSONL);
        std::fs::create_dir_all(world_path.parent().unwrap()).unwrap();
        std::fs::write(
            &world_path,
            write_world_jsonl(&renderable_entries("stale")).unwrap(),
        )
        .unwrap();
        let content = std::fs::read_to_string(&world_path).unwrap();
        crate::authoring::build_world_str_to_disk(&content, None).expect("the build writes blobs");
        let blob = concinnity_host::store::blob::primary_in(&build_root.join("data"));
        let before = std::fs::read(&blob).expect("the build wrote a primary blob");

        let mut runtime = crate::project::runtime();
        boot_world(&mut runtime, &renderable_entries("edited")).expect("the world builds");

        assert_eq!(
            booted_label(&runtime),
            "edited",
            "the entries win over the blobs the last build left"
        );
        assert_eq!(
            std::fs::read(&blob).unwrap(),
            before,
            "boot leaves the build output untouched"
        );

        crate::test_support::isolate_state_dir();
    }

    // Nothing renderable to compile still opens a window: an empty entry list
    // boots the seeded render marker.
    #[test]
    fn boot_seeds_a_render_marker_for_an_empty_entry_list() {
        let _guard = crate::test_support::lock();
        crate::test_support::isolate_state_dir();

        let mut runtime = crate::project::runtime();
        boot_world(&mut runtime, &[]).expect("an empty world seeds");
        assert!(runtime.world().renders());
    }

    // An explicit path is taken verbatim and loads directly, panel closed --
    // including one that does not exist yet, which boots as an empty world
    // rather than erroring.
    #[test]
    fn resolve_edit_target_honors_an_explicit_path() {
        let (path, pick) = resolve_edit_target(Some("/no/such/cn-editor-world.jsonl"));
        assert_eq!(path, "/no/such/cn-editor-world.jsonl");
        assert!(!pick, "a named world loads instead of the Worlds panel");
    }

    // With no world named, the session opens on the Worlds panel instead of
    // guessing which of the project's worlds the user meant.
    #[test]
    fn resolve_edit_target_without_a_path_opens_the_worlds_panel() {
        let _guard = crate::test_support::lock();
        crate::test_support::isolate_state_dir();

        let (path, pick) = resolve_edit_target(None);
        assert!(pick, "no named world opens the Worlds panel");
        assert_eq!(path, unsaved_world_path());
    }

    // With no world to discover, the editor opens an unsaved one in the
    // project's `worlds/`, so the first save lands where a build looks.
    #[test]
    fn an_unsaved_world_is_named_inside_the_projects_worlds_directory() {
        // Reading the session's project; opening one is what the guard covers.
        let _guard = crate::test_support::lock();
        crate::test_support::isolate_state_dir();

        let worlds = crate::project::worlds_dir().expect("the harness opened a project");
        assert_eq!(
            unsaved_world_path(),
            worlds.join(WORLD_JSONL).to_string_lossy()
        );
    }
}