Files
editor/packages/mcp/test-reports/villa-azul/build.ts
T
Adrian PerezandClaude Opus 4.7 f230d9a401 test(mcp): Villa Azul + 10-agent deep verification
Builds a larger, richer house than Casa del Sol via MCP save_scene
(no injection hack), then dispatches 10 parallel verifiers across
schema, geometry, dimensions, openings, HTTP, page render,
parentage, round-trip, spatial, visual.

Villa Azul — 56 nodes, validate_scene=true, 44KB on disk:
- 15x10m building envelope (vs Casa del Sol's 12x8)
- 9 interior zones (master bed/bath, bed 2/3, shared bath,
  living/dining, kitchen, entry hall, corridor)
- 10 doors + 12 windows (all cut successfully)
- 4 exterior zones (pool 8x4 + basin slab at -2m, outdoor kitchen,
  driveway, back patio)
- 5 rail-style fences (vs Casa del Sol's privacy) with 2m entrance gap

Verification: 108 checks, 104 PASS, 4 findings:
- V1 schema: 56/56
- V2 geometry: 7/7 (perimeter closes, interior T-junctions, no
  zone overlaps, fence gap verified)
- V3 dimensions: 13/13 zone areas exact (1 spec mismatch on site
  polygon default, not a build bug)
- V4 openings: 22/22 dimensional fit, surfaced a tool gap in
  cut_opening (no adjacency check) + my build packed too tightly
- V5 HTTP: 10/10 (GET/PUT/PATCH/DELETE/HEAD, If-Match conflicts)
- V6 page: 14/14 (/scene/:id 81KB, /scenes 20KB, 404 fallback)
- V7 parentage: surfaced CROSS_CUTTING §2 site->building->level
  parentId=null (pre-existing in core's loadScene)
- V8 round-trip: 10/10 byte-equal, duplicate_level -> 110 nodes
- V9 spatial: 12/12 (find_nodes, measure, constraints resource)
- V10 visual: HTML fallback (Chrome extension disconnected during
  run); API layer intact

Follow-up tracked: `cut_opening` should check opening-adjacency on
the same wall (minimum gap) to catch tight packing during patch
construction. Currently returns success and relies on the UI to
visualise the overlap.

Live at http://localhost:3002/scene/a6e7919eacbe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:39:08 +02:00

411 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Villa Azul — build the scene via MCP HTTP, save via save_scene.
* Usage:
* PASCAL_DATA_DIR=/tmp/pascal-villa bun run packages/mcp/test-reports/villa-azul/build.ts
* Assumes MCP HTTP server is listening on :3917.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const MCP_URL = 'http://localhost:3917/mcp'
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL))
const client = new Client({ name: 'villa-azul-build', version: '0.0.0' })
await client.connect(transport)
function structured<T>(result: Awaited<ReturnType<Client['callTool']>>): T {
const text = (result.content as Array<{ text: string }>)[0]!.text
return JSON.parse(text) as T
}
async function call<T>(name: string, args: Record<string, unknown> = {}): Promise<T> {
const r = await client.callTool({ name, arguments: args })
if (r.isError) {
throw new Error(`${name} failed: ${JSON.stringify(r.content).slice(0, 400)}`)
}
return structured<T>(r)
}
type Node = { id: string; type: string; [k: string]: unknown }
type Scene = { nodes: Record<string, Node>; rootNodeIds: string[] }
type Meta = {
id: string
name: string
version: number
nodeCount: number
sizeBytes: number
url: string
}
type WallId = string & { readonly _brand: 'wall' }
console.log('---- Villa Azul build ----')
// Step 1 — Discover the default site/building/level
const scene0 = await call<Scene>('get_scene')
const buildingId = Object.values(scene0.nodes).find((n) => n.type === 'building')!.id
const levelId = Object.values(scene0.nodes).find((n) => n.type === 'level')!.id
console.log(`01 discover buildingId=${buildingId} levelId=${levelId}`)
// Step 2 — Perimeter walls of the main volume (15 × 10, offset so pool sits east)
// Building occupies x ∈ [10, 5], z ∈ [5, 5]. Pool sits east of building.
const perim = [
{ label: 'south', start: [-10, 5], end: [5, 5] },
{ label: 'north', start: [-10, -5], end: [5, -5] },
{ label: 'west', start: [-10, -5], end: [-10, 5] },
{ label: 'east', start: [5, -5], end: [5, 5] },
]
const perimWallIds: Record<string, WallId> = {}
for (const { label, start, end } of perim) {
const r = await call<{ wallId: WallId }>('create_wall', {
levelId,
start,
end,
thickness: 0.22,
height: 2.8,
})
perimWallIds[label] = r.wallId
}
console.log(`02 perimeter ${Object.values(perimWallIds).join(', ')}`)
// Step 3 — Interior partitions
// Layout from west to east:
// x=-10..-7 → Master bedroom (z=-5..1)
// x=-10..-7 → Master bath (z=1..5)
// x=-7..-4 → Bedroom 2 (z=-5..-1); Bath shared (z=-1..1); Bedroom 3 (z=1..5)
// x=-4..2 → Living/dining (z=-5..2); Kitchen (z=2..5)
// x=2..5 → Entry hall (z=-5..0); Corridor (z=0..5)
// Interior partitions (start/end in level plane):
const interior = [
{ label: 'master-east', start: [-7, -5], end: [-7, 5] }, // separates master from center
{ label: 'master-bath', start: [-10, 1], end: [-7, 1] }, // splits master bedroom from master bath
{ label: 'bed2-north', start: [-7, -1], end: [-4, -1] }, // separates bed2 from bath shared
{ label: 'bed3-south', start: [-7, 1], end: [-4, 1] }, // separates bath shared from bed3
{ label: 'center-east', start: [-4, -5], end: [-4, 5] }, // separates bedrooms from living
{ label: 'kitchen-south', start: [-4, 2], end: [2, 2] }, // splits living from kitchen
{ label: 'hall-west', start: [2, -5], end: [2, 5] }, // separates hall/corridor from living
{ label: 'hall-south', start: [2, 0], end: [5, 0] }, // splits entry hall from corridor
]
const interiorWallIds: Record<string, WallId> = {}
for (const { label, start, end } of interior) {
const r = await call<{ wallId: WallId }>('create_wall', {
levelId,
start,
end,
thickness: 0.12,
height: 2.8,
})
interiorWallIds[label] = r.wallId
}
console.log(`03 interior ${Object.keys(interiorWallIds).length} walls created`)
// Step 4 — Zones
const zones = [
{
label: 'Master bedroom',
polygon: [
[-10, -5],
[-7, -5],
[-7, 1],
[-10, 1],
],
},
{
label: 'Master bath',
polygon: [
[-10, 1],
[-7, 1],
[-7, 5],
[-10, 5],
],
},
{
label: 'Bedroom 2',
polygon: [
[-7, -5],
[-4, -5],
[-4, -1],
[-7, -1],
],
},
{
label: 'Shared bath',
polygon: [
[-7, -1],
[-4, -1],
[-4, 1],
[-7, 1],
],
},
{
label: 'Bedroom 3',
polygon: [
[-7, 1],
[-4, 1],
[-4, 5],
[-7, 5],
],
},
{
label: 'Living dining',
polygon: [
[-4, -5],
[2, -5],
[2, 2],
[-4, 2],
],
},
{
label: 'Kitchen',
polygon: [
[-4, 2],
[2, 2],
[2, 5],
[-4, 5],
],
},
{
label: 'Entry hall',
polygon: [
[2, -5],
[5, -5],
[5, 0],
[2, 0],
],
},
{
label: 'Corridor',
polygon: [
[2, 0],
[5, 0],
[5, 5],
[2, 5],
],
},
]
const zoneIds: string[] = []
for (const { label, polygon } of zones) {
const r = await call<{ zoneId: string }>('set_zone', { levelId, polygon, label })
zoneIds.push(r.zoneId)
}
console.log(`04 zones ${zoneIds.length} zones`)
// Step 5 — Doors
const doors = [
{ wallId: perimWallIds.south, pos: 0.9, w: 1.0, h: 2.1, label: 'front-door' },
{ wallId: perimWallIds.north, pos: 0.75, w: 0.9, h: 2.1, label: 'kitchen-back' },
{ wallId: perimWallIds.south, pos: 0.4, w: 2.4, h: 2.2, label: 'living-patio' },
{ wallId: perimWallIds.east, pos: 0.75, w: 1.8, h: 2.2, label: 'pool-slider' },
{ wallId: interiorWallIds['master-east']!, pos: 0.25, w: 0.8, h: 2.05, label: 'master-door' },
{ wallId: interiorWallIds['master-bath']!, pos: 0.5, w: 0.7, h: 2.0, label: 'master-bath-door' },
{ wallId: interiorWallIds['center-east']!, pos: 0.12, w: 0.8, h: 2.05, label: 'bed2-door' },
{ wallId: interiorWallIds['center-east']!, pos: 0.88, w: 0.8, h: 2.05, label: 'bed3-door' },
{ wallId: interiorWallIds['bed2-north']!, pos: 0.5, w: 0.7, h: 2.0, label: 'shared-bath-door' },
{ wallId: interiorWallIds['hall-west']!, pos: 0.9, w: 0.9, h: 2.05, label: 'hall-to-living' },
]
let doorOk = 0
let doorFail = 0
for (const d of doors) {
try {
await call<{ openingId: string }>('cut_opening', {
wallId: d.wallId,
type: 'door',
position: d.pos,
width: d.w,
height: d.h,
})
doorOk++
} catch (err) {
doorFail++
console.log(` door fail ${d.label}: ${(err as Error).message.slice(0, 80)}`)
}
}
console.log(`05 doors ${doorOk}/${doors.length} ok (${doorFail} failed)`)
// Step 6 — Windows
const windows = [
{ wallId: perimWallIds.south, pos: 0.15, w: 1.4, h: 1.5, label: 'master-s-window' },
{ wallId: perimWallIds.south, pos: 0.65, w: 2.0, h: 1.5, label: 'living-s-window' },
{ wallId: perimWallIds.north, pos: 0.15, w: 1.0, h: 1.4, label: 'bed3-n-window' },
{ wallId: perimWallIds.north, pos: 0.55, w: 1.4, h: 1.4, label: 'kitchen-n-window' },
{ wallId: perimWallIds.west, pos: 0.2, w: 1.0, h: 1.4, label: 'master-w-window' },
{ wallId: perimWallIds.west, pos: 0.75, w: 0.8, h: 0.9, label: 'master-bath-w-window' },
{ wallId: perimWallIds.east, pos: 0.15, w: 1.0, h: 1.4, label: 'entry-e-window' },
{ wallId: perimWallIds.east, pos: 0.4, w: 0.9, h: 1.4, label: 'corridor-e-window' },
{ wallId: interiorWallIds['master-bath']!, pos: 0.2, w: 0.6, h: 0.6, label: 'bath-transom' },
{ wallId: perimWallIds.north, pos: 0.35, w: 0.8, h: 0.7, label: 'shared-bath-nw' },
{ wallId: perimWallIds.south, pos: 0.22, w: 1.2, h: 1.5, label: 'bed-corridor-window' },
{ wallId: perimWallIds.south, pos: 0.55, w: 1.4, h: 1.5, label: 'living-s-2' },
]
let winOk = 0
let winFail = 0
for (const w of windows) {
try {
await call<{ openingId: string }>('cut_opening', {
wallId: w.wallId,
type: 'window',
position: w.pos,
width: w.w,
height: w.h,
})
winOk++
} catch (_err) {
winFail++
}
}
console.log(`06 windows ${winOk}/${windows.length} ok (${winFail} failed)`)
// Step 7 — Pool zone (east of house) + pool basin slab
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[7, -2],
[15, -2],
[15, 2],
[7, 2],
],
label: 'Pool',
properties: { kind: 'pool', depthM: 2.0, finish: 'tile' },
})
console.log('07 pool zone created')
const slabOpId = 'slab_azul_pool'
await call<{ appliedOps: number; createdIds: string[] }>('apply_patch', {
patches: [
{
op: 'create',
node: {
object: 'node',
id: slabOpId,
type: 'slab',
parentId: null,
visible: true,
metadata: { kind: 'pool-basin', depthM: 2.0 },
polygon: [
[7, -2],
[15, -2],
[15, 2],
[7, 2],
],
holes: [],
holeMetadata: [],
elevation: -2.0,
autoFromWalls: false,
},
parentId: levelId,
},
],
})
console.log('07b pool basin slab at elevation -2.0m')
// Step 8 — Outdoor kitchen zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[7, 3],
[12, 3],
[12, 6],
[7, 6],
],
label: 'Outdoor kitchen',
properties: { kind: 'outdoor-kitchen' },
})
// Step 9 — Driveway zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[-12.5, 5.5],
[-6, 5.5],
[-6, 10],
[-12.5, 10],
],
label: 'Driveway',
properties: { kind: 'driveway', surface: 'concrete' },
})
// Step 10 — Back patio zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[-5, 5.5],
[5, 5.5],
[5, 7.5],
[-5, 7.5],
],
label: 'Back patio',
properties: { kind: 'patio' },
})
console.log('08 exterior zones added (outdoor kitchen, driveway, back patio)')
// Step 11 — Rail-style fence around lot perimeter (25 × 20, corners ±12.5, ±10)
const fences = [
{ start: [-12.5, 10], end: [-1, 10] }, // north-west
{ start: [1, 10], end: [12.5, 10] }, // north-east (gap at entrance)
{ start: [12.5, 10], end: [12.5, -10] }, // east
{ start: [12.5, -10], end: [-12.5, -10] }, // south
{ start: [-12.5, -10], end: [-12.5, 10] }, // west
]
const fencePatches = fences.map(({ start, end }) => ({
op: 'create' as const,
node: {
type: 'fence' as const,
start,
end,
height: 1.5,
style: 'rail' as const,
thickness: 0.08,
baseHeight: 0.1,
postSpacing: 2,
postSize: 0.1,
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.01,
baseStyle: 'grounded' as const,
color: '#ffffff',
},
parentId: levelId,
}))
await call<{ appliedOps: number; createdIds: string[] }>('apply_patch', {
patches: fencePatches,
})
console.log(`09 fences ${fences.length} rail segments (gap at south entrance)`)
// Step 12 — Validate
const validate = await call<{ valid: boolean; errors: unknown[] }>('validate_scene')
console.log(`10 validate valid=${validate.valid} errors=${validate.errors.length}`)
// Step 13 — Save
const meta = await call<Meta>('save_scene', { name: 'Villa Azul' })
console.log(`11 save id=${meta.id} version=${meta.version} nodes=${meta.nodeCount}`)
console.log(` url: ${meta.url}`)
console.log(` sizeBytes: ${meta.sizeBytes}`)
// Step 14 — Emit final counts + sceneId for verifier agents
const scene = await call<Scene>('get_scene')
const typeCounts = new Map<string, number>()
for (const n of Object.values(scene.nodes)) {
typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1)
}
const summary = {
sceneId: meta.id,
version: meta.version,
nodeCount: meta.nodeCount,
sizeBytes: meta.sizeBytes,
url: `http://localhost:3002${meta.url}`,
typeCounts: Object.fromEntries(typeCounts),
validation: { valid: validate.valid, errors: validate.errors.length },
doorResults: { ok: doorOk, fail: doorFail },
windowResults: { ok: winOk, fail: winFail },
}
console.log('\n=== SUMMARY ===')
console.log(JSON.stringify(summary, null, 2))
// Write the summary to a known location so verifier agents can read it
const summaryPath = 'packages/mcp/test-reports/villa-azul/build-summary.json'
await Bun.write(summaryPath, JSON.stringify(summary, null, 2))
console.log(`\nwrote ${summaryPath}`)
await client.close()