Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions client/src/components/library/AssetsLibraryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function RepoVisual({ repo, tall }: { repo: AssetRepo; tall?: boolean }) {
return (
<div
aria-hidden
className={`relative w-full ${tall ? "h-40 rounded-lg" : "h-24"}`}
className={`relative w-full shrink-0 ${tall ? "h-40 rounded-lg" : "h-24"}`}
style={{ background: repoGradient(repo.id) }}
>
<span className="absolute bottom-2 left-2.5 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-white/85">
Expand Down Expand Up @@ -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"
>
<RepoVisual repo={repo} />
<div className="flex flex-col gap-1.5 p-3">
Expand Down
65 changes: 65 additions & 0 deletions client/src/lib/assetLibrary/repos/rapier-physics.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
],
};
28 changes: 28 additions & 0 deletions client/src/lib/assetLibrary/repos/three-stdlib.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
],
};
114 changes: 101 additions & 13 deletions core/src/asset_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ pub async fn search_with<F: Fetch>(
}
}
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);
}
Expand Down Expand Up @@ -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<Value> {
/// 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<String, Value> {
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<String, Value>,
q: &Query,
types: &[String],
) -> Vec<Value> {
let mut hits = Vec::new();
for entry in catalog {
let category = entry["category"].as_str().unwrap_or("");
Expand All @@ -308,21 +333,38 @@ fn search_library(catalog: &[Value], q: &Query, types: &[String]) -> Vec<Value>
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
}
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions core/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,12 +955,12 @@ pub fn core_tool_defs() -> Vec<ToolDef> {
},
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"},
Expand Down
Loading