Unravel Engine C++ Reference
Loading...
Searching...
No Matches
mcp_tools_assets.cpp
Go to the documentation of this file.
1#include "mcp_tools_common.h"
2
3#include "mcp_async.h"
4
15#include <engine/ecs/prefab.h>
19
20#include <chrono>
21
22namespace unravel::mcp
23{
24namespace
25{
26
27auto asset_entry_json(const std::string& uid, const std::string& location, const std::string& type) -> std::string
28{
29 return fmt::format(R"({{"uid":{},"location":{},"type":{}}})",
31 make_json_string(location),
33}
34
35auto ends_with_ci(std::string_view value, std::string_view suffix) -> bool
36{
37 if(suffix.size() > value.size())
38 {
39 return false;
40 }
41 const auto v = value.substr(value.size() - suffix.size());
42 return to_lower_ascii(std::string(v)) == to_lower_ascii(std::string(suffix));
43}
44
45auto path_matches_type(const std::string& location, const std::string& type_filter) -> bool
46{
47 if(type_filter.empty())
48 {
49 return true;
50 }
51 const auto lower_loc = to_lower_ascii(location);
52 const auto lower_type = to_lower_ascii(type_filter);
53 if(ends_with_ci(lower_loc, lower_type))
54 {
55 return true;
56 }
57 if(!lower_type.empty() && lower_type.front() == '.')
58 {
59 return ends_with_ci(lower_loc, lower_type.substr(1));
60 }
61 return false;
62}
63
64auto normalize_folder_key(std::string key) -> std::string
65{
66 if(key.empty())
67 {
68 return key;
69 }
70 fs::error_code ec;
71 const fs::path as_path(key);
72 if(as_path.is_absolute())
73 {
74 key = fs::convert_to_protocol(as_path).generic_string();
75 }
76 while(!key.empty() && (key.back() == '/' || key.back() == '\\'))
77 {
78 key.pop_back();
79 }
80 return key;
81}
82
83template<typename T>
84auto matches_asset_extension(const std::string& ext) -> bool
85{
86 if(ext.empty())
87 {
88 return false;
89 }
90 if(ex::is_format<T>(ext))
91 {
92 return true;
93 }
94 const auto lower = to_lower_ascii(ext);
95 return lower != ext && ex::is_format<T>(lower);
96}
97
98template<typename T>
99auto poll_handle_ready(asset_manager& am, const std::string& key, std::chrono::milliseconds timeout) -> bool
100{
101 auto handle = am.get_asset<T>(key);
102 const auto deadline = std::chrono::steady_clock::now() + timeout;
103 while(std::chrono::steady_clock::now() < deadline)
104 {
105 if(handle && handle.is_ready())
106 {
107 return true;
108 }
109 std::this_thread::sleep_for(std::chrono::milliseconds(32));
110 }
111 return handle && handle.is_ready();
112}
113
114auto try_wait_asset_ready(rtti::context& ctx, const std::string& key, std::chrono::milliseconds timeout) -> bool
115{
116 auto& am = ctx.get_cached<asset_manager>();
117 const auto ext = fs::path(key).extension().generic_string();
118 if(matches_asset_extension<material>(ext))
119 {
120 return poll_handle_ready<material>(am, key, timeout);
121 }
122 if(matches_asset_extension<mesh>(ext))
123 {
124 return poll_handle_ready<mesh>(am, key, timeout);
125 }
126 if(matches_asset_extension<prefab>(ext))
127 {
128 return poll_handle_ready<prefab>(am, key, timeout);
129 }
130 if(matches_asset_extension<scene_prefab>(ext))
131 {
132 return poll_handle_ready<scene_prefab>(am, key, timeout);
133 }
134 const auto deadline = std::chrono::steady_clock::now() + timeout;
135 while(std::chrono::steady_clock::now() < deadline)
136 {
137 auto meta = am.get_metadata_for_key(key);
138 if(!meta.location.empty() || !meta.meta.type.empty())
139 {
140 return true;
141 }
142 const auto absolute = fs::absolute(fs::resolve_protocol(key));
143 fs::error_code ec;
144 if(fs::exists(absolute, ec) && fs::file_size(absolute, ec) > 0)
145 {
146 return true;
147 }
148 std::this_thread::sleep_for(std::chrono::milliseconds(32));
149 }
150 return false;
151}
152
153auto read_import_timeout_ms(const simdjson::dom::object& args, int64_t default_ms) -> std::chrono::milliseconds
154{
155 int64_t timeout_ms = default_ms;
156 if(args["wait_ms"].get(timeout_ms))
157 {
158 timeout_ms = default_ms;
159 }
160 if(timeout_ms < 0)
161 {
162 timeout_ms = 0;
163 }
164 if(timeout_ms > 60000)
165 {
166 timeout_ms = 60000;
167 }
168 return std::chrono::milliseconds(timeout_ms);
169}
170
171auto collect_imported_asset_keys(const import_files_item& item) -> std::vector<std::string>
172{
173 std::vector<std::string> keys;
174 if(item.dest_key.empty())
175 {
176 return keys;
177 }
178 if(!item.is_directory)
179 {
180 keys.push_back(item.dest_key);
181 return keys;
182 }
183 fs::error_code ec;
184 const fs::path root(item.dest_path);
185 const auto meta_ext = ex::get_meta_format();
186 for(fs::recursive_directory_iterator it(root, ec), end; it != end && !ec; it.increment(ec))
187 {
188 if(!it->is_regular_file(ec))
189 {
190 continue;
191 }
192 const auto path = it->path();
193 if(path.extension().generic_string() == meta_ext)
194 {
195 continue;
196 }
197 const auto key = fs::convert_to_protocol(path).generic_string();
198 if(!key.empty())
199 {
200 keys.push_back(key);
201 }
202 }
203 if(keys.empty())
204 {
205 keys.push_back(item.dest_key);
206 }
207 return keys;
208}
209
210} // namespace
211
213{
214 registry.add(
215 {.name = "project_get_info",
216 .description = "Get the currently open project name/path, or empty if none.",
217 .input_schema_json = empty_object_schema(),
218 .handler =
219 [](rtti::context& ctx, const simdjson::dom::object&) -> tool_result
220 {
221 if(!ctx.has<project_manager>())
222 {
223 return {.text = R"({"open":false})", .is_error = false};
224 }
225 auto& pm = ctx.get_cached<project_manager>();
226 if(!pm.has_open_project())
227 {
228 return {.text = R"({"open":false})", .is_error = false};
229 }
230
231 const auto& info = pm.get_project_info();
232 const auto path = fs::resolve_protocol("app:/").generic_string();
233 return {.text = fmt::format(R"({{"open":true,"name":{},"path":{},"guid":{}}})",
234 make_json_string(pm.get_name()),
235 make_json_string(path),
236 make_json_string(info.project_guid)),
237 .is_error = false};
238 },
239 .mutates_scene = false});
240
241 registry.add(
242 {.name = "assets_list_batch",
243 .description =
244 "List assets for a protocol group: app, engine, or editor. Optional type filter "
245 "(extension e.g. mat, .mat, pfb, emesh, etex, cs, spfb).",
246 .input_schema_json =
247 R"({"type":"object","properties":{"protocol":{"type":"string","enum":["app","engine","editor"]},"type":{"type":"string"}},"required":["protocol"]})",
248 .handler =
249 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
250 {
251 std::string protocol;
252 if(!read_string(args, "protocol", protocol))
253 {
254 return {.text = "Missing protocol", .is_error = true};
255 }
256 const auto group = protocol_to_group(protocol);
257 if(group.empty())
258 {
259 return {.text = "Invalid protocol (use app|engine|editor)", .is_error = true};
260 }
261
262 std::string type_filter;
263 read_string(args, "type", type_filter);
264 if(!type_filter.empty())
265 {
266 type_filter = normalize_asset_type_filter(type_filter);
267 }
268
269 auto& am = ctx.get_cached<asset_manager>();
270 auto locations = am.get_all_assets(group);
271
272 std::string json = "[";
273 bool first = true;
274 for(const auto& location : locations)
275 {
276 auto meta = am.get_metadata_for_key(location);
277 if(!type_filter.empty())
278 {
279 const auto meta_type = normalize_asset_type_filter(meta.meta.type);
280 if(meta_type != type_filter && !path_matches_type(location, type_filter))
281 {
282 continue;
283 }
284 }
285 if(!first)
286 {
287 json += ",";
288 }
289 first = false;
290 json += asset_entry_json(hpp::to_string(meta.meta.uid), location, meta.meta.type);
291 }
292 json += "]";
293 return {.text = json, .is_error = false};
294 },
295 .mutates_scene = false});
296
297 registry.add(
298 {.name = "assets_find_batch",
299 .description =
300 "Search assets across app/engine/editor (or one protocol). Filters: type (any extension: "
301 "mat, pfb, emesh, etex, cs, spfb, ...), prefix (location starts with), name_contains "
302 "(case-insensitive path/stem match). Optional limit (default 200).",
303 .input_schema_json = R"json({"type":"object","properties":{"protocol":{"type":"string","enum":["app","engine","editor","all"],"default":"all"},"type":{"type":"string","description":"Asset extension/type filter e.g. mat, .pfb, emesh"},"prefix":{"type":"string","description":"Location prefix e.g. app:/data/Materials/"},"name_contains":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":5000}}})json",
304 .handler =
305 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
306 {
307 std::string protocol = "all";
308 read_string(args, "protocol", protocol);
309 if(protocol.empty())
310 {
311 protocol = "all";
312 }
313
314 std::vector<std::string> groups;
315 if(protocol == "all")
316 {
317 groups = {"app:/", "engine:/", "editor:/"};
318 }
319 else
320 {
321 const auto group = protocol_to_group(protocol);
322 if(group.empty())
323 {
324 return {.text = "Invalid protocol (use app|engine|editor|all)", .is_error = true};
325 }
326 groups.push_back(group);
327 }
328
329 std::string type_filter;
330 read_string(args, "type", type_filter);
331 if(!type_filter.empty())
332 {
333 type_filter = normalize_asset_type_filter(type_filter);
334 }
335
336 std::string prefix;
337 read_string(args, "prefix", prefix);
338 std::string name_contains;
339 read_string(args, "name_contains", name_contains);
340
341 int64_t limit = 200;
342 if(args["limit"].get(limit))
343 {
344 limit = 200;
345 }
346 if(limit < 1)
347 {
348 limit = 1;
349 }
350 if(limit > 5000)
351 {
352 limit = 5000;
353 }
354
355 auto& am = ctx.get_cached<asset_manager>();
356 std::string json = "[";
357 bool first = true;
358 size_t count = 0;
359 size_t scanned = 0;
360 for(const auto& group : groups)
361 {
362 for(const auto& location : am.get_all_assets(group))
363 {
364 ++scanned;
365 if(!prefix.empty() && !starts_with(location, prefix))
366 {
367 continue;
368 }
369 if(!name_contains.empty() && !contains_ci(location, name_contains))
370 {
371 continue;
372 }
373 auto meta = am.get_metadata_for_key(location);
374 if(!type_filter.empty())
375 {
376 const auto meta_type = normalize_asset_type_filter(meta.meta.type);
377 if(meta_type != type_filter && !path_matches_type(location, type_filter))
378 {
379 continue;
380 }
381 }
382 if(!first)
383 {
384 json += ",";
385 }
386 first = false;
387 json += asset_entry_json(hpp::to_string(meta.meta.uid), location, meta.meta.type);
388 ++count;
389 if(static_cast<int64_t>(count) >= limit)
390 {
391 json += "]";
392 return {.text = fmt::format(
393 R"({{"assets":{},"count":{},"scanned":{},"truncated":true,"limit":{}}})",
394 json,
395 count,
396 scanned,
397 limit),
398 .is_error = false};
399 }
400 }
401 }
402 json += "]";
403 return {.text = fmt::format(R"({{"assets":{},"count":{},"scanned":{},"truncated":false,"limit":{}}})",
404 json,
405 count,
406 scanned,
407 limit),
408 .is_error = false};
409 },
410 .mutates_scene = false});
411
412 registry.add(
413 {.name = "assets_list_types",
414 .description = "List known registered asset type names.",
415 .input_schema_json = empty_object_schema(),
416 .handler =
417 [](rtti::context&, const simdjson::dom::object&) -> tool_result
418 {
419 std::string json = "[";
420 bool first = true;
421 for(const auto& group : ex::get_all_formats())
422 {
423 for(const auto& ext : group)
424 {
425 if(!first)
426 {
427 json += ",";
428 }
429 first = false;
430 json += make_json_string(ext);
431 }
432 }
433 json += "]";
434 return {.text = json, .is_error = false};
435 },
436 .mutates_scene = false});
437
438 registry.add(
439 {.name = "assets_list_embedded_primitives",
440 .description =
441 "List embedded mesh primitive names usable with scene_create_primitives_batch. "
442 "Axes: X-right, Y-up, Z-forward. Cube is 1x1x1 centered at origin.",
443 .input_schema_json = empty_object_schema(),
444 .handler =
445 [](rtti::context&, const simdjson::dom::object&) -> tool_result
446 {
447 static const char* names[] = {"Cube",
448 "Cube Rounded",
449 "Sphere",
450 "Plane",
451 "Cylinder",
452 "Capsule 2m",
453 "Capsule 1m",
454 "Cone",
455 "Torus",
456 "Teapot",
457 "Icosahedron",
458 "Dodecahedron",
459 "Icosphere0",
460 "Icosphere1",
461 "Icosphere2",
462 "Icosphere3",
463 "Terrain Test"};
464 std::string json = "[";
465 for(size_t i = 0; i < std::size(names); ++i)
466 {
467 if(i > 0)
468 {
469 json += ",";
470 }
471 json += make_json_string(names[i]);
472 }
473 json += "]";
474 return {.text = json, .is_error = false};
475 },
476 .mutates_scene = false});
477
478 registry.add(
479 {.name = "assets_create_folder",
480 .description =
481 "Create a folder under a protocol path (content-browser parity). Provide path key "
482 "(e.g. app:/data/Props) or folder+name. Optional wait_ms after create.",
483 .input_schema_json =
484 R"json({"type":"object","properties":{"path":{"type":"string"},"folder":{"type":"string"},"name":{"type":"string"},"wait_ms":{"type":"integer","minimum":0,"maximum":15000}}})json",
485 .handler =
486 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
487 {
488 auto& mcp = ctx.get_cached<mcp_manager>();
489 const auto wait_ms = read_wait_ms(args, 200);
490
491 std::string path;
492 std::string folder;
493 std::string name;
494 read_string(args, "path", path);
495 read_string(args, "folder", folder);
496 read_string(args, "name", name);
497
498 std::string key;
499 if(!path.empty())
500 {
501 key = normalize_folder_key(path);
502 }
503 else if(!folder.empty() && !name.empty())
504 {
505 key = normalize_folder_key(folder);
506 key.push_back('/');
507 key += name;
508 key = normalize_folder_key(key);
509 }
510 else
511 {
512 return {.text = "Provide path, or folder + name", .is_error = true};
513 }
514
515 auto create_result = mcp.invoke_on_main(
516 [&ctx, key]() -> tool_result
517 {
518 std::string error;
519 if(!require_open_project(ctx, error) && starts_with(key, "app:/"))
520 {
521 return {.text = error, .is_error = true};
522 }
523 fs::error_code ec;
524 const auto absolute = fs::absolute(fs::resolve_protocol(key));
525 if(fs::exists(absolute, ec))
526 {
527 if(fs::is_directory(absolute, ec))
528 {
529 return {.text = fmt::format(R"({{"key":{},"created":false,"exists":true}})",
530 make_json_string(key)),
531 .is_error = false};
532 }
533 return {.text = "Path exists and is not a folder: " + key, .is_error = true};
534 }
535 fs::create_directories(absolute, ec);
536 if(ec)
537 {
538 return {.text = "Failed to create folder: " + ec.message(), .is_error = true};
539 }
540 return {.text = fmt::format(R"({{"key":{},"created":true,"exists":false}})",
541 make_json_string(key)),
542 .is_error = false};
543 });
544
545 if(!create_result)
546 {
547 return {.text = "Timed out creating folder on main thread", .is_error = true};
548 }
549 if(!create_result->is_error)
550 {
551 sleep_worker(wait_ms);
552 }
553 return *create_result;
554 },
555 .mutates_scene = false,
556 .requires_main_thread = false});
557
558 registry.add(
559 {.name = "assets_import_files",
560 .description =
561 "Import external files/folders into the open project (content-browser Import parity). "
562 "Never download into the project first — stage files outside app:/ (e.g. OS temp), "
563 "then pass those absolute paths here. paths: absolute filesystem paths outside the "
564 "project. folder: destination protocol key (e.g. app:/data/Imported). Waits for "
565 "async copy jobs, then polls until imported asset keys are ready (wait_ms, default "
566 "15000, max 60000). Focuses the editor window so the asset watcher can process "
567 "new files.",
568 .input_schema_json =
569 R"json({"type":"object","properties":{"paths":{"type":"array","items":{"type":"string"}},"folder":{"type":"string"},"wait_ms":{"type":"integer","minimum":0,"maximum":60000}},"required":["paths","folder"]})json",
570 .handler =
571 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
572 {
573 const auto wait_ms = read_import_timeout_ms(args, 15000);
574 simdjson::dom::array paths_arr;
575 if(args["paths"].get(paths_arr))
576 {
577 return {.text = "Missing paths array", .is_error = true};
578 }
579 std::string folder;
580 if(!read_string(args, "folder", folder) || folder.empty())
581 {
582 return {.text = "Missing folder", .is_error = true};
583 }
584 folder = normalize_folder_key(folder);
585 if(!starts_with(folder, "app:/"))
586 {
587 return {.text = "folder must be under app:/ (project data)", .is_error = true};
588 }
589 std::vector<std::string> paths;
590 for(auto el : paths_arr)
591 {
592 std::string_view path_view;
593 if(el.get(path_view) || path_view.empty())
594 {
595 return {.text = "Each paths entry must be a non-empty string", .is_error = true};
596 }
597 paths.emplace_back(path_view);
598 }
599 if(paths.empty())
600 {
601 return {.text = "paths array is empty", .is_error = true};
602 }
603 std::string project_error;
604 if(!require_open_project(ctx, project_error))
605 {
606 return {.text = project_error, .is_error = true};
607 }
608 fs::error_code ec;
609 const auto project_root = fs::absolute(fs::resolve_protocol("app:/"));
610 for(const auto& path : paths)
611 {
612 const auto absolute_source = fs::absolute(fs::path(path));
613 if(fs::is_any_parent_path(project_root, absolute_source) ||
614 fs::equivalent(project_root, absolute_source, ec))
615 {
616 return {.text =
617 "paths must be outside the project folder; download/stage "
618 "elsewhere then import (rejected: " +
619 absolute_source.generic_string() + ")",
620 .is_error = true};
621 }
622 }
623 const auto target_path = fs::absolute(fs::resolve_protocol(folder));
624 if(fs::exists(target_path, ec) && !fs::is_directory(target_path, ec))
625 {
626 return {.text = "folder resolves to a non-directory path: " + folder, .is_error = true};
627 }
628 {
629 std::string focus_error;
631 }
632 auto items = editor_actions::import_files(ctx, paths, target_path, false);
633 const bool copied = editor_actions::wait_import_jobs(items, wait_ms);
634 const auto ready_deadline = std::chrono::steady_clock::now() + wait_ms;
635 std::string results = "[";
636 bool first = true;
637 size_t ready_count = 0;
638 size_t imported_count = 0;
639 for(auto& item : items)
640 {
641 bool copy_ok = false;
642 if(item.future.valid() &&
643 item.future.wait_for(std::chrono::milliseconds(0)) == std::future_status::ready)
644 {
645 copy_ok = item.future.get();
646 }
647 const auto keys = collect_imported_asset_keys(item);
648 if(keys.empty())
649 {
650 if(!first)
651 {
652 results += ",";
653 }
654 first = false;
655 results += fmt::format(
656 R"({{"ok":{},"source":{},"dest":{},"key":{},"is_directory":{},"ready":false,"error":"No destination key"}})",
657 copy_ok ? "true" : "false",
658 make_json_string(item.source_path),
659 make_json_string(item.dest_path),
660 make_json_string(item.dest_key),
661 item.is_directory ? "true" : "false");
662 continue;
663 }
664 imported_count += keys.size();
665 for(const auto& key : keys)
666 {
667 bool ready = false;
668 if(copy_ok)
669 {
670 const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(
671 ready_deadline - std::chrono::steady_clock::now());
672 ready = try_wait_asset_ready(
673 ctx,
674 key,
675 remaining.count() > 0 ? remaining : std::chrono::milliseconds(0));
676 }
677 if(ready)
678 {
679 ++ready_count;
680 }
681 if(!first)
682 {
683 results += ",";
684 }
685 first = false;
686 results += fmt::format(
687 R"({{"ok":{},"source":{},"dest":{},"key":{},"is_directory":{},"ready":{}}})",
688 copy_ok ? "true" : "false",
689 make_json_string(item.source_path),
690 make_json_string(item.dest_path),
691 make_json_string(key),
692 item.is_directory ? "true" : "false",
693 ready ? "true" : "false");
694 }
695 }
696 results += "]";
697 const bool ok = copied && ready_count == imported_count && imported_count > 0;
698 return {.text = fmt::format(
699 R"({{"folder":{},"results":{},"imported":{},"ready":{},"copied":{}}})",
700 make_json_string(folder),
701 results,
702 imported_count,
703 ready_count,
704 copied ? "true" : "false"),
705 .is_error = !ok};
706 },
707 .mutates_scene = false,
708 .requires_main_thread = false});
709
710 registry.add(
711 {.name = "assets_get_mesh_info",
712 .description =
713 "Get mesh asset local AABB (min/max/center/extents). key is any mesh-format asset "
714 "(see ex::get_suported_formats<mesh>). Axes: X-right, Y-up, Z-forward.",
715 .input_schema_json = R"json({"type":"object","properties":{"key":{"type":"string"}},"required":["key"]})json",
716 .handler =
717 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
718 {
719 std::string key;
720 if(!read_string(args, "key", key) || key.empty())
721 {
722 return {.text = "Missing key", .is_error = true};
723 }
724 auto& am = ctx.get_cached<asset_manager>();
725 auto handle = am.get_asset<mesh>(key);
726 if(!handle)
727 {
728 return {.text = "Mesh asset not found: " + key, .is_error = true};
729 }
730 auto mesh_ptr = handle.get(true);
731 if(!mesh_ptr)
732 {
733 return {.text = "Mesh failed to load: " + key, .is_error = true};
734 }
735 const auto& bounds = mesh_ptr->get_bounds();
736 return {.text = fmt::format(R"({{"key":{},"uid":{},"bounds":{}}})",
738 make_json_string(hpp::to_string(handle.uid())),
739 bbox_to_json(bounds)),
740 .is_error = false};
741 },
742 .mutates_scene = false});
743
744 registry.add(
745 {.name = "assets_get_batch",
746 .description = "Get many assets by key and/or uid. Each item: key or uid.",
747 .input_schema_json =
748 R"json({"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"uid":{"type":"string"}}}}},"required":["items"]})json",
749 .handler =
750 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
751 {
752 auto& am = ctx.get_cached<asset_manager>();
753 simdjson::dom::array items_arr;
754 if(args["items"].get(items_arr))
755 {
756 return {.text = "Missing items array", .is_error = true};
757 }
758 std::string results = "[";
759 bool first = true;
760 size_t ok_count = 0;
761 size_t requested = 0;
762 for(auto el : items_arr)
763 {
764 ++requested;
765 simdjson::dom::object obj;
766 if(el.get(obj))
767 {
768 return {.text = "Each item must be an object", .is_error = true};
769 }
770 std::string key;
771 std::string uid_str;
772 read_string(obj, "key", key);
773 read_string(obj, "uid", uid_str);
774 if(!first)
775 {
776 results += ",";
777 }
778 first = false;
780 if(!key.empty())
781 {
782 meta = am.get_metadata_for_key(key);
783 }
784 else if(!uid_str.empty())
785 {
786 auto uuid = hpp::uuid::from_string(uid_str);
787 if(!uuid)
788 {
789 results += fmt::format(R"({{"ok":false,"uid":{},"error":"Invalid uid"}})",
790 make_json_string(uid_str));
791 continue;
792 }
793 meta = am.get_metadata(*uuid);
794 }
795 else
796 {
797 results += R"({"ok":false,"error":"Provide key or uid"})";
798 continue;
799 }
800 if(meta.location.empty() && meta.meta.type.empty())
801 {
802 results += fmt::format(R"({{"ok":false,"key":{},"uid":{},"error":"Asset not found"}})",
803 make_json_string(key),
804 make_json_string(uid_str));
805 continue;
806 }
807 ++ok_count;
808 results += fmt::format(
809 R"({{"ok":true,"uid":{},"location":{},"type":{}}})",
810 make_json_string(hpp::to_string(meta.meta.uid)),
811 make_json_string(meta.location.empty() ? key : meta.location),
812 make_json_string(meta.meta.type));
813 }
814 results += "]";
815 return {.text = fmt::format(R"({{"results":{},"count":{},"requested":{}}})", results, ok_count, requested),
816 .is_error = ok_count == 0 && requested > 0};
817 },
818 .mutates_scene = false});
819
820 registry.add(
821 {.name = "assets_reimport_batch",
822 .description = "Reimport many assets by key. Optional wait_ms after the batch.",
823 .input_schema_json =
824 R"json({"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}},"wait_ms":{"type":"integer","minimum":0,"maximum":15000}},"required":["items"]})json",
825 .handler =
826 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
827 {
828 auto& mcp = ctx.get_cached<mcp_manager>();
829 const auto wait_ms = read_wait_ms(args, 500);
830 simdjson::dom::array items_arr;
831 if(args["items"].get(items_arr))
832 {
833 return {.text = "Missing items array", .is_error = true};
834 }
835 std::vector<std::string> keys;
836 for(auto el : items_arr)
837 {
838 simdjson::dom::object obj;
839 if(el.get(obj))
840 {
841 return {.text = "Each item must be an object", .is_error = true};
842 }
843 std::string key;
844 if(!read_string(obj, "key", key) || key.empty())
845 {
846 return {.text = "Item missing key", .is_error = true};
847 }
848 keys.push_back(std::move(key));
849 }
850 if(keys.empty())
851 {
852 return {.text = "items array is empty", .is_error = true};
853 }
854 auto result = mcp.invoke_on_main(
855 [keys]() -> tool_result
856 {
857 std::string results = "[";
858 bool first = true;
859 size_t ok_count = 0;
860 for(const auto& key : keys)
861 {
862 if(!first)
863 {
864 results += ",";
865 }
866 first = false;
867 const auto source = asset_actions::resolve_asset_source_path(key);
868 fs::error_code ec;
869 if(source.empty() || !fs::exists(source, ec))
870 {
871 results += fmt::format(R"({{"ok":false,"key":{},"error":{}}})",
872 make_json_string(key),
873 make_json_string("Asset source not found"));
874 continue;
875 }
876 if(!asset_actions::can_reimport(source))
877 {
878 results += fmt::format(R"({{"ok":false,"key":{},"error":{}}})",
879 make_json_string(key),
880 make_json_string("Asset cannot be reimported"));
881 continue;
882 }
884 ++ok_count;
885 results += fmt::format(R"({{"ok":true,"key":{},"reimported":true}})", make_json_string(key));
886 }
887 results += "]";
888 return {.text = fmt::format(R"({{"results":{},"count":{},"requested":{}}})",
889 results,
890 ok_count,
891 keys.size()),
892 .is_error = ok_count == 0};
893 });
894 if(!result)
895 {
896 return {.text = "Timed out reimporting on main thread", .is_error = true};
897 }
898 if(!result->is_error)
899 {
900 sleep_worker(wait_ms);
901 }
902 return *result;
903 },
904 .mutates_scene = false,
905 .requires_main_thread = false});
906
907 registry.add(
908 {.name = "assets_wait_ready_batch",
909 .description =
910 "Wait until many asset keys are loadable/ready (poll). timeout_ms applies per key "
911 "(default 5000).",
912 .input_schema_json =
913 R"json({"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]}},"timeout_ms":{"type":"integer","minimum":0,"maximum":60000}},"required":["items"]})json",
914 .handler =
915 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
916 {
917 simdjson::dom::array items_arr;
918 if(args["items"].get(items_arr))
919 {
920 return {.text = "Missing items array", .is_error = true};
921 }
922 int64_t timeout_ms = 5000;
923 if(args["timeout_ms"].get(timeout_ms))
924 {
925 timeout_ms = 5000;
926 }
927 if(timeout_ms < 0)
928 {
929 timeout_ms = 0;
930 }
931 if(timeout_ms > 60000)
932 {
933 timeout_ms = 60000;
934 }
935 std::string results = "[";
936 bool first = true;
937 size_t ok_count = 0;
938 size_t requested = 0;
939 for(auto el : items_arr)
940 {
941 ++requested;
942 simdjson::dom::object obj;
943 if(el.get(obj))
944 {
945 return {.text = "Each item must be an object", .is_error = true};
946 }
947 std::string key;
948 if(!read_string(obj, "key", key) || key.empty())
949 {
950 return {.text = "Item missing key", .is_error = true};
951 }
952 const bool ready =
953 try_wait_asset_ready(ctx, key, std::chrono::milliseconds(timeout_ms));
954 if(!first)
955 {
956 results += ",";
957 }
958 first = false;
959 if(ready)
960 {
961 ++ok_count;
962 }
963 results += fmt::format(R"({{"key":{},"ready":{}}})",
964 make_json_string(key),
965 ready ? "true" : "false");
966 }
967 results += "]";
968 return {.text = fmt::format(R"({{"results":{},"count":{},"requested":{}}})", results, ok_count, requested),
969 .is_error = ok_count != requested};
970 },
971 .mutates_scene = false,
972 .requires_main_thread = false});
973
974 registry.add(
975 {.name = "prefabs_create_from_entities_batch",
976 .description =
977 "Save entity hierarchies as .pfb prefab assets (batch). Each item: entity_id, path or "
978 "folder (+ optional name), optional attach. Optional wait_ms after the batch.",
979 .input_schema_json =
980 R"json({"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"entity_id":{"type":"string"},"path":{"type":"string"},"folder":{"type":"string"},"name":{"type":"string"},"attach":{"type":"boolean"}},"required":["entity_id"]}},"wait_ms":{"type":"integer","minimum":0,"maximum":15000}},"required":["items"]})json",
981 .handler =
982 [](rtti::context& ctx, const simdjson::dom::object& args) -> tool_result
983 {
984 auto& mcp = ctx.get_cached<mcp_manager>();
985 const auto wait_ms = read_wait_ms(args, 500);
986 simdjson::dom::array items_arr;
987 if(args["items"].get(items_arr))
988 {
989 return {.text = "Missing items array", .is_error = true};
990 }
991 struct item_t
992 {
993 std::string entity_id;
994 std::string path;
995 std::string folder;
996 std::string name;
997 bool attach{false};
998 };
999 std::vector<item_t> items;
1000 for(auto el : items_arr)
1001 {
1002 simdjson::dom::object obj;
1003 if(el.get(obj))
1004 {
1005 return {.text = "Each item must be an object", .is_error = true};
1006 }
1007 item_t item{};
1008 if(!read_string(obj, "entity_id", item.entity_id) || item.entity_id.empty())
1009 {
1010 return {.text = "Item missing entity_id", .is_error = true};
1011 }
1012 read_string(obj, "path", item.path);
1013 read_string(obj, "folder", item.folder);
1014 read_string(obj, "name", item.name);
1015 read_bool(obj, "attach", item.attach);
1016 items.push_back(std::move(item));
1017 }
1018 if(items.empty())
1019 {
1020 return {.text = "items array is empty", .is_error = true};
1021 }
1022 auto result = mcp.invoke_on_main(
1023 [&ctx, items]() -> tool_result
1024 {
1025 scene* scn = nullptr;
1026 std::string error;
1027 if(!require_edit_scene(ctx, scn, error))
1028 {
1029 return {.text = error, .is_error = true};
1030 }
1031 if(!require_open_project(ctx, error))
1032 {
1033 return {.text = error, .is_error = true};
1034 }
1035 auto& am = ctx.get_cached<asset_manager>();
1036 std::string results = "[";
1037 bool first = true;
1038 size_t ok_count = 0;
1039 for(const auto& item : items)
1040 {
1041 if(!first)
1042 {
1043 results += ",";
1044 }
1045 first = false;
1046 auto entity = find_entity(*scn, item.entity_id);
1047 if(!entity)
1048 {
1049 results += fmt::format(R"({{"ok":false,"entity_id":{},"error":"Entity not found"}})",
1050 make_json_string(item.entity_id));
1051 continue;
1052 }
1053 std::string key;
1054 if(!item.path.empty())
1055 {
1056 key = item.path;
1057 }
1058 else if(!item.folder.empty())
1059 {
1060 key = normalize_folder_key(item.folder);
1061 key.push_back('/');
1062 if(!item.name.empty())
1063 {
1064 key += item.name;
1065 }
1066 else if(auto* tag = entity.try_get<tag_component>())
1067 {
1068 key += tag->name;
1069 }
1070 else
1071 {
1072 key += "Prefab";
1073 }
1074 }
1075 else
1076 {
1077 results += fmt::format(
1078 R"json({{"ok":false,"entity_id":{},"error":"Provide path, or folder and optional name"}})json",
1079 make_json_string(item.entity_id));
1080 continue;
1081 }
1082 fs::error_code ec;
1083 const fs::path as_path(key);
1084 if(as_path.is_absolute())
1085 {
1086 key = fs::convert_to_protocol(as_path).generic_string();
1087 }
1088 const auto prefab_ext = ex::get_format<prefab>();
1089 if(!ends_with_ci(key, prefab_ext))
1090 {
1091 key += prefab_ext;
1092 }
1093 const auto absolute = fs::absolute(fs::resolve_protocol(key));
1094 fs::create_directories(absolute.parent_path(), ec);
1095 if(!asset_writer::atomic_save_to_file(absolute.string(), entity))
1096 {
1097 results += fmt::format(R"({{"ok":false,"entity_id":{},"key":{},"error":"Failed to save prefab"}})",
1098 make_json_string(item.entity_id),
1099 make_json_string(key));
1100 continue;
1101 }
1102 auto prefab_handle = am.get_asset<prefab>(key);
1103 if(item.attach && prefab_handle)
1104 {
1105 entity.get_or_emplace<prefab_component>().source = prefab_handle;
1106 }
1107 ++ok_count;
1108 results += fmt::format(
1109 R"({{"ok":true,"key":{},"uid":{},"entity_id":{},"attached":{}}})",
1110 make_json_string(key),
1111 make_json_string(hpp::to_string(prefab_handle.uid())),
1112 make_json_string(item.entity_id),
1113 item.attach ? "true" : "false");
1114 }
1115 results += "]";
1116 return {.text = fmt::format(R"({{"results":{},"count":{},"requested":{}}})",
1117 results,
1118 ok_count,
1119 items.size()),
1120 .is_error = ok_count == 0};
1121 },
1122 std::chrono::milliseconds(15000));
1123 if(!result)
1124 {
1125 return {.text = "Timed out creating prefabs on main thread", .is_error = true};
1126 }
1127 if(!result->is_error)
1128 {
1129 sleep_worker(wait_ms);
1130 }
1131 return *result;
1132 },
1133 .mutates_scene = true,
1134 .requires_main_thread = false});
1135}
1136
1137} // namespace unravel::mcp
1138
Manages assets, including loading, unloading, and storage.
auto get_all_assets(const std::string &group) const -> std::vector< std::string >
Gets all assets.
auto get_asset(const std::string &key, load_flags flags=load_flags::standard, load_mode mode=load_mode::immediate) -> asset_handle< T >
Gets an asset by its key.
Main class representing a 3D mesh with support for different LODs, submeshes, and skinning.
Definition mesh.h:323
auto get_bounds() const -> const math::bbox &
Gets the local bounding box for this mesh.
Definition mesh.cpp:2730
auto get_project_info() -> project_info &
std::vector< render_pass_node_item > items
std::string name
Definition hub.cpp:33
std::string tag
Definition hub.cpp:32
std::string error
Definition mcp_async.cpp:33
texture_job_type type
auto get_all_formats() -> const std::vector< std::vector< std::string > > &
auto get_format(bool include_dot=true) -> std::string
auto is_format(const std::string &ex) -> bool
auto get_meta_format() -> const std::string &
path resolve_protocol(const path &_path)
Given the specified path/filename, resolve the final full filename. This will be based on either the ...
bool is_any_parent_path(const path &parent, const path &child)
path convert_to_protocol(const path &_path)
Oposite of the resolve_protocol this function tries to convert to protocol path from an absolute one.
void end(encoder *_encoder)
Definition graphics.cpp:427
auto resolve_asset_source_path(const std::string &asset_key) -> fs::path
void reimport_key(const std::string &asset_key)
auto can_reimport(const fs::path &absolute_path) -> bool
auto atomic_save_to_file(const fs::path &key, const asset_handle< T > &obj) -> bool
void register_asset_tools(mcp_tool_registry &registry)
auto sleep_worker(std::chrono::milliseconds wait_ms) -> void
auto protocol_to_group(const std::string &protocol) -> std::string
auto empty_object_schema() -> std::string
auto find_entity(scene &scn, const std::string &id) -> entt::handle
auto to_lower_ascii(std::string value) -> std::string
auto require_edit_scene(rtti::context &ctx, scene *&out_scene, std::string &error) -> bool
auto read_bool(const simdjson::dom::object &args, const char *key, bool &out) -> bool
auto make_json_string(const std::string &value) -> std::string
auto starts_with(std::string_view value, std::string_view prefix) -> bool
auto read_wait_ms(const simdjson::dom::object &args, int64_t default_ms=1000) -> std::chrono::milliseconds
auto read_string(const simdjson::dom::object &args, const char *key, std::string &out) -> bool
auto require_open_project(rtti::context &ctx, std::string &error) -> bool
auto contains_ci(std::string_view haystack, std::string_view needle) -> bool
auto normalize_asset_type_filter(std::string type) -> std::string
auto bbox_to_json(const math::bbox &bounds) -> std::string
entt::handle entity
auto get_cached() -> T &
Definition context.hpp:49
auto has() const -> bool
Definition context.hpp:28
Metadata information for an asset including its location.
static auto import_files(rtti::context &ctx, const std::vector< std::string > &paths, const fs::path &target_path, bool async=true) -> std::vector< import_files_item >
Copy external files/folders into target_path (content-browser Import parity).
static auto request_main_window_focus(rtti::context &ctx, std::string *error=nullptr) -> bool
Focus/raise the OS main window so asset watcher and similar focus-gated work can run (e....
static auto wait_import_jobs(std::vector< import_files_item > &items, std::chrono::milliseconds timeout) -> bool
Block until all import copy jobs complete or timeout elapses.
Component that holds a reference to a prefab asset and tracks property overrides.
Represents a generic prefab with a buffer for serialized data.
Definition prefab.h:18
Represents a scene in the ACE framework, managing entities and their relationships.
Definition scene.h:70
Component that provides a tag (name or label) for an entity.
gfx::uniform_handle handle
Definition uniform.cpp:9