diff --git a/data/tool-guides/object-assembly-guide.md b/data/tool-guides/object-assembly-guide.md index 817f93f..cf4569f 100644 --- a/data/tool-guides/object-assembly-guide.md +++ b/data/tool-guides/object-assembly-guide.md @@ -1,8 +1,8 @@ --- title: "Multi-Part Object Assembly Guide" category: "object-assembly" -tags: ["assembly", "compound", "multi-part", "parent", "attach", "connect", "hierarchy", "empty", "root-control", "locator", "pole", "arm", "joint", "furniture", "vehicle", "building", "add_empty_object", "set_empty_properties", "parent_set", "set_object_transform"] -triggered_by: ["add_empty_object", "set_empty_properties", "parent_set", "set_object_transform"] +tags: ["assembly", "compound", "multi-part", "parent", "attach", "connect", "hierarchy", "empty", "root-control", "locator", "pole", "arm", "joint", "furniture", "vehicle", "building", "array", "pattern", "grid", "radial", "add_empty_object", "set_empty_properties", "parent_set", "set_object_transform", "arrange_objects", "duplicate_object_pattern"] +triggered_by: ["add_empty_object", "set_empty_properties", "parent_set", "set_object_transform", "arrange_objects", "duplicate_object_pattern"] description: "Domain knowledge for building compound objects from primitives. Covers connection point calculation, parent-child assembly, flush attachment, and common archetypes like furniture, street infrastructure, and architectural elements." blender_version: "4.0+" --- @@ -28,6 +28,55 @@ When building any compound object from primitives: 4. **Create child parts at calculated positions** — Each child's position derives from its parent geometry 5. **Optionally parent all parts** — Group under an Empty for unified control +## DIRECT PATTERN TOOLS + +Use `arrange_objects` and `duplicate_object_pattern` before `execute_code` for repeated objects, scene dressing, and symmetrical layouts. These tools cover the common Python-loop cases: + +- bar stools in front of a counter +- columns, fence posts, books, lamps, table legs, wheels, bolts, and tiles +- product objects evenly spaced on a pedestal +- plants/rocks/props distributed in a row or grid +- circular/radial displays around a centerpiece + +### Arrange Existing Objects + +Use this when all objects already exist and only need clean spacing: + +```text +arrange_objects( + names=["Ball_Red", "Ball_Green", "Ball_Blue"], + layout="CIRCLE", + center=[0, 0, 0.25], + radius=0.9, + plane="XY", + align_bottom_to_z=0.1 +) +``` + +`align_bottom_to_z` grounds every arranged object by its bounding-box bottom. Use it for objects sitting on a shared plane such as a pedestal top, floor, shelf, or table. + +### Duplicate Into A Pattern + +Use this when one source object should become many repeated parts: + +```text +duplicate_object_pattern( + name="Stool", + count=3, + layout="LINE", + name_prefix="Counter_Stool", + spacing=0.75, + axis="X", + center=[0, 1.2, 0], + include_source=true, + align_bottom_to_z=0 +) +``` + +Prefer `linked=true` for lightweight repeated mesh instances when the duplicates should share geometry. Use `linked=false` when later edits may make the copies unique. + +Do not write generated Python loops for simple rows, grids, or radial duplication. Use focused `execute_code` only when the pattern must follow a custom curve, irregular terrain, or semantic per-item variation that these direct tools cannot express. + ### Connection Point Formulas **Top of a vertical piece (pole, leg, column):** diff --git a/desktop/assets/vipermesh-addon.py b/desktop/assets/vipermesh-addon.py index 024be24..f8fa280 100644 --- a/desktop/assets/vipermesh-addon.py +++ b/desktop/assets/vipermesh-addon.py @@ -232,6 +232,8 @@ def _execute_command_internal(self, command): "align_object_attachment_points": self.align_object_attachment_points, "rename_object": self.rename_object, "duplicate_object": self.duplicate_object, + "arrange_objects": self.arrange_objects, + "duplicate_object_pattern": self.duplicate_object_pattern, "join_objects": self.join_objects, "add_empty_object": self.add_empty_object, "set_empty_properties": self.set_empty_properties, @@ -1600,6 +1602,260 @@ def duplicate_object(self, name, new_name=None, linked=False): except Exception as e: return {"error": f"Failed to duplicate object: {str(e)}"} + def _pattern_positions( + self, + count, + layout="LINE", + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + ): + if count < 1: + raise ValueError("count must be at least 1") + + def vector3(value, fallback, label): + raw = fallback if value is None else value + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + raise ValueError(f"{label} must be [x,y,z]") + return Vector((float(raw[0]), float(raw[1]), float(raw[2]))) + + resolved_center = vector3(center, (0.0, 0.0, 0.0), "center") + resolved_layout = str(layout or "LINE").upper() + resolved_axis = str(axis or "X").upper() + resolved_plane = str(plane or "XY").upper() + + axis_vectors = { + "X": Vector((1.0, 0.0, 0.0)), + "Y": Vector((0.0, 1.0, 0.0)), + "Z": Vector((0.0, 0.0, 1.0)), + } + plane_axes = { + "XY": (Vector((1.0, 0.0, 0.0)), Vector((0.0, 1.0, 0.0))), + "XZ": (Vector((1.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))), + "YZ": (Vector((0.0, 1.0, 0.0)), Vector((0.0, 0.0, 1.0))), + } + + if resolved_layout == "LINE": + if isinstance(spacing, (list, tuple)): + step = vector3(spacing, (1.0, 0.0, 0.0), "spacing") + else: + if resolved_axis not in axis_vectors: + raise ValueError("axis must be X, Y, or Z") + step = axis_vectors[resolved_axis] * float(1.0 if spacing is None else spacing) + midpoint = (count - 1) / 2.0 + return [resolved_center + step * (index - midpoint) for index in range(count)] + + if resolved_layout == "CIRCLE": + if resolved_plane not in plane_axes: + raise ValueError("plane must be XY, XZ, or YZ") + basis_a, basis_b = plane_axes[resolved_plane] + resolved_radius = max(float(radius), 0.0) + start_angle = math.radians(float(start_angle_degrees or 0.0)) + angle_step = (math.tau / count) if count > 0 else 0.0 + return [ + resolved_center + + basis_a * (math.cos(start_angle + angle_step * index) * resolved_radius) + + basis_b * (math.sin(start_angle + angle_step * index) * resolved_radius) + for index in range(count) + ] + + if resolved_layout == "GRID": + if resolved_plane not in plane_axes: + raise ValueError("plane must be XY, XZ, or YZ") + basis_a, basis_b = plane_axes[resolved_plane] + resolved_columns = int(columns or math.ceil(math.sqrt(count))) + if resolved_columns < 1: + raise ValueError("columns must be at least 1") + if isinstance(spacing, (list, tuple)): + step_a = float(spacing[0]) + step_b = float(spacing[1] if len(spacing) > 1 else spacing[0]) + else: + step_a = float(1.0 if spacing is None else spacing) + step_b = float(row_spacing if row_spacing is not None else step_a) + rows = math.ceil(count / resolved_columns) + x_midpoint = (resolved_columns - 1) / 2.0 + y_midpoint = (rows - 1) / 2.0 + positions = [] + for index in range(count): + column = index % resolved_columns + row = index // resolved_columns + positions.append( + resolved_center + + basis_a * ((column - x_midpoint) * step_a) + + basis_b * ((row - y_midpoint) * step_b) + ) + return positions + + raise ValueError("layout must be LINE, CIRCLE, or GRID") + + def _align_object_bottom_to_z(self, obj, target_z): + if target_z is None: + return + if not hasattr(obj, "bound_box") or not obj.bound_box: + obj.location.z = float(target_z) + return + bottom_z = min((obj.matrix_world @ Vector(corner)).z for corner in obj.bound_box) + obj.location.z += float(target_z) - bottom_z + + def _move_object_to_pattern_position(self, obj, position, align_bottom_to_z=None): + matrix = obj.matrix_world.copy() + matrix.translation = Vector(position) + obj.matrix_world = matrix + self._align_object_bottom_to_z(obj, align_bottom_to_z) + + def arrange_objects( + self, + names, + layout="LINE", + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + align_bottom_to_z=None, + ): + """Arrange existing objects into a line, circle, or grid without Python loops.""" + try: + if not isinstance(names, (list, tuple)) or not names: + return {"error": "names must be a non-empty list"} + objects = [] + for name in names: + obj = bpy.data.objects.get(str(name)) + if not obj: + return {"error": f"Object not found: {name}"} + objects.append(obj) + + positions = self._pattern_positions( + len(objects), + layout=layout, + center=center, + spacing=spacing, + axis=axis, + radius=radius, + plane=plane, + columns=columns, + row_spacing=row_spacing, + start_angle_degrees=start_angle_degrees, + ) + + arranged = [] + for obj, position in zip(objects, positions): + self._move_object_to_pattern_position(obj, position, align_bottom_to_z) + arranged.append({ + "name": obj.name, + "location": [round(value, 6) for value in obj.location], + }) + + return { + "success": True, + "layout": str(layout or "LINE").upper(), + "arranged_count": len(arranged), + "objects": arranged, + "next_safe_action": "validate_object_clearance or inspect with get_all_object_info after arranging objects", + } + except Exception as e: + return {"error": f"Failed to arrange objects: {str(e)}"} + + def duplicate_object_pattern( + self, + name, + count=1, + layout="LINE", + name_prefix=None, + linked=False, + include_source=False, + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + align_bottom_to_z=None, + ): + """Duplicate one object into a repeated line, circle, or grid pattern.""" + try: + source = bpy.data.objects.get(name) + if not source: + return {"error": f"Object not found: {name}"} + resolved_count = int(count or 1) + if resolved_count < 1 or resolved_count > 100: + return {"error": "count must be between 1 and 100"} + + def coerce_bool(value, default=False): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + resolved_linked = coerce_bool(linked, False) + resolved_include_source = coerce_bool(include_source, False) + objects_to_place = [source] if resolved_include_source else [] + copies_to_create = resolved_count - len(objects_to_place) + if copies_to_create < 0: + copies_to_create = 0 + + collection = source.users_collection[0] if source.users_collection else bpy.context.collection + prefix = str(name_prefix or source.name) + created = [] + for index in range(copies_to_create): + new_obj = source.copy() + if source.data and not resolved_linked: + new_obj.data = source.data.copy() + new_obj.name = f"{prefix}_{index + 1:02d}" if not resolved_include_source else f"{prefix}_{index + 2:02d}" + if new_obj.data and not resolved_linked: + new_obj.data.name = new_obj.name + new_obj.animation_data_clear() + collection.objects.link(new_obj) + objects_to_place.append(new_obj) + created.append(new_obj.name) + + positions = self._pattern_positions( + len(objects_to_place), + layout=layout, + center=center, + spacing=spacing, + axis=axis, + radius=radius, + plane=plane, + columns=columns, + row_spacing=row_spacing, + start_angle_degrees=start_angle_degrees, + ) + + placed = [] + for obj, position in zip(objects_to_place, positions): + self._move_object_to_pattern_position(obj, position, align_bottom_to_z) + placed.append({ + "name": obj.name, + "location": [round(value, 6) for value in obj.location], + }) + + return { + "success": True, + "source": source.name, + "layout": str(layout or "LINE").upper(), + "linked": resolved_linked, + "include_source": resolved_include_source, + "created": created, + "placed": placed, + "next_safe_action": "validate_object_clearance or inspect with get_all_object_info after duplicating the pattern", + } + except Exception as e: + return {"error": f"Failed to duplicate object pattern: {str(e)}"} + def join_objects(self, names): """Join multiple objects into one. First name becomes the active (target) object.""" try: diff --git a/docs/blender-mcp-capability-inventory.md b/docs/blender-mcp-capability-inventory.md index 857a7e5..7e09510 100644 --- a/docs/blender-mcp-capability-inventory.md +++ b/docs/blender-mcp-capability-inventory.md @@ -6,7 +6,7 @@ ## Summary - Official Blender MCP tools found: 26 -- ViperMesh addon commands found: 86 +- ViperMesh addon commands found: 88 - Decision: keep ViperMesh addon as the product connector, use official MCP as a benchmark/reference, and build deterministic ViperMesh tools where both sides currently rely on Python. ## Capability Matrix @@ -70,6 +70,7 @@ - `aim_camera_at` - `apply_modifier` - `apply_transforms` +- `arrange_objects` - `assign_material` - `configure_constraint` - `configure_modifier` @@ -81,6 +82,7 @@ - `download_polyhaven_asset` - `download_sketchfab_model` - `duplicate_object` +- `duplicate_object_pattern` - `execute_code` - `export_object` - `focus_viewport_on_objects` diff --git a/lib/ai/agents.ts b/lib/ai/agents.ts index 2052646..6035c66 100644 --- a/lib/ai/agents.ts +++ b/lib/ai/agents.ts @@ -666,6 +666,142 @@ const duplicateObject = tool( } ) +const arrangeObjects = tool( + async ({ + names, + layout, + center, + spacing, + axis, + radius, + plane, + columns, + row_spacing, + start_angle_degrees, + align_bottom_to_z, + }: { + names: string[] + layout?: "LINE" | "CIRCLE" | "GRID" + center?: number[] + spacing?: number | number[] + axis?: "X" | "Y" | "Z" + radius?: number + plane?: "XY" | "XZ" | "YZ" + columns?: number + row_spacing?: number + start_angle_degrees?: number + align_bottom_to_z?: number + }) => + executeMcpCommand("arrange_objects", { + names, + layout, + center, + spacing, + axis, + radius, + plane, + columns, + row_spacing, + start_angle_degrees, + align_bottom_to_z, + }), + { + name: "arrange_objects", + description: + "Arrange existing objects into a centered LINE, CIRCLE, or GRID pattern without generated Python loops. " + + "Use for evenly spaced products, stools, chairs, columns, scene dressing, circular displays, and repeated parts.", + schema: z.object({ + names: z.array(z.string()).min(1).describe("Existing object names to arrange in order"), + layout: z.enum(["LINE", "CIRCLE", "GRID"]).optional().describe("Pattern layout; defaults to LINE"), + center: z.array(z.number()).length(3).optional().describe("World-space center of the pattern"), + spacing: z.union([z.number(), z.array(z.number()).length(3)]).optional().describe("Line/grid spacing; number or [x,y,z] vector"), + axis: z.enum(["X", "Y", "Z"]).optional().describe("Line axis when spacing is a number"), + radius: z.number().min(0).optional().describe("Circle radius"), + plane: z.enum(["XY", "XZ", "YZ"]).optional().describe("Circle/grid plane"), + columns: z.number().int().min(1).max(50).optional().describe("Grid columns"), + row_spacing: z.number().optional().describe("Grid row spacing when spacing is numeric"), + start_angle_degrees: z.number().optional().describe("Circle start angle in degrees"), + align_bottom_to_z: z.number().optional().describe("Optional ground/support Z plane to snap object bottoms onto"), + }), + } +) + +const duplicateObjectPattern = tool( + async ({ + name, + count, + layout, + name_prefix, + linked, + include_source, + center, + spacing, + axis, + radius, + plane, + columns, + row_spacing, + start_angle_degrees, + align_bottom_to_z, + }: { + name: string + count: number + layout?: "LINE" | "CIRCLE" | "GRID" + name_prefix?: string + linked?: boolean + include_source?: boolean + center?: number[] + spacing?: number | number[] + axis?: "X" | "Y" | "Z" + radius?: number + plane?: "XY" | "XZ" | "YZ" + columns?: number + row_spacing?: number + start_angle_degrees?: number + align_bottom_to_z?: number + }) => + executeMcpCommand("duplicate_object_pattern", { + name, + count, + layout, + name_prefix, + linked, + include_source, + center, + spacing, + axis, + radius, + plane, + columns, + row_spacing, + start_angle_degrees, + align_bottom_to_z, + }), + { + name: "duplicate_object_pattern", + description: + "Duplicate one existing object into a centered LINE, CIRCLE, or GRID pattern without generated Python loops. " + + "Use linked=true for lightweight repeated mesh instances; include_source=true when the original should become the first pattern item.", + schema: z.object({ + name: z.string().describe("Source object to duplicate"), + count: z.number().int().min(1).max(100).describe("Total pattern item count"), + layout: z.enum(["LINE", "CIRCLE", "GRID"]).optional().describe("Pattern layout; defaults to LINE"), + name_prefix: z.string().optional().describe("Prefix for created objects"), + linked: z.boolean().optional().describe("Share mesh data for lightweight instances"), + include_source: z.boolean().optional().describe("Move source object as the first pattern item instead of creating all items as copies"), + center: z.array(z.number()).length(3).optional().describe("World-space center of the pattern"), + spacing: z.union([z.number(), z.array(z.number()).length(3)]).optional().describe("Line/grid spacing; number or [x,y,z] vector"), + axis: z.enum(["X", "Y", "Z"]).optional().describe("Line axis when spacing is a number"), + radius: z.number().min(0).optional().describe("Circle radius"), + plane: z.enum(["XY", "XZ", "YZ"]).optional().describe("Circle/grid plane"), + columns: z.number().int().min(1).max(50).optional().describe("Grid columns"), + row_spacing: z.number().optional().describe("Grid row spacing when spacing is numeric"), + start_angle_degrees: z.number().optional().describe("Circle start angle in degrees"), + align_bottom_to_z: z.number().optional().describe("Optional ground/support Z plane to snap object bottoms onto"), + }), + } +) + const joinObjects = tool( async ({ names }: { names: string[] }) => executeMcpCommand("join_objects", { names }), @@ -2789,6 +2925,8 @@ const ALL_TOOLS = [ alignObjectAttachmentPoints, renameObject, duplicateObject, + arrangeObjects, + duplicateObjectPattern, joinObjects, addEmptyObject, setEmptyProperties, diff --git a/lib/orchestration/prompts/blender-agent-system.md b/lib/orchestration/prompts/blender-agent-system.md index dbadf23..37be00c 100644 --- a/lib/orchestration/prompts/blender-agent-system.md +++ b/lib/orchestration/prompts/blender-agent-system.md @@ -73,6 +73,8 @@ You have access to the following MCP tools. **Use direct tools whenever one matc - `align_object_attachment_points(name, obj_local_point, reference_name, reference_local_point, offset?, preserve_rotation?)`: Translate an object so one of its local attachment points meets a local attachment point on a reference object without freeform Python. - `rename_object(name, new_name)`: Rename a Blender object. - `duplicate_object(name, new_name?, linked?)`: Duplicate an object. +- `arrange_objects(names, layout?, center?, spacing?, axis?, radius?, plane?, columns?, row_spacing?, start_angle_degrees?, align_bottom_to_z?)`: Arrange existing objects into line, circle, or grid patterns without freeform Python loops. +- `duplicate_object_pattern(name, count, layout?, name_prefix?, linked?, include_source?, center?, spacing?, axis?, radius?, plane?, columns?, row_spacing?, start_angle_degrees?, align_bottom_to_z?)`: Duplicate one object into a line, circle, or grid pattern without freeform Python loops. - `join_objects(names)`: Join multiple mesh objects into one. - `delete_object(name)`: Delete an object. - `parent_set(child_name, parent_name, parent_type?)`: Set parent-child relationship. @@ -161,6 +163,8 @@ You have access to the following MCP tools. **Use direct tools whenever one matc | Put objects on ground, on top of supports, or flush beside a support face | `align_object_to_surface` after `get_object_info` | ✓ | | Validate close object placement or clipping | `validate_object_clearance` | ✓ | | Attach rotated parts by local connection points | `align_object_attachment_points` after `get_object_info` | ✓ | +| Evenly space existing objects in a row, grid, or circle | `arrange_objects` | ✓ | +| Create repeated parts or scene dressing from one source object | `duplicate_object_pattern` | ✓ | | Add/configure camera | `add_camera` + `set_camera_properties` | ✓ | | Aim camera at an object or point | `aim_camera_at` | ✓ | | Add/configure lights | `add_light` + `set_light_properties` | ✓ | diff --git a/lib/orchestration/tool-filter.ts b/lib/orchestration/tool-filter.ts index 30a0640..cfe2f53 100644 --- a/lib/orchestration/tool-filter.ts +++ b/lib/orchestration/tool-filter.ts @@ -2,7 +2,7 @@ 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", "list_materials", "list_installed_addons"], - geometry: ["add_empty_object", "set_empty_properties", "add_text_object", "add_mesh_primitive", "add_curve_object", "set_curve_properties", "create_mesh_from_data", "validate_mesh_geometry", "validate_object_clearance", "inspect_retopology_readiness", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "normalize_vertex_group_weights", "delete_object", "set_object_transform", "align_object_to_surface", "align_object_attachment_points", "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"], + geometry: ["add_empty_object", "set_empty_properties", "add_text_object", "add_mesh_primitive", "add_curve_object", "set_curve_properties", "create_mesh_from_data", "validate_mesh_geometry", "validate_object_clearance", "inspect_retopology_readiness", "inspect_modifier_constraint_stack", "inspect_rigging_data", "inspect_weight_paint_readiness", "normalize_vertex_group_weights", "delete_object", "set_object_transform", "align_object_to_surface", "align_object_attachment_points", "rename_object", "duplicate_object", "arrange_objects", "duplicate_object_pattern", "join_objects", "add_modifier", "configure_modifier", "configure_constraint", "add_object_constraint", "remove_object_constraint", "apply_modifier", "apply_transforms", "shade_smooth", "set_origin"], animation: ["inspect_animation_data", "set_timeline_settings", "set_keyframe_animation"], organization: ["inspect_collection_hierarchy", "organize_collection_hierarchy", "select_scene_objects", "set_active_collection", "parent_set", "parent_clear", "move_to_collection", "set_visibility"], materials: ["list_materials", "create_material", "assign_material", "create_material_preset", "inspect_material_node_graph", "set_texture"], @@ -172,6 +172,20 @@ const GEOMETRY_KEYWORD_SOURCES = [ "connect point", "joint", "hinge", + "arrange", + "arrangement", + "distribute", + "distribution", + "evenly spaced", + "space evenly", + "duplicate", + "array", + "pattern", + "grid", + "circle", + "radial", + "row", + "column", ] const GEOMETRY_KEYWORDS = new RegExp( @@ -196,7 +210,7 @@ const KEYWORD_CATEGORY_MAP: Array<{ keywords: RegExp; categories: Array(["get_scene_info"]) diff --git a/lib/orchestration/tool-registry.ts b/lib/orchestration/tool-registry.ts index 7b472b6..ea4a829 100644 --- a/lib/orchestration/tool-registry.ts +++ b/lib/orchestration/tool-registry.ts @@ -136,6 +136,22 @@ export const TOOL_REGISTRY: ToolMetadata[] = [ category: "geometry", parameters: "name: string, new_name?: string, linked?: boolean", }, + { + name: "arrange_objects", + description: + "Arrange existing objects into centered line, circle, or grid patterns without generated Python loops.", + category: "geometry", + parameters: + "names: string[], layout?: LINE|CIRCLE|GRID, center?: number[3], spacing?: number|number[], axis?: X|Y|Z, radius?: number, plane?: XY|XZ|YZ, columns?: number, row_spacing?: number, start_angle_degrees?: number, align_bottom_to_z?: number", + }, + { + name: "duplicate_object_pattern", + description: + "Duplicate one source object into a line, circle, or grid pattern without generated Python loops.", + category: "geometry", + parameters: + "name: string, count: number, layout?: LINE|CIRCLE|GRID, name_prefix?: string, linked?: boolean, include_source?: boolean, center?: number[3], spacing?: number|number[], axis?: X|Y|Z, radius?: number, plane?: XY|XZ|YZ, columns?: number, row_spacing?: number, start_angle_degrees?: number, align_bottom_to_z?: number", + }, { name: "join_objects", description: diff --git a/public/downloads/vipermesh-addon.py b/public/downloads/vipermesh-addon.py index 024be24..f8fa280 100644 --- a/public/downloads/vipermesh-addon.py +++ b/public/downloads/vipermesh-addon.py @@ -232,6 +232,8 @@ def _execute_command_internal(self, command): "align_object_attachment_points": self.align_object_attachment_points, "rename_object": self.rename_object, "duplicate_object": self.duplicate_object, + "arrange_objects": self.arrange_objects, + "duplicate_object_pattern": self.duplicate_object_pattern, "join_objects": self.join_objects, "add_empty_object": self.add_empty_object, "set_empty_properties": self.set_empty_properties, @@ -1600,6 +1602,260 @@ def duplicate_object(self, name, new_name=None, linked=False): except Exception as e: return {"error": f"Failed to duplicate object: {str(e)}"} + def _pattern_positions( + self, + count, + layout="LINE", + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + ): + if count < 1: + raise ValueError("count must be at least 1") + + def vector3(value, fallback, label): + raw = fallback if value is None else value + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + raise ValueError(f"{label} must be [x,y,z]") + return Vector((float(raw[0]), float(raw[1]), float(raw[2]))) + + resolved_center = vector3(center, (0.0, 0.0, 0.0), "center") + resolved_layout = str(layout or "LINE").upper() + resolved_axis = str(axis or "X").upper() + resolved_plane = str(plane or "XY").upper() + + axis_vectors = { + "X": Vector((1.0, 0.0, 0.0)), + "Y": Vector((0.0, 1.0, 0.0)), + "Z": Vector((0.0, 0.0, 1.0)), + } + plane_axes = { + "XY": (Vector((1.0, 0.0, 0.0)), Vector((0.0, 1.0, 0.0))), + "XZ": (Vector((1.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))), + "YZ": (Vector((0.0, 1.0, 0.0)), Vector((0.0, 0.0, 1.0))), + } + + if resolved_layout == "LINE": + if isinstance(spacing, (list, tuple)): + step = vector3(spacing, (1.0, 0.0, 0.0), "spacing") + else: + if resolved_axis not in axis_vectors: + raise ValueError("axis must be X, Y, or Z") + step = axis_vectors[resolved_axis] * float(1.0 if spacing is None else spacing) + midpoint = (count - 1) / 2.0 + return [resolved_center + step * (index - midpoint) for index in range(count)] + + if resolved_layout == "CIRCLE": + if resolved_plane not in plane_axes: + raise ValueError("plane must be XY, XZ, or YZ") + basis_a, basis_b = plane_axes[resolved_plane] + resolved_radius = max(float(radius), 0.0) + start_angle = math.radians(float(start_angle_degrees or 0.0)) + angle_step = (math.tau / count) if count > 0 else 0.0 + return [ + resolved_center + + basis_a * (math.cos(start_angle + angle_step * index) * resolved_radius) + + basis_b * (math.sin(start_angle + angle_step * index) * resolved_radius) + for index in range(count) + ] + + if resolved_layout == "GRID": + if resolved_plane not in plane_axes: + raise ValueError("plane must be XY, XZ, or YZ") + basis_a, basis_b = plane_axes[resolved_plane] + resolved_columns = int(columns or math.ceil(math.sqrt(count))) + if resolved_columns < 1: + raise ValueError("columns must be at least 1") + if isinstance(spacing, (list, tuple)): + step_a = float(spacing[0]) + step_b = float(spacing[1] if len(spacing) > 1 else spacing[0]) + else: + step_a = float(1.0 if spacing is None else spacing) + step_b = float(row_spacing if row_spacing is not None else step_a) + rows = math.ceil(count / resolved_columns) + x_midpoint = (resolved_columns - 1) / 2.0 + y_midpoint = (rows - 1) / 2.0 + positions = [] + for index in range(count): + column = index % resolved_columns + row = index // resolved_columns + positions.append( + resolved_center + + basis_a * ((column - x_midpoint) * step_a) + + basis_b * ((row - y_midpoint) * step_b) + ) + return positions + + raise ValueError("layout must be LINE, CIRCLE, or GRID") + + def _align_object_bottom_to_z(self, obj, target_z): + if target_z is None: + return + if not hasattr(obj, "bound_box") or not obj.bound_box: + obj.location.z = float(target_z) + return + bottom_z = min((obj.matrix_world @ Vector(corner)).z for corner in obj.bound_box) + obj.location.z += float(target_z) - bottom_z + + def _move_object_to_pattern_position(self, obj, position, align_bottom_to_z=None): + matrix = obj.matrix_world.copy() + matrix.translation = Vector(position) + obj.matrix_world = matrix + self._align_object_bottom_to_z(obj, align_bottom_to_z) + + def arrange_objects( + self, + names, + layout="LINE", + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + align_bottom_to_z=None, + ): + """Arrange existing objects into a line, circle, or grid without Python loops.""" + try: + if not isinstance(names, (list, tuple)) or not names: + return {"error": "names must be a non-empty list"} + objects = [] + for name in names: + obj = bpy.data.objects.get(str(name)) + if not obj: + return {"error": f"Object not found: {name}"} + objects.append(obj) + + positions = self._pattern_positions( + len(objects), + layout=layout, + center=center, + spacing=spacing, + axis=axis, + radius=radius, + plane=plane, + columns=columns, + row_spacing=row_spacing, + start_angle_degrees=start_angle_degrees, + ) + + arranged = [] + for obj, position in zip(objects, positions): + self._move_object_to_pattern_position(obj, position, align_bottom_to_z) + arranged.append({ + "name": obj.name, + "location": [round(value, 6) for value in obj.location], + }) + + return { + "success": True, + "layout": str(layout or "LINE").upper(), + "arranged_count": len(arranged), + "objects": arranged, + "next_safe_action": "validate_object_clearance or inspect with get_all_object_info after arranging objects", + } + except Exception as e: + return {"error": f"Failed to arrange objects: {str(e)}"} + + def duplicate_object_pattern( + self, + name, + count=1, + layout="LINE", + name_prefix=None, + linked=False, + include_source=False, + center=None, + spacing=None, + axis="X", + radius=1.0, + plane="XY", + columns=None, + row_spacing=None, + start_angle_degrees=0.0, + align_bottom_to_z=None, + ): + """Duplicate one object into a repeated line, circle, or grid pattern.""" + try: + source = bpy.data.objects.get(name) + if not source: + return {"error": f"Object not found: {name}"} + resolved_count = int(count or 1) + if resolved_count < 1 or resolved_count > 100: + return {"error": "count must be between 1 and 100"} + + def coerce_bool(value, default=False): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + resolved_linked = coerce_bool(linked, False) + resolved_include_source = coerce_bool(include_source, False) + objects_to_place = [source] if resolved_include_source else [] + copies_to_create = resolved_count - len(objects_to_place) + if copies_to_create < 0: + copies_to_create = 0 + + collection = source.users_collection[0] if source.users_collection else bpy.context.collection + prefix = str(name_prefix or source.name) + created = [] + for index in range(copies_to_create): + new_obj = source.copy() + if source.data and not resolved_linked: + new_obj.data = source.data.copy() + new_obj.name = f"{prefix}_{index + 1:02d}" if not resolved_include_source else f"{prefix}_{index + 2:02d}" + if new_obj.data and not resolved_linked: + new_obj.data.name = new_obj.name + new_obj.animation_data_clear() + collection.objects.link(new_obj) + objects_to_place.append(new_obj) + created.append(new_obj.name) + + positions = self._pattern_positions( + len(objects_to_place), + layout=layout, + center=center, + spacing=spacing, + axis=axis, + radius=radius, + plane=plane, + columns=columns, + row_spacing=row_spacing, + start_angle_degrees=start_angle_degrees, + ) + + placed = [] + for obj, position in zip(objects_to_place, positions): + self._move_object_to_pattern_position(obj, position, align_bottom_to_z) + placed.append({ + "name": obj.name, + "location": [round(value, 6) for value in obj.location], + }) + + return { + "success": True, + "source": source.name, + "layout": str(layout or "LINE").upper(), + "linked": resolved_linked, + "include_source": resolved_include_source, + "created": created, + "placed": placed, + "next_safe_action": "validate_object_clearance or inspect with get_all_object_info after duplicating the pattern", + } + except Exception as e: + return {"error": f"Failed to duplicate object pattern: {str(e)}"} + def join_objects(self, names): """Join multiple objects into one. First name becomes the active (target) object.""" try: diff --git a/scripts/test/test-blender-procedural-helper-tools.ts b/scripts/test/test-blender-procedural-helper-tools.ts new file mode 100644 index 0000000..92bfb23 --- /dev/null +++ b/scripts/test/test-blender-procedural-helper-tools.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" + +import { extractViperMeshCommandNames } from "../../lib/blender/capability-inventory" +import { filterRelevantTools, formatToolListForPrompt } from "../../lib/orchestration/tool-filter" + +const addonSource = readFileSync("desktop/assets/vipermesh-addon.py", "utf8") +const publicAddonSource = readFileSync("public/downloads/vipermesh-addon.py", "utf8") + +function extractPythonMethod(source: string, methodName: string): string { + const startToken = ` def ${methodName}(` + const start = source.indexOf(startToken) + assert.notEqual(start, -1, `Expected ${methodName} definition`) + const next = source.indexOf("\n def ", start + startToken.length) + return source.slice(start, next === -1 ? source.length : next) +} + +for (const [label, source] of [ + ["desktop addon", addonSource], + ["public addon", publicAddonSource], +] as const) { + const commands = extractViperMeshCommandNames(source) + assert.ok(commands.includes("arrange_objects"), `${label} exposes arrange_objects`) + assert.ok(commands.includes("duplicate_object_pattern"), `${label} exposes duplicate_object_pattern`) + + const arrangeBody = extractPythonMethod(source, "arrange_objects") + assert.match(arrangeBody, /_pattern_positions/, `${label} uses shared pattern math for arrangements`) + assert.match(arrangeBody, /align_bottom_to_z/, `${label} can ground arranged objects`) + assert.match(source, /matrix\.translation = Vector\(position\)/, `${label} moves objects by matrix translation`) + + const duplicateBody = extractPythonMethod(source, "duplicate_object_pattern") + assert.match(duplicateBody, /source\.copy\(\)/, `${label} duplicates objects without bpy.ops loops`) + assert.match(duplicateBody, /data\.copy\(\)/, `${label} supports unlinked mesh data copies`) + assert.match(duplicateBody, /include_source/, `${label} can include source object as first pattern item`) +} + +const arrangementTools = filterRelevantTools("duplicate the stool in a circle and arrange the chairs evenly around the table") +assert.ok(arrangementTools.includes("arrange_objects")) +assert.ok(arrangementTools.includes("duplicate_object_pattern")) + +const promptToolList = formatToolListForPrompt(["arrange_objects", "duplicate_object_pattern"]) +assert.match(promptToolList, /arrange/i) +assert.match(promptToolList, /duplicate/i) + +const agentSource = readFileSync("lib/ai/agents.ts", "utf8") +assert.match(agentSource, /executeMcpCommand\("arrange_objects"/) +assert.match(agentSource, /executeMcpCommand\("duplicate_object_pattern"/) + +const systemPrompt = readFileSync("lib/orchestration/prompts/blender-agent-system.md", "utf8") +assert.match(systemPrompt, /arrange_objects/) +assert.match(systemPrompt, /duplicate_object_pattern/) + +const assemblyGuide = readFileSync("data/tool-guides/object-assembly-guide.md", "utf8") +assert.match(assemblyGuide, /arrange_objects/) +assert.match(assemblyGuide, /duplicate_object_pattern/) + +console.log("Blender procedural helper tool tests passed") diff --git a/scripts/test/test-tool-guide-trigger-coverage.ts b/scripts/test/test-tool-guide-trigger-coverage.ts index 92bc77a..d53cfa4 100644 --- a/scripts/test/test-tool-guide-trigger-coverage.ts +++ b/scripts/test/test-tool-guide-trigger-coverage.ts @@ -38,6 +38,8 @@ const requiredGuidedTools = [ "align_object_to_surface", "align_object_attachment_points", "validate_object_clearance", + "arrange_objects", + "duplicate_object_pattern", "add_text_object", "add_empty_object", "set_empty_properties",