From fe7bc5dd3302e2b0fb74f505fcd76f8db8c2bcdd Mon Sep 17 00:00:00 2001 From: Rootsbybenda Date: Thu, 30 Jul 2026 11:15:19 +0300 Subject: [PATCH 1/3] fix(mcp): subtract surface holes from measured area --- packages/mcp/src/tools/measure.test.ts | 44 +++++++++++++++++++++++++- packages/mcp/src/tools/measure.ts | 6 ++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/tools/measure.test.ts b/packages/mcp/src/tools/measure.test.ts index dd489d66b..2a0233e2c 100644 --- a/packages/mcp/src/tools/measure.test.ts +++ b/packages/mcp/src/tools/measure.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { WallNode, ZoneNode } from '@pascal-app/core/schema' +import { SlabNode, WallNode, ZoneNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' import { registerMeasure } from './measure' @@ -61,6 +61,48 @@ describe('measure', () => { expect(parsed.areaSqMeters).toBeCloseTo(16, 5) }) + test('subtracts slab holes without changing an un-holed slab area', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const polygon: Array<[number, number]> = [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ] + const solid = SlabNode.parse({ polygon }) + const holed = SlabNode.parse({ + polygon, + holes: [ + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + ], + }) + bridge.createNode(solid, level.id) + bridge.createNode(holed, level.id) + + const solidResult = await client.callTool({ + name: 'measure', + arguments: { fromId: solid.id, toId: solid.id }, + }) + const solidArea = JSON.parse( + (solidResult.content as Array<{ type: string; text: string }>)[0]!.text, + ).areaSqMeters + const holedResult = await client.callTool({ + name: 'measure', + arguments: { fromId: holed.id, toId: holed.id }, + }) + const holedArea = JSON.parse( + (holedResult.content as Array<{ type: string; text: string }>)[0]!.text, + ).areaSqMeters + + expect(solidArea).toBeCloseTo(16, 5) + expect(holedArea).toBeCloseTo(12, 5) + }) + test('errors on unknown id', async () => { const result = await client.callTool({ name: 'measure', diff --git a/packages/mcp/src/tools/measure.ts b/packages/mcp/src/tools/measure.ts index b7b05a802..e6d0aab17 100644 --- a/packages/mcp/src/tools/measure.ts +++ b/packages/mcp/src/tools/measure.ts @@ -90,7 +90,7 @@ export function registerMeasure(server: McpServer, bridge: SceneOperations): voi { title: 'Measure', description: - 'Measure distance (in meters) between two nodes, or the area of a polygon node when fromId === toId.', + 'Measure distance (in meters) between two nodes, or the net area of a polygon node when fromId === toId.', inputSchema: measureInput, outputSchema: measureOutput, }, @@ -108,7 +108,9 @@ export function registerMeasure(server: McpServer, bridge: SceneOperations): voi if (fromId === toId) { const n = from as AnyNode if (n.type === 'zone' || n.type === 'slab' || n.type === 'ceiling') { - const area = shoelaceArea(n.polygon as Array<[number, number]>) + const holeArea = + n.type === 'zone' ? 0 : n.holes.reduce((sum, hole) => sum + shoelaceArea(hole), 0) + const area = Math.max(0, shoelaceArea(n.polygon) - holeArea) const payload = { distanceMeters: 0, areaSqMeters: area, From 2bba13dc8cd86b253c10e5ce85407d4de0a0eba0 Mon Sep 17 00:00:00 2001 From: Rootsbybenda Date: Thu, 30 Jul 2026 13:28:09 +0300 Subject: [PATCH 2/3] fix(mcp): tolerate nodes without a holes array Mirrors validate-build-json's Array.isArray guard: documents loaded without schema defaults can carry no holes array; measure falls back to the outer polygon instead of crashing. Regression test included. Co-Authored-By: Claude Fable 5 --- packages/mcp/src/tools/measure.test.ts | 35 ++++++++++++++++++++++++++ packages/mcp/src/tools/measure.ts | 6 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/tools/measure.test.ts b/packages/mcp/src/tools/measure.test.ts index 2a0233e2c..a9eaca2f6 100644 --- a/packages/mcp/src/tools/measure.test.ts +++ b/packages/mcp/src/tools/measure.test.ts @@ -103,6 +103,41 @@ describe('measure', () => { expect(holedArea).toBeCloseTo(12, 5) }) + test('tolerates a holed node whose holes array was stripped by its source', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const polygon: Array<[number, number]> = [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ] + const slab = SlabNode.parse({ + polygon, + holes: [ + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + ], + }) + bridge.createNode(slab, level.id) + // Documents loaded without running schema defaults can carry no holes array at all; + // measure must fall back to the outer polygon instead of crashing (mirrors + // validate-build-json's Array.isArray guard). + delete (bridge.getNodes()[slab.id] as { holes?: unknown }).holes + + const result = await client.callTool({ + name: 'measure', + arguments: { fromId: slab.id, toId: slab.id }, + }) + const parsed = JSON.parse( + (result.content as Array<{ type: string; text: string }>)[0]!.text, + ) + expect(parsed.areaSqMeters).toBeCloseTo(16, 5) + }) + test('errors on unknown id', async () => { const result = await client.callTool({ name: 'measure', diff --git a/packages/mcp/src/tools/measure.ts b/packages/mcp/src/tools/measure.ts index e6d0aab17..ba41f3ef6 100644 --- a/packages/mcp/src/tools/measure.ts +++ b/packages/mcp/src/tools/measure.ts @@ -108,8 +108,10 @@ export function registerMeasure(server: McpServer, bridge: SceneOperations): voi if (fromId === toId) { const n = from as AnyNode if (n.type === 'zone' || n.type === 'slab' || n.type === 'ceiling') { - const holeArea = - n.type === 'zone' ? 0 : n.holes.reduce((sum, hole) => sum + shoelaceArea(hole), 0) + const holes = n.type === 'zone' ? [] : n.holes + const holeArea = Array.isArray(holes) + ? holes.reduce((sum, hole) => sum + shoelaceArea(hole), 0) + : 0 const area = Math.max(0, shoelaceArea(n.polygon) - holeArea) const payload = { distanceMeters: 0, From 1e94c4194517abba7184e7c4bc79a42e0afa375b Mon Sep 17 00:00:00 2001 From: Rootsbybenda Date: Thu, 30 Jul 2026 13:38:59 +0300 Subject: [PATCH 3/3] style(mcp): format measure regression test Co-Authored-By: Claude Fable 5 --- packages/mcp/src/tools/measure.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/mcp/src/tools/measure.test.ts b/packages/mcp/src/tools/measure.test.ts index a9eaca2f6..8b9d05a65 100644 --- a/packages/mcp/src/tools/measure.test.ts +++ b/packages/mcp/src/tools/measure.test.ts @@ -132,9 +132,7 @@ describe('measure', () => { name: 'measure', arguments: { fromId: slab.id, toId: slab.id }, }) - const parsed = JSON.parse( - (result.content as Array<{ type: string; text: string }>)[0]!.text, - ) + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) expect(parsed.areaSqMeters).toBeCloseTo(16, 5) })