Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.
Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
@types/react, @types/react-dom, @types/three to prevent JSX namespace
fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
match the new versions on every bump (so viewer/editor/mcp tarballs always
reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts
Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
what private-editor currently reports
Open PRs against editor-v2 will need rebasing/conflict resolution.
155 lines
4.6 KiB
TypeScript
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,
|
|
}
|
|
},
|
|
)
|
|
}
|