diff --git a/client/src/components/library/AssetsLibraryPage.tsx b/client/src/components/library/AssetsLibraryPage.tsx index 403f0bab..1510541c 100644 --- a/client/src/components/library/AssetsLibraryPage.tsx +++ b/client/src/components/library/AssetsLibraryPage.tsx @@ -74,7 +74,7 @@ function RepoVisual({ repo, tall }: { repo: AssetRepo; tall?: boolean }) { return (
@@ -176,7 +176,10 @@ export function AssetsLibraryPage({ installedRepoIds, onInstall, onUninstall, pr aria-label={repo.name} data-asset-card={repo.id} onClick={() => setOpenRepoId(repo.id)} - className="group overflow-hidden rounded-lg border border-line bg-surface-0 text-left transition-colors hover:bg-surface-2" + // A bare button centres its content box, so a card whose tags wrap to a + // second row overflows top and bottom into overflow-hidden instead of + // growing the grid row. Flex column makes content flow from the top. + className="group flex flex-col overflow-hidden rounded-lg border border-line bg-surface-0 text-left transition-colors hover:bg-surface-2" >
diff --git a/client/src/lib/assetLibrary/repos/rapier-physics.ts b/client/src/lib/assetLibrary/repos/rapier-physics.ts new file mode 100644 index 00000000..6ca9d988 --- /dev/null +++ b/client/src/lib/assetLibrary/repos/rapier-physics.ts @@ -0,0 +1,65 @@ +import type { AssetRepo } from "../types"; + +/** + * The physics engine itself, not a binding. Chosen over `use-cannon` and + * `react-three-rapier` because both of those are React-Three-Fiber hook + * packages with no non-R3F entry point, and this editor drives raw three.js. + * `@dimforge/rapier3d-compat` is plain WASM: it steps a world and hands back + * transforms, which the caller copies onto `Object3D`s however it likes. + */ +export const repo: AssetRepo = { + id: "rapier-physics", + name: "Rapier Physics", + url: "https://github.com/dimforge/rapier", + category: "tooling", + description: + "Rigid-body and collision physics as a standalone WASM engine — rigid bodies, colliders, joints, character controllers and raycasts. Framework-agnostic, so it drives plain three.js meshes without pulling in React.", + tags: ["physics", "three.js", "wasm", "collision", "rigidbody"], + license: "Apache-2.0", + settings: [ + { + key: "gravityY", + label: "Gravity", + type: "number", + default: -9.81, + min: -50, + max: 50, + step: 0.01, + description: "Vertical acceleration in units/s². Zero gives a space-sim feel.", + }, + { + key: "timestepHz", + label: "Simulation rate", + type: "number", + default: 60, + min: 30, + max: 240, + step: 10, + description: "Fixed physics steps per second, decoupled from render framerate.", + }, + { + key: "solverIterations", + label: "Solver iterations", + type: "number", + default: 4, + min: 1, + max: 16, + step: 1, + description: "Higher values stiffen stacks and joints at proportional CPU cost.", + }, + { + key: "ccd", + label: "Continuous collision", + type: "boolean", + default: false, + description: "Stops fast bodies tunnelling through thin geometry. Costs a broadphase pass.", + }, + { + key: "debugRender", + label: "Debug wireframes", + type: "boolean", + default: false, + description: "Draw collider outlines over the scene to check shapes match the visual mesh.", + }, + ], +}; diff --git a/client/src/lib/assetLibrary/repos/three-stdlib.ts b/client/src/lib/assetLibrary/repos/three-stdlib.ts new file mode 100644 index 00000000..4a7dbf0d --- /dev/null +++ b/client/src/lib/assetLibrary/repos/three-stdlib.ts @@ -0,0 +1,28 @@ +import type { AssetRepo } from "../types"; + +/** + * three.js ships its `examples/jsm` helpers untranspiled and unversioned + * against the core package. three-stdlib repackages the same modules as typed, + * separately versioned ESM, so an agent can import `OrbitControls` or `GLTFLoader` + * without reaching into a version-pinned deep path inside `three` itself. + */ +export const repo: AssetRepo = { + id: "three-stdlib", + name: "three-stdlib", + url: "https://github.com/pmndrs/three-stdlib", + category: "tooling", + description: + "Stand-alone, typed builds of the three.js example modules — camera controls, GLTF/FBX/DRACO loaders, postprocessing passes, geometry and math utilities — importable directly instead of through three/examples/jsm.", + tags: ["three.js", "loaders", "controls", "postprocessing", "utilities"], + license: "MIT", + settings: [ + { + key: "modules", + label: "Preferred modules", + type: "select", + default: "all", + options: ["all", "controls", "loaders", "postprocessing", "geometries"], + description: "Narrows what the agent reaches for first when it needs a helper.", + }, + ], +}; diff --git a/core/src/asset_search.rs b/core/src/asset_search.rs index c441888e..1e9e6565 100644 --- a/core/src/asset_search.rs +++ b/core/src/asset_search.rs @@ -187,7 +187,8 @@ pub async fn search_with( } } if want("library") { - let library = search_library(catalog, &q, types); + let installed = installed_repos(root, slug); + let library = search_library(catalog, &installed, &q, types); counts.insert("library".into(), json!(library.len())); hits.extend(library); } @@ -293,7 +294,31 @@ fn search_local(root: &Path, slug: &str, q: &Query, types: &[String]) -> Result< Ok(hits) } -fn search_library(catalog: &[Value], q: &Query, types: &[String]) -> Vec { +/// What this game has installed, repo id -> attachment. Empty when there is no +/// project in context, which correctly leaves every catalogue entry uninstalled. +fn installed_repos(root: &Path, slug: Option<&str>) -> serde_json::Map { + let Some(slug) = slug else { + return serde_json::Map::new(); + }; + let Ok(project) = crate::store::read_project(root, slug) else { + return serde_json::Map::new(); + }; + project["settings"]["assetRepos"] + .as_object() + .cloned() + .unwrap_or_default() +} + +/// The catalogue is a storefront: every game can *see* every repo, but only one +/// it has installed carries a usable `url`. Repos are metadata-only pointers, so +/// the url is the actionable payload — handing it out for an uninstalled repo +/// would let the agent build against something this project never took on. +fn search_library( + catalog: &[Value], + installed: &serde_json::Map, + q: &Query, + types: &[String], +) -> Vec { let mut hits = Vec::new(); for entry in catalog { let category = entry["category"].as_str().unwrap_or(""); @@ -308,21 +333,38 @@ fn search_library(catalog: &[Value], q: &Query, types: &[String]) -> Vec continue; }; let id = entry["id"].as_str().unwrap_or(""); - hits.push(json!({ + let attachment = installed.get(id); + let mut detail = json!({ + "license": entry["license"], + "description": description, + "settings": entry["settings"] + }); + if let Some(attachment) = attachment { + detail["url"] = entry["url"].clone(); + // This game's tuned values. Reporting the catalogue defaults here + // would misdescribe every project that changed one. + detail["currentSettings"] = attachment + .get("settings") + .cloned() + .unwrap_or_else(|| json!({})); + } + let mut hit = json!({ "source": "library", "id": id, "name": name, "type": category, "score": round3(score), "tags": tags, - "detail": { - "url": entry["url"], - "license": entry["license"], - "description": description, - "settings": entry["settings"] - }, + "installed": attachment.is_some(), + "detail": detail, "pick": { "source": "library", "id": id } - })); + }); + if attachment.is_none() { + hit["hint"] = json!(format!( + "not installed in this game; asset_pick source=library id={id} installs it and returns the url" + )); + } + hits.push(hit); } hits } @@ -784,21 +826,67 @@ mod tests { #[test] fn library_search_scores_the_catalogue() { let catalog = canned_catalog(); + let none = serde_json::Map::new(); let q = Query::parse("barrel"); - let hits = search_library(&catalog, &q, &[]); + let hits = search_library(&catalog, &none, &q, &[]); assert_eq!(hits.len(), 1); assert_eq!(hits[0]["id"], "props-pack"); assert_eq!(hits[0]["detail"]["license"], "CC0"); // Category filter uses the types list. let q = Query::parse("trail"); - assert_eq!(search_library(&catalog, &q, &["vfx".to_string()]).len(), 1); assert_eq!( - search_library(&catalog, &q, &["props".to_string()]).len(), + search_library(&catalog, &none, &q, &["vfx".to_string()]).len(), + 1 + ); + assert_eq!( + search_library(&catalog, &none, &q, &["props".to_string()]).len(), 0 ); } + #[test] + fn library_search_withholds_the_url_until_the_game_installs_it() { + let catalog = canned_catalog(); + let q = Query::parse("barrel"); + + // Uninstalled: discoverable, but nothing the agent can build against. + let hits = search_library(&catalog, &serde_json::Map::new(), &q, &[]); + assert_eq!(hits[0]["installed"], false); + assert!(hits[0]["detail"]["url"].is_null()); + assert!(hits[0]["hint"].as_str().unwrap().contains("asset_pick")); + + // Installed: url appears, and the settings reported are this game's. + let installed = serde_json::Map::from_iter([( + "props-pack".to_string(), + json!({ "settings": { "density": 4 } }), + )]); + let hits = search_library(&catalog, &installed, &q, &[]); + assert_eq!(hits[0]["installed"], true); + assert_eq!(hits[0]["detail"]["url"], "https://example.com/props"); + assert_eq!(hits[0]["detail"]["currentSettings"]["density"], 4); + assert!(hits[0]["hint"].is_null()); + } + + #[test] + fn installed_repos_reads_the_games_own_attachments() { + let root = tempfile::tempdir().unwrap(); + create_project(root.path(), "demo", "Demo").unwrap(); + let catalog = canned_catalog(); + + // Nothing installed, and an absent project is simply "nothing installed". + assert!(installed_repos(root.path(), Some("demo")).is_empty()); + assert!(installed_repos(root.path(), None).is_empty()); + assert!(installed_repos(root.path(), Some("no-such-game")).is_empty()); + + pick_library(&catalog, root.path(), "demo", "props-pack").unwrap(); + let installed = installed_repos(root.path(), Some("demo")); + assert!(installed.contains_key("props-pack")); + // One game installing it leaves another game untouched. + create_project(root.path(), "other", "Other").unwrap(); + assert!(installed_repos(root.path(), Some("other")).is_empty()); + } + #[tokio::test] async fn search_merges_sources_and_reports_errors() { let root = tempfile::tempdir().unwrap(); diff --git a/core/src/tools.rs b/core/src/tools.rs index 07bbd173..956eafcf 100644 --- a/core/src/tools.rs +++ b/core/src/tools.rs @@ -955,12 +955,12 @@ pub fn core_tool_defs() -> Vec { }, ToolDef { name: "asset_search".into(), - description: "Search for assets across the project's local store, the attached asset-repo library catalogue, and PolyHaven's free CC0 catalogue. Returns scored hits with ready-made asset_pick arguments.".into(), + description: "Search for assets across the project's local store, the asset-repo library catalogue, and PolyHaven's free CC0 catalogue. Returns scored hits with ready-made asset_pick arguments. Library hits are marked `installed`: the whole catalogue is browsable, but a repo carries a usable `detail.url` only once this game has installed it — asset_pick installs it and returns the url.".into(), parameters: json!({ "type": "object", "properties": { - "query": {"type": "string", "description": "keywords, e.g. 'wooden barrel'"}, - "slug": {"type": "string", "description": "project slug; required for local hits"}, + "query": {"type": "string", "description": "keywords, e.g. 'wooden barrel'"}, + "slug": {"type": "string", "description": "project slug; required for local hits, and decides which library repos count as installed"}, "sources": {"type": "array", "items": {"type": "string", "enum": ["local", "library", "polyhaven"]}, "description": "default: all three"},