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
15 changes: 14 additions & 1 deletion src/agent/mcp/toolRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,9 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
'(dist/headless-player) is served from an ephemeral local port automatically; a running studio dev server is used ' +
'as fallback, and { base_url } forces one. The only environment dependency is playwright chromium ' +
'(npx playwright install chromium). Pass { focus } or { hide } (arrays of feature ids or assembly part names, ' +
'mutually exclusive) to isolate parts — same semantics as `kernelcad render --focus/--hide`. PNGs are written to ' +
'mutually exclusive) to isolate parts — same semantics as `kernelcad render --focus/--hide`. Pass ' +
'{ section: { axis, position, flip? } } to cut a cross-section and inspect INTERIOR geometry (wall thickness, ' +
'internal pockets, whether a bore runs through) rather than only the outer shell. PNGs are written to ' +
'{ out_dir } (default: a fresh temp session directory) and returned as absolute paths with per-view camera ' +
'descriptions (kernelCAD is Z-up). Mechanism truth runs first, same protocol as `kernelcad render`: a broken ' +
'mechanism still renders but every tile is watermarked MECHANISM BROKEN (KERNELCAD_RENDER_STRICT=1 refuses ' +
Expand All @@ -1525,6 +1527,17 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [
no_watermark: { type: 'boolean', description: 'Suppress the kernelCAD version watermark.', default: false },
no_mechanism_check: { type: 'boolean', description: "Skip the mechanism-truth probe for fast iteration on large assemblies; the preview reports mechanism: 'unverified'. Ignored under KERNELCAD_RENDER_STRICT=1.", default: false },
base_url: { type: 'string', description: 'Advanced: force a specific render server (e.g. a running studio dev server) instead of the bundled static player.' },
section: {
type: 'object',
description: "Cut the model with one axis-aligned section plane to inspect INTERIOR structure (wall thickness, internal pockets, whether a bore runs through) instead of only the outer shell. position is in mm along the axis (kernelCAD Z-up frame); flip keeps the +axis side (default keeps the -axis side).",
properties: {
axis: { type: 'string', enum: ['x', 'y', 'z'] },
position: { type: 'number' },
flip: { type: 'boolean', default: false },
},
required: ['axis', 'position'],
additionalProperties: false,
},
},
},
},
Expand Down
75 changes: 75 additions & 0 deletions src/agent/mcp/tools/renderPreview.section.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
//
// Section-plane forwarding for the render_preview MCP tool. The headless
// renderer already supports a single axis-aligned clip plane (see
// headlessRender's `section` opt); these tests pin that render_preview exposes
// it to the agent and forwards a validated `{axis, position, positionRaw, flip}`
// down to deps.render — so the agent can cut a cross-section to inspect interior
// geometry, not just the outer shell.

import { describe, it, expect } from 'vitest';
import { renderPreviewTool, type RenderPreviewDeps } from './renderPreview';
import type { HeadlessRenderOpts, HeadlessRenderResult } from '../../render/headlessRender';

/** Fake deps that never touch chromium/disk-server: mechanism probe reports
* 'unverified', the base resolves to a stub, and render captures the opts it
* was handed and returns one tiny iso tile. */
function makeDeps(): { deps: RenderPreviewDeps; captured: () => HeadlessRenderOpts | undefined } {
let seen: HeadlessRenderOpts | undefined;
const deps: RenderPreviewDeps = {
render: async (opts: HeadlessRenderOpts): Promise<HeadlessRenderResult> => {
seen = opts;
return {
pngsByView: { iso: Buffer.from('png') },
pngsByPose: {},
bounds: { min: [0, 0, 0], max: [1, 1, 1] },
} as unknown as HeadlessRenderResult;
},
resolveBaseUrl: async () =>
({ baseUrl: 'http://stub', source: 'static-player', close: async () => undefined }) as never,
mechanismProbe: async () => ({ mechanism: 'unverified' as const, failures: [] }),
};
return { deps, captured: () => seen };
}

const CUBE = 'export default () => lib.box(10, 10, 10);';

describe('render_preview section plane', () => {
it('forwards a validated section plane to the renderer with verbatim positionRaw', async () => {
const { deps, captured } = makeDeps();
const out = await renderPreviewTool(
{ code: CUBE, views: ['iso'], section: { axis: 'z', position: 10 } },
deps,
);
expect(out.ok).toBe(true);
expect(captured()?.section).toEqual({ axis: 'z', position: 10, positionRaw: '10', flip: false });
});

it('preserves a negative/decimal position verbatim and honors flip', async () => {
const { deps, captured } = makeDeps();
await renderPreviewTool(
{ code: CUBE, views: ['iso'], section: { axis: 'x', position: -2.5, flip: true } },
deps,
);
expect(captured()?.section).toEqual({ axis: 'x', position: -2.5, positionRaw: '-2.5', flip: true });
});

it('omits section from the render opts when not requested', async () => {
const { deps, captured } = makeDeps();
await renderPreviewTool({ code: CUBE, views: ['iso'] }, deps);
expect(captured()?.section).toBeUndefined();
});

it('refuses an invalid section value instead of rendering silently unclipped', async () => {
const { deps, captured } = makeDeps();
const out = await renderPreviewTool(
// Non-finite position cannot be expressed as a clip plane.
{ code: CUBE, views: ['iso'], section: { axis: 'z', position: Number.NaN } },
deps,
);
expect(out.ok).toBe(false);
expect(out.error).toMatch(/section/i);
expect(captured()).toBeUndefined();
});
});
30 changes: 28 additions & 2 deletions src/agent/mcp/tools/renderPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { resolveRenderBaseUrl, type ResolvedRenderBase } from '../../render/play
import {
buildObjectFilter,
isRenderStrictMode,
parseSectionFlag,
runRenderMechanismProbe,
watermarkBrokenMechanism,
} from '../../cli/commands/render';
Expand Down Expand Up @@ -88,6 +89,12 @@ export interface RenderPreviewInput {
/** Advanced: force a specific render server base URL (e.g. a running
* studio dev server) instead of the bundled static player. */
base_url?: string;
/** Cut the model with a single axis-aligned section plane so the captures
* show interior structure (wall thickness, pockets, whether a bore runs
* through) instead of only the outer shell. `position` is in mm along the
* axis in kernelCAD's Z-up frame; `flip` keeps the positive-axis side
* (default keeps the negative-axis side). */
section?: { axis: 'x' | 'y' | 'z'; position: number; flip?: boolean };
}

export interface RenderPreviewImage {
Expand Down Expand Up @@ -251,6 +258,23 @@ export async function renderPreviewTool(
);
}

// Section plane: reuse the CLI's parseSectionFlag so positionRaw carries the
// digits verbatim (stringifying the Number would emit exponent notation the
// page-side `?section=` regex silently rejects → an unclipped render).
let section: { axis: 'x' | 'y' | 'z'; position: number; positionRaw: string; flip: boolean } | undefined;
if (input.section !== undefined) {
try {
const parsed = parseSectionFlag(`${input.section.axis}=${input.section.position}`);
section = { ...parsed, flip: input.section.flip ?? false };
} catch {
return refusal(
'cli.invalid-args',
`render_preview: invalid section ${JSON.stringify(input.section)} — axis must be 'x', 'y', or 'z' and position a finite decimal.`,
"Pass section as { axis: 'x'|'y'|'z', position: <number>, flip?: boolean }, e.g. { axis: 'z', position: 10 }.",
);
}
}

// --- Session dir + code-mode temp script. ---
let outDir: string;
let scriptPath: string;
Expand All @@ -275,7 +299,7 @@ export async function renderPreviewTool(
);
}

const work = renderPreviewWork({ input, deps, scriptPath, outDir, views, pose, objectFilter, width, height });
const work = renderPreviewWork({ input, deps, scriptPath, outDir, views, pose, objectFilter, width, height, section });
// Swallow the losing chain's rejection if the timeout wins (same pattern as
// capture_animation) so it never surfaces as an unhandled rejection.
work.catch(() => undefined);
Expand Down Expand Up @@ -312,8 +336,9 @@ async function renderPreviewWork(args: {
objectFilter: ReturnType<typeof buildObjectFilter>;
width: number;
height: number;
section?: { axis: 'x' | 'y' | 'z'; position: number; positionRaw: string; flip: boolean };
}): Promise<RenderPreviewOutput> {
const { input, deps, scriptPath, outDir, views, pose, objectFilter, width, height } = args;
const { input, deps, scriptPath, outDir, views, pose, objectFilter, width, height, section } = args;
const t0 = Date.now();

// Physics-loop probe — identical protocol to the render CLI: strict mode
Expand Down Expand Up @@ -365,6 +390,7 @@ async function renderPreviewWork(args: {
...(input.environment !== undefined ? { environment: input.environment } : {}),
...(input.no_watermark === true ? { noWatermark: true } : {}),
...(objectFilter !== undefined ? { objectFilter } : {}),
...(section !== undefined ? { section } : {}),
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Expand Down
Loading