concinnity_dev/editor/
mod.rs1mod asset_list;
16mod asset_tree;
17mod axes;
18mod behavior;
19mod behavior_chart;
20mod behavior_panel;
21mod billboards;
22mod character_shape;
23mod character_shape_panel;
24mod console;
25mod console_panel;
26mod content_panel;
27mod create_menu;
28mod cursor;
29mod file_dialog;
30mod filter;
31mod form;
32mod form_panel;
33mod framing;
34mod gizmo;
35mod gltf_export;
36mod group_transform;
37mod health;
38mod health_panel;
39mod highlight;
40mod history;
41mod hook;
42mod hud;
43mod import_panel;
44mod inject;
45mod lighting;
46mod lighting_panel;
47mod list_panel;
48mod live;
49mod marquee;
50mod modal;
51pub(crate) mod notify;
52mod orbit;
53mod outlines;
54mod overrides;
55mod palette;
56mod palette_panel;
57mod panel;
58mod preview;
59mod registry;
60mod resize;
61mod select_related;
62mod selection;
63mod session_store;
64mod sim;
65mod snap;
66mod story;
67mod story_panel;
68mod template_panel;
69mod templates;
70mod theme;
71mod thumbs;
72mod toast_overlay;
73mod variables;
74mod variables_panel;
75mod view;
76mod view_menu;
77mod visibility;
78mod widget;
79mod widget_slider;
80mod world_files;
81mod worlds;
82
83use crate::app::state::App;
84use crate::debug_hook::DebugHook;
85use crate::ecs::World;
86use crate::world::WORLD_JSONL;
87use concinnity_engine::shutdown::ShutdownToken;
88use hook::EditorHook;
89
90const SEED_GRAPHICS_CONFIG: &str =
98 "{\"name\":\"editor_default_gfx\",\"type\":\"GraphicsConfig\",\"args\":{}}";
99
100pub fn run_editor(json_path: Option<&str>, debug_port: Option<u16>) -> std::io::Result<()> {
104 let console_sink = console::ConsoleSink::default();
108 console::install_tracing(console_sink.clone());
109
110 let (world_path, pick_a_world) = resolve_edit_target(json_path);
114
115 concinnity_engine::app::dev_flags::set_world_jsonl_path(Some(world_path.clone()));
119
120 let (entries, previewing) = if pick_a_world {
128 (Vec::new(), start_screen_pick())
129 } else if std::path::Path::new(&world_path).exists() {
130 let content = std::fs::read_to_string(&world_path)?;
131 let entries = crate::world::parse_world_jsonl(&content)
132 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
133 (entries, None)
134 } else {
135 (Vec::new(), None)
136 };
137
138 let mut app = crate::project::app();
141 boot_world(&mut app, &entries)?;
142
143 inject::editor_hud(app.world_mut());
147
148 let mut editor_hook = EditorHook::new(world_path, entries).with_console_sink(console_sink);
154 if pick_a_world {
155 editor_hook = editor_hook.with_start_screen(previewing);
156 }
157 let hook: Box<dyn DebugHook> = match debug_port {
158 Some(port) => {
159 let server =
160 crate::debug::DebugServer::start(port)?.with_notifier(editor_hook.notifier());
161 MultiHook::boxed(vec![Box::new(editor_hook), Box::new(server)])
162 }
163 None => {
164 let reload = crate::debug::hot_reload::HotReloadDriver::new()
165 .with_notifier(editor_hook.notifier());
166 MultiHook::boxed(vec![Box::new(editor_hook), Box::new(reload)])
167 }
168 };
169
170 crate::run::start_app(app, Some(hook))
171}
172
173fn resolve_edit_target(json_path: Option<&str>) -> (String, bool) {
180 match json_path {
181 Some(p) => (p.to_string(), false),
182 None => (unsaved_world_path(), true),
183 }
184}
185
186fn start_screen_pick() -> Option<String> {
191 let world = world_files::newest(
192 crate::project::worlds_dir().as_deref(),
193 crate::project::content_root().as_deref(),
194 )?;
195 Some(world.path.to_string_lossy().into_owned())
196}
197
198pub(crate) fn unsaved_world_path() -> String {
200 crate::project::worlds_dir()
201 .map(|dir| dir.join(WORLD_JSONL).to_string_lossy().into_owned())
202 .unwrap_or_else(|| WORLD_JSONL.to_string())
203}
204
205fn boot_world(app: &mut App, entries: &[serde_json::Value]) -> std::io::Result<()> {
210 let jsonl = crate::world::write_world_jsonl(entries)
211 .map_err(|e| std::io::Error::other(e.to_string()))?;
212 let (world, _) = build_renderable(&jsonl)?;
213 app.load_world(world);
214 Ok(())
215}
216
217fn build_renderable(
224 jsonl: &str,
225) -> std::io::Result<(World, Vec<concinnity_cook::build_only::ShadowedAsset>)> {
226 match crate::authoring::build_world_and_shadows(jsonl) {
227 Ok(built) if concinnity_engine::ecs::renders(&built.0) => Ok(built),
228 _ => crate::authoring::build_world_and_shadows(&seeded_content(jsonl)),
229 }
230}
231
232fn seeded_content(base: &str) -> String {
236 if base.trim().is_empty() {
237 SEED_GRAPHICS_CONFIG.to_string()
238 } else {
239 format!("{base}\n{SEED_GRAPHICS_CONFIG}")
240 }
241}
242
243struct MultiHook {
246 hooks: Vec<Box<dyn DebugHook>>,
247}
248
249impl MultiHook {
250 fn boxed(hooks: Vec<Box<dyn DebugHook>>) -> Box<dyn DebugHook> {
251 Box::new(Self { hooks })
252 }
253}
254
255impl DebugHook for MultiHook {
256 fn tick(&mut self, world: &mut World) {
257 for hook in &mut self.hooks {
258 hook.tick(world);
259 }
260 }
261
262 fn apply_world_swap(&mut self, app: &mut crate::app::state::App) {
263 for hook in &mut self.hooks {
264 hook.apply_world_swap(app);
265 }
266 }
267
268 fn attach_shutdown(&mut self, shutdown: ShutdownToken) {
269 for hook in &mut self.hooks {
270 hook.attach_shutdown(shutdown.clone());
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
282 fn seeded_content_of_empty_is_the_render_marker() {
283 assert_eq!(seeded_content(""), SEED_GRAPHICS_CONFIG);
284 assert_eq!(seeded_content(" \n"), SEED_GRAPHICS_CONFIG);
285 }
286
287 #[test]
290 fn seeded_content_appends_marker_to_authored_content() {
291 let base = "{\"name\":\"phys\",\"type\":\"PhysicsConfig\",\"args\":{}}";
292 let seeded = seeded_content(base);
293 let parsed = crate::world::parse_world_jsonl(&seeded).unwrap();
294 assert_eq!(parsed.len(), 2, "authored entry plus the seed marker");
295 assert_eq!(parsed[0]["name"], "phys");
296 assert_eq!(parsed[1]["type"], "GraphicsConfig");
297 }
298
299 #[test]
301 fn seed_marker_is_a_graphics_config() {
302 let parsed = crate::world::parse_world_jsonl(SEED_GRAPHICS_CONFIG).unwrap();
303 assert_eq!(parsed.len(), 1);
304 assert_eq!(parsed[0]["type"], "GraphicsConfig");
305 }
306
307 fn open_project(dir: &std::path::Path) -> std::path::PathBuf {
310 let build_root = dir.join(".concinnity");
311 crate::project::open(
312 concinnity_host::store::paths::StateTree::at(dir)
313 .with_build(&build_root)
314 .with_cache(concinnity_testing::shared_cache_dir(
315 "concinnity-dev-tests-cache",
316 )),
317 );
318 build_root
319 }
320
321 fn entry(name: &str, ty: &str, args: serde_json::Value) -> serde_json::Value {
322 serde_json::json!({"name": name, "type": ty, "args": args})
323 }
324
325 fn renderable_entries(label: &str) -> Vec<serde_json::Value> {
328 vec![
329 entry("cam", "Camera3D", serde_json::json!({})),
330 entry("room", "Room", serde_json::json!({})),
331 entry("hint", "TextLabel", serde_json::json!({"content": label})),
332 ]
333 }
334
335 fn booted_label(app: &App) -> String {
337 app.world()
338 .query::<crate::components::TextLabel>()
339 .next()
340 .expect("the authored label is in the booted world")
341 .content
342 .clone()
343 }
344
345 #[test]
348 fn boot_compiles_the_authored_entries_without_touching_the_build_root() {
349 let _guard = crate::test_support::lock();
350 let dir = concinnity_testing::TempTree::new();
351 let build_root = open_project(dir.path());
352
353 let mut app = crate::project::app();
354 boot_world(&mut app, &renderable_entries("authored")).expect("the world builds");
355
356 assert!(concinnity_engine::ecs::renders(app.world()));
357 assert_eq!(booted_label(&app), "authored");
358 assert!(
359 !build_root.join("data").exists() && !build_root.join("world-lock.json").exists(),
360 "boot writes no blobs and no lock"
361 );
362
363 crate::test_support::isolate_state_dir();
364 }
365
366 #[test]
370 fn boot_ignores_blobs_that_no_longer_match_the_entries() {
371 let _guard = crate::test_support::lock();
372 let dir = concinnity_testing::TempTree::new();
373 let build_root = open_project(dir.path());
374
375 let world_path = dir.path().join("worlds").join(WORLD_JSONL);
377 std::fs::create_dir_all(world_path.parent().unwrap()).unwrap();
378 std::fs::write(
379 &world_path,
380 crate::world::write_world_jsonl(&renderable_entries("stale")).unwrap(),
381 )
382 .unwrap();
383 crate::build_world_to_disk(world_path.to_str().unwrap()).expect("the build writes blobs");
384 let blob = concinnity_host::store::blob::primary_in(&build_root.join("data"));
385 let before = std::fs::read(&blob).expect("the build wrote a primary blob");
386
387 let mut app = crate::project::app();
388 boot_world(&mut app, &renderable_entries("edited")).expect("the world builds");
389
390 assert_eq!(
391 booted_label(&app),
392 "edited",
393 "the entries win over the blobs the last build left"
394 );
395 assert_eq!(
396 std::fs::read(&blob).unwrap(),
397 before,
398 "boot leaves the build output untouched"
399 );
400
401 crate::test_support::isolate_state_dir();
402 }
403
404 #[test]
407 fn boot_seeds_a_render_marker_for_an_empty_entry_list() {
408 let _guard = crate::test_support::lock();
409 crate::test_support::isolate_state_dir();
410
411 let mut app = crate::project::app();
412 boot_world(&mut app, &[]).expect("an empty world seeds");
413 assert!(concinnity_engine::ecs::renders(app.world()));
414 }
415
416 #[test]
420 fn resolve_edit_target_honors_an_explicit_path() {
421 let (path, pick) = resolve_edit_target(Some("/no/such/cn-editor-world.jsonl"));
422 assert_eq!(path, "/no/such/cn-editor-world.jsonl");
423 assert!(!pick, "a named world loads instead of the Worlds panel");
424 }
425
426 #[test]
429 fn resolve_edit_target_without_a_path_opens_the_worlds_panel() {
430 let _guard = crate::test_support::lock();
431 crate::test_support::isolate_state_dir();
432
433 let (path, pick) = resolve_edit_target(None);
434 assert!(pick, "no named world opens the Worlds panel");
435 assert_eq!(path, unsaved_world_path());
436 }
437
438 #[test]
441 fn an_unsaved_world_is_named_inside_the_projects_worlds_directory() {
442 let _guard = crate::test_support::lock();
444 crate::test_support::isolate_state_dir();
445
446 let worlds = crate::project::worlds_dir().expect("the harness opened a project");
447 assert_eq!(
448 unsaved_world_path(),
449 worlds.join(WORLD_JSONL).to_string_lossy()
450 );
451 }
452}