diff --git a/app/api/test-pipeline/route.ts b/app/api/test-pipeline/route.ts index 14c2b97..60b2d69 100644 --- a/app/api/test-pipeline/route.ts +++ b/app/api/test-pipeline/route.ts @@ -53,14 +53,34 @@ const MCP_TOOLS = [ "focus_viewport_on_objects", "select_scene_objects", "set_active_collection", + "list_materials", + "delete_object", + "set_object_transform", + "rename_object", + "duplicate_object", + "join_objects", "create_mesh_from_data", "validate_mesh_geometry", "inspect_retopology_readiness", "normalize_vertex_group_weights", + "add_modifier", "configure_modifier", "configure_constraint", "add_object_constraint", "remove_object_constraint", + "apply_modifier", + "apply_transforms", + "shade_smooth", + "parent_set", + "parent_clear", + "set_origin", + "organize_collection_hierarchy", + "move_to_collection", + "set_visibility", + "export_object", + "list_installed_addons", + "create_material", + "assign_material", "get_local_asset_library_status", "search_local_assets", "import_local_asset", @@ -84,6 +104,12 @@ const MCP_TOOLS = [ "validate_studio_scene", "render_thumbnail_to_path", "render_viewport_to_path", + "add_light", + "set_light_properties", + "add_camera", + "set_camera_properties", + "set_render_settings", + "render_image", ] const MCP_TOOL_SET = new Set(MCP_TOOLS) diff --git a/lib/ai/agents.ts b/lib/ai/agents.ts index 53da0a3..5e04290 100644 --- a/lib/ai/agents.ts +++ b/lib/ai/agents.ts @@ -1844,6 +1844,16 @@ const importLocalAsset = tool( } ) +const getPolyhavenStatus = tool( + async () => executeMcpCommand("get_polyhaven_status"), + { + name: "get_polyhaven_status", + description: + "Check whether PolyHaven integration is configured and available before searching or downloading HDRIs, textures, or models.", + schema: z.object({}), + } +) + const getPolyhavenCategories = tool( async ({ asset_type }: { asset_type: string }) => executeMcpCommand("get_polyhaven_categories", { asset_type }), @@ -1903,6 +1913,16 @@ const setTexture = tool( // ---------- Sketchfab Tools --------- +const getSketchfabStatus = tool( + async () => executeMcpCommand("get_sketchfab_status"), + { + name: "get_sketchfab_status", + description: + "Check whether Sketchfab integration is configured and available before searching or downloading Sketchfab models.", + schema: z.object({}), + } +) + const searchSketchfabModels = tool( async ({ query, downloadable }: { query: string; downloadable?: boolean }) => executeMcpCommand("search_sketchfab_models", { query, downloadable }), @@ -1984,8 +2004,9 @@ const importGeneratedAsset = tool( // Tool Sets (filtered by config) // ============================================================================ -const SKETCHFAB_TOOL_NAMES = new Set(["search_sketchfab_models", "download_sketchfab_model"]) +const SKETCHFAB_TOOL_NAMES = new Set(["get_sketchfab_status", "search_sketchfab_models", "download_sketchfab_model"]) const POLYHAVEN_TOOL_NAMES = new Set([ + "get_polyhaven_status", "get_polyhaven_categories", "search_polyhaven_assets", "download_polyhaven_asset", @@ -2064,10 +2085,12 @@ const ALL_TOOLS = [ getLocalAssetLibraryStatus, searchLocalAssets, importLocalAsset, + getPolyhavenStatus, getPolyhavenCategories, searchPolyhavenAssets, downloadPolyhavenAsset, setTexture, + getSketchfabStatus, searchSketchfabModels, downloadSketchfabModel, getHyper3dStatus, diff --git a/lib/orchestration/tool-filter.ts b/lib/orchestration/tool-filter.ts index 92c8a7c..91f4df9 100644 --- a/lib/orchestration/tool-filter.ts +++ b/lib/orchestration/tool-filter.ts @@ -1,14 +1,14 @@ import { TOOL_REGISTRY } from "./tool-registry" const CATEGORY_GROUPS: Record = { - inspection: ["get_scene_info", "get_object_info", "get_all_object_info", "inspect_blend_file_health", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "inspect_animation_data", "inspect_collection_hierarchy", "inspect_viewport_areas", "set_viewport_shading", "focus_viewport_on_objects", "select_scene_objects", "set_active_collection", "get_viewport_screenshot", "render_viewport_to_path"], - geometry: ["create_mesh_from_data", "validate_mesh_geometry", "inspect_retopology_readiness", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "normalize_vertex_group_weights", "configure_modifier", "configure_constraint", "add_object_constraint", "remove_object_constraint", "execute_code"], + inspection: ["get_scene_info", "get_object_info", "get_all_object_info", "inspect_blend_file_health", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "inspect_animation_data", "inspect_collection_hierarchy", "inspect_viewport_areas", "set_viewport_shading", "focus_viewport_on_objects", "select_scene_objects", "set_active_collection", "get_viewport_screenshot", "render_viewport_to_path", "list_materials", "list_installed_addons"], + geometry: ["create_mesh_from_data", "validate_mesh_geometry", "inspect_retopology_readiness", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "normalize_vertex_group_weights", "delete_object", "set_object_transform", "rename_object", "duplicate_object", "join_objects", "add_modifier", "configure_modifier", "configure_constraint", "add_object_constraint", "remove_object_constraint", "apply_modifier", "apply_transforms", "shade_smooth", "set_origin", "execute_code"], animation: ["inspect_animation_data", "execute_code"], organization: ["inspect_collection_hierarchy", "organize_collection_hierarchy", "select_scene_objects", "set_active_collection", "parent_set", "parent_clear", "move_to_collection", "set_visibility"], - materials: ["create_material_preset", "inspect_material_node_graph", "set_texture"], + materials: ["list_materials", "create_material", "assign_material", "create_material_preset", "inspect_material_node_graph", "set_texture"], pipeline: ["prepare_uv_layout", "validate_export_readiness", "export_object"], - lighting: ["setup_studio_scene", "validate_studio_scene", "render_thumbnail_to_path", "add_light", "set_light_properties", "render_image"], - camera: ["inspect_viewport_areas", "focus_viewport_on_objects", "get_viewport_screenshot", "render_viewport_to_path", "setup_studio_scene", "validate_studio_scene", "render_thumbnail_to_path", "add_camera", "set_camera_properties", "render_image"], + lighting: ["setup_studio_scene", "validate_studio_scene", "render_thumbnail_to_path", "add_light", "set_light_properties", "set_render_settings", "render_image"], + camera: ["inspect_viewport_areas", "focus_viewport_on_objects", "get_viewport_screenshot", "render_viewport_to_path", "setup_studio_scene", "validate_studio_scene", "render_thumbnail_to_path", "add_camera", "set_camera_properties", "set_render_settings", "render_image"], viewport: ["inspect_viewport_areas", "set_viewport_shading", "focus_viewport_on_objects", "select_scene_objects", "set_active_collection", "get_viewport_screenshot", "render_viewport_to_path"], assets: [ "get_local_asset_library_status", diff --git a/lib/orchestration/tool-registry.ts b/lib/orchestration/tool-registry.ts index b7bfcae..9cf4263 100644 --- a/lib/orchestration/tool-registry.ts +++ b/lib/orchestration/tool-registry.ts @@ -78,6 +78,139 @@ export const TOOL_REGISTRY: ToolMetadata[] = [ category: "inspection", parameters: "collection_name: string, create_new?: boolean", }, + { + name: "list_materials", + description: + "List existing Blender materials before reusing, assigning, or replacing material slots.", + category: "materials", + parameters: "(no parameters)", + }, + { + name: "delete_object", + description: + "Delete a named object from the scene without generated Python. Use after inspecting scene state and confirming the exact object name.", + category: "geometry", + parameters: "name: string", + }, + { + name: "set_object_transform", + description: + "Set object location, rotation, or scale directly with structured parameters instead of Python snippets.", + category: "geometry", + parameters: "name: string, location?: number[3], rotation?: number[3] degrees, scale?: number[3]", + }, + { + name: "rename_object", + description: + "Rename an object with a deterministic direct tool so later tool calls can reference stable names.", + category: "geometry", + parameters: "name: string, new_name: string", + }, + { + name: "duplicate_object", + description: + "Duplicate an existing object and optionally place it, preserving the source object as-is.", + category: "geometry", + parameters: "name: string, new_name?: string, linked?: boolean", + }, + { + name: "join_objects", + description: + "Join multiple mesh objects into one named object when the scene needs a single combined mesh.", + category: "geometry", + parameters: "names: string[], new_name?: string", + }, + { + name: "add_modifier", + description: + "Add a Blender modifier to an object using structured parameters before configuring or applying it.", + category: "geometry", + parameters: "name: string, modifier_type: string, modifier_name?: string", + }, + { + name: "apply_modifier", + description: + "Apply a named modifier after stack inspection and validation.", + category: "geometry", + parameters: "name: string, modifier: string", + }, + { + name: "apply_transforms", + description: + "Apply object location, rotation, or scale transforms with explicit booleans.", + category: "geometry", + parameters: "name: string, location?: boolean, rotation?: boolean, scale?: boolean", + }, + { + name: "shade_smooth", + description: + "Set smooth or flat shading for mesh objects without Python.", + category: "geometry", + parameters: "name: string, smooth?: boolean", + }, + { + name: "parent_set", + description: + "Parent one or more child objects to a parent while preserving the intended scene hierarchy.", + category: "geometry", + parameters: "child_name: string, parent_name: string, parent_type?: OBJECT|ARMATURE|BONE", + }, + { + name: "parent_clear", + description: + "Clear object parenting with optional transform preservation.", + category: "geometry", + parameters: "name: string, keep_transform?: boolean", + }, + { + name: "set_origin", + description: + "Set object origin using a bounded direct tool instead of context-sensitive Python operators.", + category: "geometry", + parameters: "name: string, origin_type?: ORIGIN_GEOMETRY|ORIGIN_CURSOR|GEOMETRY_ORIGIN|ORIGIN_CENTER_OF_VOLUME, center?: MEDIAN|BOUNDS", + }, + { + name: "move_to_collection", + description: + "Move objects into an existing or newly created collection for deterministic scene organization.", + category: "geometry", + parameters: "name: string, collection_name: string, create_new?: boolean", + }, + { + name: "set_visibility", + description: + "Set viewport and/or render visibility for a named object.", + category: "geometry", + parameters: "name: string, hide_viewport?: boolean, hide_render?: boolean", + }, + { + name: "export_object", + description: + "Export named objects to GLB, GLTF, FBX, OBJ, or STL after export readiness validation.", + category: "advanced", + parameters: "names: string[], filepath: string, file_format?: GLB|GLTF|FBX|OBJ|STL", + }, + { + name: "list_installed_addons", + description: + "List enabled Blender addons so the agent can adapt to available capabilities and avoid assuming unavailable integrations.", + category: "inspection", + parameters: "(no parameters)", + }, + { + name: "create_material", + description: + "Create a basic Blender material with structured color and shader parameters when a full preset is unnecessary.", + category: "materials", + parameters: "name: string, color?: number[4], roughness?: number, metallic?: number", + }, + { + name: "assign_material", + description: + "Assign an existing material to an object by name without generated Python.", + category: "materials", + parameters: "object_name: string, material_name: string, slot_index?: number", + }, { name: "execute_code", description: @@ -196,6 +329,54 @@ export const TOOL_REGISTRY: ToolMetadata[] = [ parameters: "output_path?: string, target_names?: string[], preset?: studio|product|indoor|exterior|night, camera_name?: string, resolution?: number, samples?: number, file_format?: string, frame_camera?: boolean, distance_multiplier?: number, focal_length?: number", }, + { + name: "render_image", + description: + "Render the current scene to an image file using current render settings, optionally overriding output path or file format.", + category: "lighting", + parameters: + "output_path?: string, file_format?: PNG|JPEG|OPEN_EXR|TIFF", + }, + { + name: "add_light", + description: + "Add a new POINT, SUN, SPOT, or AREA light with optional location, energy, and RGB color. Prefer this over execute_code for simple scene lighting.", + category: "lighting", + parameters: + "light_type?: POINT|SUN|SPOT|AREA, name?: string, location?: number[3], energy?: number, color?: number[3]", + }, + { + name: "set_light_properties", + description: + "Modify an existing light's energy, color, softness, spot cone, spot blend, or area size without generated Python.", + category: "lighting", + parameters: + "name: string, energy?: number, color?: number[3], shadow_soft_size?: number, spot_size?: number, spot_blend?: number, size?: number", + }, + { + name: "add_camera", + description: + "Add a new camera with optional location, rotation in degrees, lens, and sensor width. Use for explicit camera creation before rendering.", + category: "lighting", + parameters: + "name?: string, location?: number[3], rotation?: number[3] degrees, lens?: number, sensor_width?: number", + }, + { + name: "set_camera_properties", + description: + "Modify an existing camera's lens, sensor width, clipping, depth of field, and active-scene-camera state. Make the camera active with set_active=true before render_image.", + category: "lighting", + parameters: + "name: string, lens?: number, sensor_width?: number, clip_start?: number, clip_end?: number, dof_use?: boolean, dof_focus_distance?: number, dof_aperture_fstop?: number, set_active?: boolean", + }, + { + name: "set_render_settings", + description: + "Configure render engine, resolution, samples, denoising, transparency, output path, and file format before preview or final renders.", + category: "lighting", + parameters: + "engine?: BLENDER_EEVEE_NEXT|BLENDER_EEVEE|CYCLES|BLENDER_WORKBENCH, resolution_x?: number, resolution_y?: number, resolution_percentage?: number, samples?: number, use_denoising?: boolean, film_transparent?: boolean, output_path?: string, file_format?: PNG|JPEG|OPEN_EXR|TIFF", + }, { name: "create_mesh_from_data", description: diff --git a/scripts/test/test-camera-light-render-tool-registry.ts b/scripts/test/test-camera-light-render-tool-registry.ts new file mode 100644 index 0000000..1fdb521 --- /dev/null +++ b/scripts/test/test-camera-light-render-tool-registry.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" + +import { TOOL_REGISTRY } from "../../lib/orchestration/tool-registry" +import { filterRelevantTools, formatToolListForPrompt } from "../../lib/orchestration/tool-filter" + +const expectedTools = [ + "add_light", + "set_light_properties", + "add_camera", + "set_camera_properties", + "set_render_settings", +] + +for (const toolName of expectedTools) { + const metadata = TOOL_REGISTRY.find((tool) => tool.name === toolName) + assert.ok(metadata, `${toolName} should be described in TOOL_REGISTRY`) + assert.ok(metadata.description.length > 40, `${toolName} should have useful planner guidance`) + assert.ok(metadata.parameters?.length, `${toolName} should document its parameters`) +} + +const lightingTools = filterRelevantTools("Add a warm spotlight, a camera, set render settings, and render a preview") +for (const toolName of expectedTools) { + assert.ok(lightingTools.includes(toolName), `${toolName} should be selected for camera/light/render requests`) +} + +const promptToolList = formatToolListForPrompt(expectedTools) +assert.match(promptToolList, /Add a new POINT, SUN, SPOT, or AREA light/) +assert.match(promptToolList, /Make the camera active/) +assert.match(promptToolList, /Configure render engine/) + +const testPipelineSource = readFileSync("app/api/test-pipeline/route.ts", "utf8") +for (const toolName of expectedTools) { + assert.match(testPipelineSource, new RegExp(`"${toolName}"`), `${toolName} should be allowed in the dev test pipeline`) +} + +console.log("Camera, light, and render tool registry tests passed") diff --git a/scripts/test/test-direct-tool-surface-coverage.ts b/scripts/test/test-direct-tool-surface-coverage.ts new file mode 100644 index 0000000..9d969eb --- /dev/null +++ b/scripts/test/test-direct-tool-surface-coverage.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" + +const agentSource = readFileSync("lib/ai/agents.ts", "utf8") +const registrySource = readFileSync("lib/orchestration/tool-registry.ts", "utf8") +const filterSource = readFileSync("lib/orchestration/tool-filter.ts", "utf8") +const testPipelineSource = readFileSync("app/api/test-pipeline/route.ts", "utf8") +const addonSource = readFileSync("desktop/assets/vipermesh-addon.py", "utf8") + +const docLookupTools = new Set([ + "search_blender_api_docs", + "get_blender_api_doc", + "search_blender_manual_docs", +]) + +function extractBalancedBlock(source: string, declarationName: string) { + const start = source.indexOf(declarationName) + assert.notEqual(start, -1, `${declarationName} should exist`) + + const openIndex = source.indexOf("{", start) + assert.notEqual(openIndex, -1, `${declarationName} should have an object literal`) + + let depth = 0 + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index] + if (char === "{") depth += 1 + if (char === "}") depth -= 1 + if (depth === 0) return source.slice(openIndex, index + 1) + } + + throw new Error(`${declarationName} object literal was not closed`) +} + +function extractArrayLiteral(source: string, declarationName: string) { + const start = source.indexOf(declarationName) + assert.notEqual(start, -1, `${declarationName} should exist`) + + const openIndex = source.indexOf("[", start) + assert.notEqual(openIndex, -1, `${declarationName} should have an array literal`) + + let depth = 0 + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index] + if (char === "[") depth += 1 + if (char === "]") depth -= 1 + if (depth === 0) return source.slice(openIndex, index + 1) + } + + throw new Error(`${declarationName} array literal was not closed`) +} + +function quotedToolNames(source: string) { + return new Set([...source.matchAll(/"([a-z][a-z0-9_]+)"/g)].map((match) => match[1])) +} + +const agentToolNames = new Set( + [...agentSource.matchAll(/name:\s*"([a-z][a-z0-9_]+)"/g)] + .map((match) => match[1]) + .filter((name) => !name.endsWith("Middleware")) +) +const addonCommandNames = new Set( + [...addonSource.matchAll(/"([a-z][a-z0-9_]+)":\s*self\.[a-z][a-z0-9_]+/g)].map((match) => match[1]) +) +const registryNames = new Set( + [...registrySource.matchAll(/name:\s*"([a-z][a-z0-9_]+)"/g)].map((match) => match[1]) +) +const filterNames = quotedToolNames(extractBalancedBlock(filterSource, "CATEGORY_GROUPS")) +const testPipelineNames = quotedToolNames(extractArrayLiteral(testPipelineSource, "MCP_TOOLS")) + +const expectedMcpTools = [...addonCommandNames].filter((name) => !docLookupTools.has(name)).sort() +const exposedAgentTools = [...agentToolNames].filter((name) => !docLookupTools.has(name)).sort() + +assert.deepEqual(exposedAgentTools, expectedMcpTools, "LangGraph agent tools should mirror addon MCP command names") + +for (const toolName of expectedMcpTools) { + assert.ok(registryNames.has(toolName), `${toolName} should have TOOL_REGISTRY metadata`) + assert.ok(filterNames.has(toolName), `${toolName} should be selectable by tool filtering`) + assert.ok(testPipelineNames.has(toolName), `${toolName} should be allowlisted in the dev test pipeline`) +} + +console.log("Direct tool surface coverage tests passed")