Files
editor/packages/mcp/test-reports/villa-azul/v3-dimensions.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

247 lines
7.2 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.
/**
* Phase 9 Verifier V3 — Dimensional Accuracy
*
* Computes areas from polygons via the shoelace formula and compares against
* the Villa Azul design spec. Also verifies total interior footprint, pool
* area, site polygon area, and the centroid distance from Pool to Master
* bedroom.
*
* Run with: npx tsx packages/mcp/test-reports/villa-azul/v3-dimensions.ts
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
type Point = [number, number]
const SCENE_PATH = '/tmp/pascal-villa/scenes/a6e7919eacbe.json'
const REPORT_PATH = path.resolve(
'/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/villa-azul/v3-dimensions.md',
)
const TOLERANCE_PCT = 1.0
interface Expected {
name: string
expected: number
}
const expectedZones: Expected[] = [
{ name: 'Master bedroom', expected: 18 },
{ name: 'Master bath', expected: 12 },
{ name: 'Bedroom 2', expected: 12 },
{ name: 'Shared bath', expected: 6 },
{ name: 'Bedroom 3', expected: 12 },
{ name: 'Living dining', expected: 42 },
{ name: 'Kitchen', expected: 18 },
{ name: 'Entry hall', expected: 15 },
{ name: 'Corridor', expected: 15 },
{ name: 'Pool', expected: 32 },
{ name: 'Outdoor kitchen', expected: 15 },
{ name: 'Driveway', expected: 29.25 },
{ name: 'Back patio', expected: 20 },
]
const INTERIOR_ZONE_NAMES = new Set([
'Master bedroom',
'Master bath',
'Bedroom 2',
'Shared bath',
'Bedroom 3',
'Living dining',
'Kitchen',
'Entry hall',
'Corridor',
])
function shoelaceArea(points: Point[]): number {
let sum = 0
const n = points.length
for (let i = 0; i < n; i++) {
const [x1, y1] = points[i]
const [x2, y2] = points[(i + 1) % n]
sum += x1 * y2 - x2 * y1
}
return Math.abs(sum) / 2
}
function centroid(points: Point[]): Point {
let cx = 0
let cy = 0
let a = 0
const n = points.length
for (let i = 0; i < n; i++) {
const [x1, y1] = points[i]
const [x2, y2] = points[(i + 1) % n]
const cross = x1 * y2 - x2 * y1
a += cross
cx += (x1 + x2) * cross
cy += (y1 + y2) * cross
}
a /= 2
cx /= 6 * a
cy /= 6 * a
return [cx, cy]
}
function distance(a: Point, b: Point): number {
const dx = b[0] - a[0]
const dy = b[1] - a[1]
return Math.sqrt(dx * dx + dy * dy)
}
function pctError(expected: number, actual: number): number {
if (expected === 0) return actual === 0 ? 0 : Infinity
return (Math.abs(actual - expected) / expected) * 100
}
function fmt(n: number, d = 3): string {
return n.toFixed(d)
}
interface ZoneRecord {
name: string
expected: number
actual: number
absErr: number
pctErr: number
pass: boolean
centroid: Point
}
function main() {
const raw = fs.readFileSync(SCENE_PATH, 'utf8')
const scene = JSON.parse(raw)
const nodes = scene.graph.nodes as Record<string, any>
// Collect zones by name
const zonesByName: Record<string, any> = {}
for (const id in nodes) {
const n = nodes[id]
if (n.type === 'zone' && Array.isArray(n.polygon)) {
zonesByName[n.name] = n
}
}
const records: ZoneRecord[] = []
const missing: string[] = []
for (const e of expectedZones) {
const zone = zonesByName[e.name]
if (!zone) {
missing.push(e.name)
continue
}
const poly = zone.polygon as Point[]
const actual = shoelaceArea(poly)
const c = centroid(poly)
const absErr = Math.abs(actual - e.expected)
const pctErr = pctError(e.expected, actual)
records.push({
name: e.name,
expected: e.expected,
actual,
absErr,
pctErr,
pass: pctErr < TOLERANCE_PCT,
centroid: c,
})
}
const interior = records.filter((r) => INTERIOR_ZONE_NAMES.has(r.name))
const interiorSum = interior.reduce((s, r) => s + r.actual, 0)
const interiorTargetSum = 150
const interiorPct = pctError(interiorTargetSum, interiorSum)
const interiorPass = interiorPct < TOLERANCE_PCT
const pool = records.find((r) => r.name === 'Pool')!
const poolExact = Math.abs(pool.actual - 32) < 1e-9
// Site polygon
const siteNode = Object.values(nodes).find((n: any) => n.type === 'site') as any
const sitePts: Point[] = siteNode?.polygon?.points ?? []
const siteArea = shoelaceArea(sitePts)
const siteExpected = 500
const sitePctErr = pctError(siteExpected, siteArea)
const sitePass = sitePctErr < TOLERANCE_PCT
// Centroid distance: Pool vs Master bedroom
const master = records.find((r) => r.name === 'Master bedroom')!
const poolMasterDist = distance(pool.centroid, master.centroid)
const distPass = poolMasterDist >= 17 && poolMasterDist <= 20
// Build report
const now = new Date().toISOString()
const lines: string[] = []
lines.push('# Villa Azul — V3 Dimensional Accuracy Report\n')
lines.push(`- Scene file: \`${SCENE_PATH}\``)
lines.push(`- Generated: ${now}`)
lines.push(`- Tolerance: < ${TOLERANCE_PCT}% per-zone area error\n`)
lines.push('## Per-zone areas\n')
lines.push('| Zone | Expected (m²) | Actual (m²) | Abs err (m²) | % err | Pass |')
lines.push('|---|---:|---:|---:|---:|:---:|')
for (const r of records) {
lines.push(
`| ${r.name} | ${fmt(r.expected, 2)} | ${fmt(r.actual, 3)} | ${fmt(
r.absErr,
3,
)} | ${fmt(r.pctErr, 3)}% | ${r.pass ? 'PASS' : 'FAIL'} |`,
)
}
for (const m of missing) {
lines.push(`| ${m} | — | MISSING | — | — | FAIL |`)
}
lines.push('\n## Aggregate checks\n')
lines.push('| Check | Expected | Actual | % err | Pass |')
lines.push('|---|---:|---:|---:|:---:|')
lines.push(
`| Interior sum (first 9 zones) | ${fmt(interiorTargetSum, 2)} m² | ${fmt(
interiorSum,
3,
)} m² | ${fmt(interiorPct, 3)}% | ${interiorPass ? 'PASS' : 'FAIL'} |`,
)
lines.push(
`| Pool exactly 32 m² (8×4) | 32 m² | ${fmt(pool.actual, 3)} m² | ${fmt(
pctError(32, pool.actual),
3,
)}% | ${poolExact ? 'PASS' : 'FAIL'} |`,
)
lines.push(
`| Site polygon area | 500 m² | ${fmt(siteArea, 3)} m² | ${fmt(sitePctErr, 3)}% | ${
sitePass ? 'PASS' : 'FAIL'
} |`,
)
lines.push(
`| Pool↔Master bedroom centroid dist | 1720 m | ${fmt(
poolMasterDist,
3,
)} m | — | ${distPass ? 'PASS' : 'FAIL'} |`,
)
lines.push('\n## Centroids (reference)\n')
lines.push('| Zone | cx | cy |')
lines.push('|---|---:|---:|')
for (const r of records) {
lines.push(`| ${r.name} | ${fmt(r.centroid[0], 3)} | ${fmt(r.centroid[1], 3)} |`)
}
lines.push('\n## Summary\n')
const allZonesPass = records.every((r) => r.pass) && missing.length === 0
const allPass = allZonesPass && interiorPass && poolExact && sitePass && distPass
lines.push(`- Zones pass (<${TOLERANCE_PCT}%): ${allZonesPass ? 'YES' : 'NO'}`)
lines.push(`- Interior sum pass: ${interiorPass ? 'YES' : 'NO'}`)
lines.push(`- Pool exact pass: ${poolExact ? 'YES' : 'NO'}`)
lines.push(`- Site polygon pass: ${sitePass ? 'YES' : 'NO'}`)
lines.push(`- Pool↔Master distance pass: ${distPass ? 'YES' : 'NO'}`)
lines.push(`- Overall: ${allPass ? 'PASS' : 'FAIL'}`)
fs.writeFileSync(REPORT_PATH, lines.join('\n') + '\n', 'utf8')
// eslint-disable-next-line no-console
console.log(`Wrote ${REPORT_PATH}`)
// eslint-disable-next-line no-console
console.log(`Overall: ${allPass ? 'PASS' : 'FAIL'}`)
}
main()