@@ -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"},