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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0b84e7b7b1
commit
f230d9a401
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Phase 9 Verifier V2 - Geometric sanity checks for Villa Azul.
|
||||
*
|
||||
* Pure Node (no deps). Reads the scene JSON and runs checks 1-7 defined in
|
||||
* the task spec, then emits a markdown report.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
type Vec2 = [number, number]
|
||||
type Polygon = Vec2[]
|
||||
|
||||
interface WallLike {
|
||||
id: string
|
||||
start: Vec2
|
||||
end: Vec2
|
||||
thickness: number
|
||||
}
|
||||
|
||||
interface ZoneLike {
|
||||
id: string
|
||||
name: string
|
||||
polygon: Polygon
|
||||
kind?: string
|
||||
}
|
||||
|
||||
interface FenceLike {
|
||||
id: string
|
||||
start: Vec2
|
||||
end: Vec2
|
||||
}
|
||||
|
||||
interface SlabLike {
|
||||
id: string
|
||||
polygon: Polygon
|
||||
kind?: string
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
name: string
|
||||
passed: boolean
|
||||
details: string[]
|
||||
anomalies: string[]
|
||||
}
|
||||
|
||||
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/v2-geometry.md',
|
||||
)
|
||||
|
||||
function polygonArea(poly: Polygon): number {
|
||||
let a = 0
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const [x1, y1] = poly[i]
|
||||
const [x2, y2] = poly[(i + 1) % poly.length]
|
||||
a += x1 * y2 - x2 * y1
|
||||
}
|
||||
return Math.abs(a) / 2
|
||||
}
|
||||
|
||||
function polygonBounds(poly: Polygon) {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
for (const [x, y] of poly) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
return { minX, minY, maxX, maxY }
|
||||
}
|
||||
|
||||
function boundsOverlap(
|
||||
a: ReturnType<typeof polygonBounds>,
|
||||
b: ReturnType<typeof polygonBounds>,
|
||||
): { overlap: boolean; area: number } {
|
||||
const ix = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)
|
||||
const iy = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)
|
||||
if (ix <= 0 || iy <= 0) return { overlap: false, area: 0 }
|
||||
return { overlap: true, area: ix * iy }
|
||||
}
|
||||
|
||||
function polygonsEqual(a: Polygon, b: Polygon, tol = 1e-6): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
// allow rotation of indexing / reverse direction
|
||||
const tryMatch = (rev: boolean) => {
|
||||
for (let off = 0; off < a.length; off++) {
|
||||
let ok = true
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const [ax, ay] = a[i]
|
||||
const j = rev ? (off - i + a.length) % a.length : (off + i) % a.length
|
||||
const [bx, by] = b[j]
|
||||
if (Math.abs(ax - bx) > tol || Math.abs(ay - by) > tol) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return tryMatch(false) || tryMatch(true)
|
||||
}
|
||||
|
||||
function pointsEqual(a: Vec2, b: Vec2, tol = 1e-3): boolean {
|
||||
return Math.abs(a[0] - b[0]) < tol && Math.abs(a[1] - b[1]) < tol
|
||||
}
|
||||
|
||||
function pointOnSegment(p: Vec2, a: Vec2, b: Vec2, tol = 1e-3): boolean {
|
||||
const ax = a[0],
|
||||
ay = a[1],
|
||||
bx = b[0],
|
||||
by = b[1],
|
||||
px = p[0],
|
||||
py = p[1]
|
||||
const dx = bx - ax
|
||||
const dy = by - ay
|
||||
const len2 = dx * dx + dy * dy
|
||||
if (len2 < 1e-12) return pointsEqual(p, a, tol)
|
||||
const t = ((px - ax) * dx + (py - ay) * dy) / len2
|
||||
if (t < -tol || t > 1 + tol) return false
|
||||
const qx = ax + t * dx
|
||||
const qy = ay + t * dy
|
||||
const d2 = (qx - px) * (qx - px) + (qy - py) * (qy - py)
|
||||
return d2 < tol * tol
|
||||
}
|
||||
|
||||
function parseScene() {
|
||||
const raw = fs.readFileSync(SCENE_PATH, 'utf8')
|
||||
const scene = JSON.parse(raw)
|
||||
const nodes = scene.graph.nodes as Record<string, any>
|
||||
|
||||
let site: any = null
|
||||
const walls: WallLike[] = []
|
||||
const zones: ZoneLike[] = []
|
||||
const fences: FenceLike[] = []
|
||||
const slabs: SlabLike[] = []
|
||||
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
switch (n.type) {
|
||||
case 'site':
|
||||
site = n
|
||||
break
|
||||
case 'wall':
|
||||
walls.push({
|
||||
id,
|
||||
start: n.start,
|
||||
end: n.end,
|
||||
thickness: n.thickness,
|
||||
})
|
||||
break
|
||||
case 'zone':
|
||||
zones.push({
|
||||
id,
|
||||
name: n.name ?? id,
|
||||
polygon: n.polygon,
|
||||
kind: n.metadata?.kind,
|
||||
})
|
||||
break
|
||||
case 'fence':
|
||||
fences.push({ id, start: n.start, end: n.end })
|
||||
break
|
||||
case 'slab':
|
||||
slabs.push({
|
||||
id,
|
||||
polygon: n.polygon,
|
||||
kind: n.metadata?.kind,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { site, walls, zones, fences, slabs }
|
||||
}
|
||||
|
||||
// Check 1 - zone polygons closed and non-degenerate
|
||||
function checkZonesClosed(zones: ZoneLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Zones closed & non-degenerate',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
for (const z of zones) {
|
||||
if (!Array.isArray(z.polygon) || z.polygon.length < 3) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${z.id} (${z.name}) has <3 vertices`)
|
||||
continue
|
||||
}
|
||||
const first = z.polygon[0]
|
||||
const last = z.polygon[z.polygon.length - 1]
|
||||
if (pointsEqual(first, last)) {
|
||||
r.anomalies.push(`${z.id} (${z.name}) has explicit first==last (OK but unusual)`)
|
||||
}
|
||||
const area = polygonArea(z.polygon)
|
||||
if (area <= 0.1) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${z.id} (${z.name}) degenerate area=${area.toFixed(3)}`)
|
||||
continue
|
||||
}
|
||||
r.details.push(`${z.name}: ${z.polygon.length} verts, area=${area.toFixed(2)}m^2`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 2 - zones don't overlap (except pool zone with pool basin slab).
|
||||
// Use bounding-box overlap as a loose proxy (axis-aligned polys here).
|
||||
function checkZoneOverlap(zones: ZoneLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: "Zones don't overlap",
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const bounds = zones.map((z) => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
kind: z.kind,
|
||||
b: polygonBounds(z.polygon),
|
||||
}))
|
||||
for (let i = 0; i < bounds.length; i++) {
|
||||
for (let j = i + 1; j < bounds.length; j++) {
|
||||
const a = bounds[i]
|
||||
const c = bounds[j]
|
||||
const ov = boundsOverlap(a.b, c.b)
|
||||
if (ov.overlap && ov.area > 0.1) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${a.name} overlaps ${c.name} by ~${ov.area.toFixed(2)}m^2`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.passed) r.details.push(`Pairwise BB-overlap check clean for ${zones.length} zones`)
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 3 - perimeter walls form a closed loop.
|
||||
// Identify perimeter walls as the 4 outermost thickness=0.22 exterior walls.
|
||||
function checkPerimeterLoop(walls: WallLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Perimeter walls closed loop',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const perim = walls.filter((w) => w.thickness >= 0.2)
|
||||
if (perim.length !== 4) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`Expected 4 perimeter walls (thickness>=0.2), got ${perim.length}`)
|
||||
}
|
||||
// Build endpoint histogram - every endpoint should be shared exactly once with another wall.
|
||||
const endpointCount = new Map<string, number>()
|
||||
const k = (p: Vec2) => `${p[0].toFixed(3)},${p[1].toFixed(3)}`
|
||||
for (const w of perim) {
|
||||
endpointCount.set(k(w.start), (endpointCount.get(k(w.start)) ?? 0) + 1)
|
||||
endpointCount.set(k(w.end), (endpointCount.get(k(w.end)) ?? 0) + 1)
|
||||
}
|
||||
for (const [pt, count] of endpointCount) {
|
||||
if (count !== 2) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`Perimeter endpoint ${pt} connects ${count} wall ends (expected 2)`)
|
||||
}
|
||||
}
|
||||
const corners = [...endpointCount.keys()].sort().join(' | ')
|
||||
r.details.push(`Perimeter corners: ${corners}`)
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 4 - interior walls connect to perimeter or other interior walls.
|
||||
// Every wall endpoint must either share with another wall endpoint OR lie on another wall's segment.
|
||||
function checkInteriorConnectivity(walls: WallLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Interior walls connected (no floating endpoints)',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const interior = walls.filter((w) => w.thickness < 0.2)
|
||||
|
||||
const isConnected = (p: Vec2, selfId: string): 'endpoint' | 'segment' | 'none' => {
|
||||
for (const other of walls) {
|
||||
if (other.id === selfId) continue
|
||||
if (pointsEqual(p, other.start) || pointsEqual(p, other.end)) {
|
||||
return 'endpoint'
|
||||
}
|
||||
if (pointOnSegment(p, other.start, other.end)) {
|
||||
return 'segment'
|
||||
}
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
for (const w of interior) {
|
||||
const s = isConnected(w.start, w.id)
|
||||
const e = isConnected(w.end, w.id)
|
||||
if (s === 'none') {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${w.id} start (${w.start[0]},${w.start[1]}) floats`)
|
||||
}
|
||||
if (e === 'none') {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${w.id} end (${w.end[0]},${w.end[1]}) floats`)
|
||||
}
|
||||
r.details.push(`${w.id}: start=${s}, end=${e}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 5 - wall endpoints fit inside |x|<=12.5, |z|<=10 fenced envelope.
|
||||
function checkWallBounds(walls: WallLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Wall endpoints inside fenced envelope',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const LIM_X = 12.5
|
||||
const LIM_Z = 10
|
||||
for (const w of walls) {
|
||||
for (const label of ['start', 'end'] as const) {
|
||||
const [x, z] = w[label]
|
||||
if (Math.abs(x) > LIM_X + 1e-3 || Math.abs(z) > LIM_Z + 1e-3) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${w.id} ${label} (${x},${z}) outside envelope`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.passed)
|
||||
r.details.push(`All ${walls.length * 2} wall endpoints within |x|<=${LIM_X}, |z|<=${LIM_Z}`)
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 6 - pool basin slab polygon matches pool zone polygon.
|
||||
function checkPoolMatch(zones: ZoneLike[], slabs: SlabLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Pool basin slab == pool zone polygon',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const poolZone = zones.find((z) => z.kind === 'pool' || z.name.toLowerCase() === 'pool')
|
||||
const poolSlab = slabs.find((s) => s.kind === 'pool-basin' || s.id.includes('pool'))
|
||||
if (!poolZone) {
|
||||
r.passed = false
|
||||
r.anomalies.push('No pool zone found')
|
||||
return r
|
||||
}
|
||||
if (!poolSlab) {
|
||||
r.passed = false
|
||||
r.anomalies.push('No pool basin slab found')
|
||||
return r
|
||||
}
|
||||
if (!polygonsEqual(poolZone.polygon, poolSlab.polygon)) {
|
||||
r.passed = false
|
||||
r.anomalies.push(
|
||||
`Pool zone polygon ${JSON.stringify(poolZone.polygon)} != slab polygon ${JSON.stringify(poolSlab.polygon)}`,
|
||||
)
|
||||
} else {
|
||||
r.details.push(`Pool zone & basin share ${poolZone.polygon.length}-vertex polygon`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Check 7 - fence gap at south entrance x in [-1,1] on z=10.
|
||||
// Spec: "x ∈ [-1, 1] on z=10 has no fence". Note: +z is south in this scene.
|
||||
function checkFenceGap(fences: FenceLike[]): CheckResult {
|
||||
const r: CheckResult = {
|
||||
name: 'Fence gap at south entrance (x in [-1,1], z=10)',
|
||||
passed: true,
|
||||
details: [],
|
||||
anomalies: [],
|
||||
}
|
||||
const GAP_X0 = -1
|
||||
const GAP_X1 = 1
|
||||
const ENTRANCE_Z = 10
|
||||
for (const f of fences) {
|
||||
const [sx, sz] = f.start
|
||||
const [ex, ez] = f.end
|
||||
// Only consider fences that touch z=ENTRANCE_Z.
|
||||
if (Math.abs(sz - ENTRANCE_Z) > 1e-3 || Math.abs(ez - ENTRANCE_Z) > 1e-3) continue
|
||||
const fMinX = Math.min(sx, ex)
|
||||
const fMaxX = Math.max(sx, ex)
|
||||
const overlapMin = Math.max(fMinX, GAP_X0)
|
||||
const overlapMax = Math.min(fMaxX, GAP_X1)
|
||||
if (overlapMax - overlapMin > 1e-3) {
|
||||
r.passed = false
|
||||
r.anomalies.push(`${f.id} covers x in [${overlapMin},${overlapMax}] at z=${ENTRANCE_Z}`)
|
||||
} else {
|
||||
r.details.push(`${f.id} [${sx},${sz}]->[${ex},${ez}] clears gap`)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function renderReport(results: CheckResult[]): string {
|
||||
const ts = new Date().toISOString()
|
||||
const lines: string[] = []
|
||||
lines.push('# Villa Azul - V2 Geometry Report')
|
||||
lines.push('')
|
||||
lines.push(`- Scene: \`${SCENE_PATH}\``)
|
||||
lines.push(`- Generated: ${ts}`)
|
||||
lines.push(`- Script: \`packages/mcp/test-reports/villa-azul/v2-geometry.ts\``)
|
||||
lines.push('')
|
||||
const passCount = results.filter((x) => x.passed).length
|
||||
lines.push(`## Summary: ${passCount}/${results.length} checks passed`)
|
||||
lines.push('')
|
||||
lines.push('| # | Check | Status |')
|
||||
lines.push('|---|-------|--------|')
|
||||
results.forEach((r, i) => {
|
||||
lines.push(`| ${i + 1} | ${r.name} | ${r.passed ? 'PASS' : 'FAIL'} |`)
|
||||
})
|
||||
lines.push('')
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i]
|
||||
lines.push(`## ${i + 1}. ${r.name} - ${r.passed ? 'PASS' : 'FAIL'}`)
|
||||
if (r.anomalies.length) {
|
||||
lines.push('')
|
||||
lines.push('**Anomalies:**')
|
||||
for (const a of r.anomalies) lines.push(`- ${a}`)
|
||||
}
|
||||
if (r.details.length) {
|
||||
lines.push('')
|
||||
lines.push('**Details:**')
|
||||
for (const d of r.details) lines.push(`- ${d}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { walls, zones, fences, slabs } = parseScene()
|
||||
const results: CheckResult[] = [
|
||||
checkZonesClosed(zones),
|
||||
checkZoneOverlap(zones),
|
||||
checkPerimeterLoop(walls),
|
||||
checkInteriorConnectivity(walls),
|
||||
checkWallBounds(walls),
|
||||
checkPoolMatch(zones, slabs),
|
||||
checkFenceGap(fences),
|
||||
]
|
||||
const md = renderReport(results)
|
||||
fs.writeFileSync(REPORT_PATH, md)
|
||||
// Console summary for the test runner.
|
||||
for (const r of results) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${r.passed ? 'PASS' : 'FAIL'} ${r.name}`)
|
||||
for (const a of r.anomalies) console.log(` ! ${a}`)
|
||||
}
|
||||
const passCount = results.filter((x) => x.passed).length
|
||||
console.log(`\n${passCount}/${results.length} passed. Report: ${REPORT_PATH}`)
|
||||
if (passCount < results.length) process.exitCode = 1
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user