From 4834ec780fe0ffbc20c8f63d3820420254021386 Mon Sep 17 00:00:00 2001 From: Kristofer Jussmann Date: Mon, 1 Jun 2026 18:44:14 +0300 Subject: [PATCH] Improve studio camera framing validation --- data/tool-guides/lighting-guide.md | 5 +++ desktop/assets/vipermesh-addon.py | 39 ++++++++++++++++++- lib/ai/agents.ts | 12 ++++++ .../prompts/blender-agent-system.md | 2 +- lib/orchestration/tool-registry.ts | 2 +- public/downloads/vipermesh-addon.py | 39 ++++++++++++++++++- .../test-blender-studio-framing-validation.ts | 36 +++++++++++++++++ 7 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 scripts/test/test-blender-studio-framing-validation.ts diff --git a/data/tool-guides/lighting-guide.md b/data/tool-guides/lighting-guide.md index 119f4d0..4933624 100644 --- a/data/tool-guides/lighting-guide.md +++ b/data/tool-guides/lighting-guide.md @@ -20,6 +20,11 @@ Recommended sequence: 2. `validate_studio_scene(target_names)` 3. `render_image(output_path)` +`validate_studio_scene` now checks all eight world-space target bounding-box corners in camera space and reports `camera.frame_bounds`. Treat frame fill warnings as real composition problems: +- "too little" means the model is distant or visually weak; rerun `setup_studio_scene` with a lower `distance_multiplier` or longer focal length. +- "too much" or outside-frame warnings mean the model is cropped; rerun with a higher `distance_multiplier`, wider lens, or larger `frame_margin`. +- For product previews, a fill around 0.35-0.75 usually leaves enough professional breathing room. + Use `add_light` and `set_light_properties` for custom scene lighting after the studio preset is in place or when the user asks for a specific lamp/sun/spot setup. Use `set_world_environment` for world/background changes after the scene rig is in place: diff --git a/desktop/assets/vipermesh-addon.py b/desktop/assets/vipermesh-addon.py index 0991cbc..2574e42 100644 --- a/desktop/assets/vipermesh-addon.py +++ b/desktop/assets/vipermesh-addon.py @@ -6287,6 +6287,16 @@ def _studio_bounds(self, target_names=None): "radius": radius, }, missing + def _studio_box_corners(self, bounds): + min_corner = bounds["min"] + max_corner = bounds["max"] + return [ + mathutils.Vector((x, y, z)) + for x in (min_corner.x, max_corner.x) + for y in (min_corner.y, max_corner.y) + for z in (min_corner.z, max_corner.z) + ] + def _look_at(self, obj, target): direction = mathutils.Vector(target) - obj.location if direction.length > 0: @@ -6568,6 +6578,9 @@ def validate_studio_scene( require_camera=True, require_lights=True, require_render_settings=True, + min_frame_fill=0.18, + max_frame_fill=0.92, + frame_margin=0.06, ): """Validate that the scene has usable target meshes, lighting, camera framing, and render settings.""" try: @@ -6601,12 +6614,34 @@ def validate_studio_scene( from bpy_extras.object_utils import world_to_camera_view projected = [ world_to_camera_view(scene, camera, corner) - for corner in (bounds["min"], bounds["max"], bounds["center"]) + for corner in self._studio_box_corners(bounds) ] if any(point.z < 0 for point in projected): errors.append("Target bounds are behind the camera") - if any(point.x < -0.15 or point.x > 1.15 or point.y < -0.15 or point.y > 1.15 for point in projected): + min_x = min(point.x for point in projected) + max_x = max(point.x for point in projected) + min_y = min(point.y for point in projected) + max_y = max(point.y for point in projected) + frame_width = max_x - min_x + frame_height = max_y - min_y + frame_fill = max(frame_width, frame_height) + frame_bounds = { + "min_x": round(min_x, 6), + "max_x": round(max_x, 6), + "min_y": round(min_y, 6), + "max_y": round(max_y, 6), + "width": round(frame_width, 6), + "height": round(frame_height, 6), + "fill": round(frame_fill, 6), + "margin": round(float(frame_margin), 6), + } + camera_report["frame_bounds"] = frame_bounds + if min_x < float(frame_margin) or max_x > 1.0 - float(frame_margin) or min_y < float(frame_margin) or max_y > 1.0 - float(frame_margin): warnings.append("Target bounds may be partially outside the camera frame") + if frame_fill < float(min_frame_fill): + warnings.append("Target occupies too little of the camera frame") + if frame_fill > float(max_frame_fill): + warnings.append("Target occupies too much of the camera frame") except Exception as exc: warnings.append(f"Could not project target into camera view: {str(exc)}") diff --git a/lib/ai/agents.ts b/lib/ai/agents.ts index 9c90f37..2030cb8 100644 --- a/lib/ai/agents.ts +++ b/lib/ai/agents.ts @@ -2487,12 +2487,18 @@ const validateStudioScene = tool( require_camera, require_lights, require_render_settings, + min_frame_fill, + max_frame_fill, + frame_margin, }: { target_names?: string[] camera_name?: string require_camera?: boolean require_lights?: boolean require_render_settings?: boolean + min_frame_fill?: number + max_frame_fill?: number + frame_margin?: number }) => executeMcpCommand("validate_studio_scene", { target_names, @@ -2500,6 +2506,9 @@ const validateStudioScene = tool( require_camera, require_lights, require_render_settings, + min_frame_fill, + max_frame_fill, + frame_margin, }), { name: "validate_studio_scene", @@ -2511,6 +2520,9 @@ const validateStudioScene = tool( require_camera: z.boolean().optional().describe("Require a camera; defaults true"), require_lights: z.boolean().optional().describe("Require render-visible positive-energy lights; defaults true"), require_render_settings: z.boolean().optional().describe("Check render resolution/settings; defaults true"), + min_frame_fill: z.number().min(0).max(1).optional().describe("Warn when projected target bounds fill less than this normalized frame share"), + max_frame_fill: z.number().min(0).max(2).optional().describe("Warn when projected target bounds fill more than this normalized frame share"), + frame_margin: z.number().min(-1).max(0.5).optional().describe("Required normalized frame margin around projected target bounds"), }), } ) diff --git a/lib/orchestration/prompts/blender-agent-system.md b/lib/orchestration/prompts/blender-agent-system.md index ad88645..5296ea3 100644 --- a/lib/orchestration/prompts/blender-agent-system.md +++ b/lib/orchestration/prompts/blender-agent-system.md @@ -120,7 +120,7 @@ You have access to the following MCP tools. **Use direct tools whenever one matc ### 💡 Lighting Tools - `setup_studio_scene(target_names?, preset?, camera_name?, frame_camera?, focal_length?, distance_multiplier?, resolution_x?, resolution_y?)`: Create/update a studio/product lighting rig, camera, and render defaults around target meshes. - `set_world_environment(mode?, color?, strength?, hdri_path?, rotation_z?, sky_type?, sun_elevation?, sun_rotation?)`: Configure world/background environment lighting. Use for solid background, HDRI rotation/strength, procedural sky, ambient/environment changes. -- `validate_studio_scene(target_names?, camera_name?, require_camera?, require_lights?, require_render_settings?)`: Check camera, framing, lights, and render settings before rendering. +- `validate_studio_scene(target_names?, camera_name?, require_camera?, require_lights?, require_render_settings?, min_frame_fill?, max_frame_fill?, frame_margin?)`: Check camera, projected target frame bounds/fill, lights, and render settings before rendering. - `render_thumbnail_to_path(output_path?, target_names?, preset?, camera_name?, resolution?, samples?, file_format?, frame_camera?, distance_multiplier?, focal_length?)`: Render a lightweight studio thumbnail/preview artifact. Prefer this over `render_image` for validation previews unless the user asked for a final render. - `add_light(light_type?, name?, location?, energy?, color?)`: Add POINT, SUN, SPOT, or AREA light. - `set_light_properties(name, energy?, color?, shadow_soft_size?, spot_size?, spot_blend?, size?)`: Modify existing light. diff --git a/lib/orchestration/tool-registry.ts b/lib/orchestration/tool-registry.ts index 12581c7..e002517 100644 --- a/lib/orchestration/tool-registry.ts +++ b/lib/orchestration/tool-registry.ts @@ -398,7 +398,7 @@ export const TOOL_REGISTRY: ToolMetadata[] = [ "Validate target meshes, active/requested camera, target framing, render-visible lights, and render settings before render_image.", category: "lighting", parameters: - "target_names?: string[], camera_name?: string, require_camera?: boolean, require_lights?: boolean, require_render_settings?: boolean", + "target_names?: string[], camera_name?: string, require_camera?: boolean, require_lights?: boolean, require_render_settings?: boolean, min_frame_fill?: number, max_frame_fill?: number, frame_margin?: number", }, { name: "render_thumbnail_to_path", diff --git a/public/downloads/vipermesh-addon.py b/public/downloads/vipermesh-addon.py index 0991cbc..2574e42 100644 --- a/public/downloads/vipermesh-addon.py +++ b/public/downloads/vipermesh-addon.py @@ -6287,6 +6287,16 @@ def _studio_bounds(self, target_names=None): "radius": radius, }, missing + def _studio_box_corners(self, bounds): + min_corner = bounds["min"] + max_corner = bounds["max"] + return [ + mathutils.Vector((x, y, z)) + for x in (min_corner.x, max_corner.x) + for y in (min_corner.y, max_corner.y) + for z in (min_corner.z, max_corner.z) + ] + def _look_at(self, obj, target): direction = mathutils.Vector(target) - obj.location if direction.length > 0: @@ -6568,6 +6578,9 @@ def validate_studio_scene( require_camera=True, require_lights=True, require_render_settings=True, + min_frame_fill=0.18, + max_frame_fill=0.92, + frame_margin=0.06, ): """Validate that the scene has usable target meshes, lighting, camera framing, and render settings.""" try: @@ -6601,12 +6614,34 @@ def validate_studio_scene( from bpy_extras.object_utils import world_to_camera_view projected = [ world_to_camera_view(scene, camera, corner) - for corner in (bounds["min"], bounds["max"], bounds["center"]) + for corner in self._studio_box_corners(bounds) ] if any(point.z < 0 for point in projected): errors.append("Target bounds are behind the camera") - if any(point.x < -0.15 or point.x > 1.15 or point.y < -0.15 or point.y > 1.15 for point in projected): + min_x = min(point.x for point in projected) + max_x = max(point.x for point in projected) + min_y = min(point.y for point in projected) + max_y = max(point.y for point in projected) + frame_width = max_x - min_x + frame_height = max_y - min_y + frame_fill = max(frame_width, frame_height) + frame_bounds = { + "min_x": round(min_x, 6), + "max_x": round(max_x, 6), + "min_y": round(min_y, 6), + "max_y": round(max_y, 6), + "width": round(frame_width, 6), + "height": round(frame_height, 6), + "fill": round(frame_fill, 6), + "margin": round(float(frame_margin), 6), + } + camera_report["frame_bounds"] = frame_bounds + if min_x < float(frame_margin) or max_x > 1.0 - float(frame_margin) or min_y < float(frame_margin) or max_y > 1.0 - float(frame_margin): warnings.append("Target bounds may be partially outside the camera frame") + if frame_fill < float(min_frame_fill): + warnings.append("Target occupies too little of the camera frame") + if frame_fill > float(max_frame_fill): + warnings.append("Target occupies too much of the camera frame") except Exception as exc: warnings.append(f"Could not project target into camera view: {str(exc)}") diff --git a/scripts/test/test-blender-studio-framing-validation.ts b/scripts/test/test-blender-studio-framing-validation.ts new file mode 100644 index 0000000..bac1549 --- /dev/null +++ b/scripts/test/test-blender-studio-framing-validation.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" + +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", readFileSync("desktop/assets/vipermesh-addon.py", "utf8")], + ["public addon", readFileSync("public/downloads/vipermesh-addon.py", "utf8")], +] as const) { + const validateBody = extractPythonMethod(source, "validate_studio_scene") + assert.match(validateBody, /_studio_box_corners/, `${label} projects all target bounds corners`) + assert.match(validateBody, /frame_bounds/, `${label} reports normalized camera frame bounds`) + assert.match(validateBody, /min_frame_fill/, `${label} warns when target is too small in frame`) + assert.match(validateBody, /max_frame_fill/, `${label} warns when target is too large in frame`) + assert.match(validateBody, /frame_margin/, `${label} supports configurable camera-frame margin`) +} + +const agentSource = readFileSync("lib/ai/agents.ts", "utf8") +assert.match(agentSource, /min_frame_fill/) +assert.match(agentSource, /max_frame_fill/) +assert.match(agentSource, /frame_margin/) + +const registrySource = readFileSync("lib/orchestration/tool-registry.ts", "utf8") +assert.match(registrySource, /min_frame_fill/) +assert.match(registrySource, /max_frame_fill/) + +const lightingGuide = readFileSync("data/tool-guides/lighting-guide.md", "utf8") +assert.match(lightingGuide, /frame fill/i) + +console.log("Blender studio framing validation tests passed")