Files
editor/packages/mcp/src/tools/measure.ts
T

155 lines
4.6 KiB
TypeScript

import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const measureInput = {
fromId: NodeIdSchema,
toId: NodeIdSchema,
}
export const measureOutput = {
distanceMeters: z.number(),
areaSqMeters: z.number().optional(),
units: z.literal('meters'),
}
/**
* Compute a 3D centre point in level-coordinate space for distance measurement.
*
* For walls / fences: midpoint of the start/end segment at Y=0.
* For positioned nodes (item, door, window, building, stair, roof): `position`.
* For polygon nodes (slab, ceiling, zone): 2D centroid lifted to Y=0.
* For site: centroid of property line at Y=0 if available.
*
* Returns null if no representative centre can be derived (e.g. level node
* has no position of its own).
*/
function getCentre(node: AnyNode): [number, number, number] | null {
switch (node.type) {
case 'wall':
case 'fence': {
const [x1, z1] = node.start
const [x2, z2] = node.end
return [(x1 + x2) / 2, 0, (z1 + z2) / 2]
}
case 'item':
case 'door':
case 'window':
case 'building':
case 'stair':
case 'roof':
return node.position
case 'slab':
case 'ceiling':
case 'zone': {
const poly = node.polygon as Array<[number, number]> | undefined
if (!poly || poly.length === 0) return null
let cx = 0
let cz = 0
for (const [x, z] of poly) {
cx += x
cz += z
}
return [cx / poly.length, 0, cz / poly.length]
}
case 'site': {
const pts = node.polygon?.points ?? []
if (pts.length === 0) return [0, 0, 0]
let cx = 0
let cz = 0
for (const [x, z] of pts) {
cx += x
cz += z
}
return [cx / pts.length, 0, cz / pts.length]
}
default:
return null
}
}
/** Compute polygon area via the shoelace formula. */
function shoelaceArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0
let sum = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const [x1, z1] = polygon[i]!
const [x2, z2] = polygon[(i + 1) % n]!
sum += x1 * z2 - x2 * z1
}
return Math.abs(sum) / 2
}
export function registerMeasure(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'measure',
{
title: 'Measure',
description:
'Measure distance (in meters) between two nodes, or the area of a polygon node when fromId === toId.',
inputSchema: measureInput,
outputSchema: measureOutput,
},
async ({ fromId, toId }) => {
const from = bridge.getNode(fromId as AnyNodeId)
if (!from) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${fromId}`)
}
const to = bridge.getNode(toId as AnyNodeId)
if (!to) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${toId}`)
}
// Self-measurement: compute area for polygon-bearing nodes.
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 payload = {
distanceMeters: 0,
areaSqMeters: area,
units: 'meters' as const,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
// For non-polygon self, distance is 0 and no area.
const payload = { distanceMeters: 0, units: 'meters' as const }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
const fromCentre = getCentre(from as AnyNode)
const toCentre = getCentre(to as AnyNode)
if (!fromCentre || !toCentre) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot derive centre for measurement between ${from.type} and ${to.type}`,
)
}
const dx = fromCentre[0] - toCentre[0]
const dy = fromCentre[1] - toCentre[1]
const dz = fromCentre[2] - toCentre[2]
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
const payload = {
distanceMeters: distance,
units: 'meters' as const,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}