From 3d5c87a651974cc41ed20cacde0597529761b775 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 27 Apr 2026 14:31:04 -0400 Subject: [PATCH] feat(mcp): add guided construction workflows --- .../app/api/scenes/[id]/events/route.ts | 101 +++ apps/editor/components/scene-loader.tsx | 65 +- packages/core/package.json | 5 + packages/core/src/schema/nodes/level.ts | 2 + .../systems/stair/stair-opening-sync.test.ts | 71 ++ .../src/systems/stair/stair-opening-sync.ts | 146 +++- packages/mcp/README.md | 43 +- packages/mcp/src/bridge/scene-bridge.ts | 30 + packages/mcp/src/prompts/from-brief.ts | 11 +- .../mcp/src/prompts/iterate-on-feedback.ts | 4 + packages/mcp/src/prompts/prompts.test.ts | 3 + packages/mcp/src/prompts/scene-guidance.ts | 22 + packages/mcp/src/resources/agent-guide.ts | 67 ++ packages/mcp/src/resources/catalog-items.ts | 17 +- packages/mcp/src/resources/index.ts | 3 + packages/mcp/src/resources/resources.test.ts | 29 +- packages/mcp/src/server.ts | 17 + .../src/storage/sqlite-scene-store.test.ts | 47 ++ .../mcp/src/storage/sqlite-scene-store.ts | 84 +++ packages/mcp/src/storage/types.ts | 23 + packages/mcp/src/tools/apply-patch.test.ts | 51 +- packages/mcp/src/tools/apply-patch.ts | 9 +- packages/mcp/src/tools/asset-catalog.ts | 315 +++++++++ .../mcp/src/tools/construction-tools.test.ts | 208 ++++++ packages/mcp/src/tools/construction-tools.ts | 498 +++++++++++++ packages/mcp/src/tools/create-level.ts | 9 +- packages/mcp/src/tools/create-wall.test.ts | 81 +++ packages/mcp/src/tools/create-wall.ts | 9 +- packages/mcp/src/tools/cut-opening.test.ts | 4 + packages/mcp/src/tools/cut-opening.ts | 38 +- packages/mcp/src/tools/delete-node.ts | 9 +- packages/mcp/src/tools/duplicate-level.ts | 9 +- packages/mcp/src/tools/geometry.ts | 128 ++++ packages/mcp/src/tools/index.ts | 26 +- packages/mcp/src/tools/live-sync.ts | 80 +++ .../tools/photo-to-scene/photo-to-scene.ts | 4 + packages/mcp/src/tools/place-item.test.ts | 38 +- packages/mcp/src/tools/place-item.ts | 87 ++- packages/mcp/src/tools/redo.ts | 5 +- packages/mcp/src/tools/room-tools.test.ts | 189 +++++ packages/mcp/src/tools/room-tools.ts | 596 ++++++++++++++++ .../src/tools/scene-lifecycle/load-scene.ts | 1 + .../src/tools/scene-lifecycle/save-scene.ts | 5 + packages/mcp/src/tools/scene-query.test.ts | 128 ++++ packages/mcp/src/tools/scene-query.ts | 655 ++++++++++++++++++ packages/mcp/src/tools/set-zone.ts | 5 +- .../tools/templates/create-from-template.ts | 10 +- packages/mcp/src/tools/undo.ts | 5 +- 48 files changed, 3877 insertions(+), 115 deletions(-) create mode 100644 apps/editor/app/api/scenes/[id]/events/route.ts create mode 100644 packages/core/src/systems/stair/stair-opening-sync.test.ts create mode 100644 packages/mcp/src/prompts/scene-guidance.ts create mode 100644 packages/mcp/src/resources/agent-guide.ts create mode 100644 packages/mcp/src/tools/asset-catalog.ts create mode 100644 packages/mcp/src/tools/construction-tools.test.ts create mode 100644 packages/mcp/src/tools/construction-tools.ts create mode 100644 packages/mcp/src/tools/geometry.ts create mode 100644 packages/mcp/src/tools/live-sync.ts create mode 100644 packages/mcp/src/tools/room-tools.test.ts create mode 100644 packages/mcp/src/tools/room-tools.ts create mode 100644 packages/mcp/src/tools/scene-query.test.ts create mode 100644 packages/mcp/src/tools/scene-query.ts diff --git a/apps/editor/app/api/scenes/[id]/events/route.ts b/apps/editor/app/api/scenes/[id]/events/route.ts new file mode 100644 index 00000000..575ac6d6 --- /dev/null +++ b/apps/editor/app/api/scenes/[id]/events/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from 'next/server' +import { getSceneStore } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type RouteParams = { params: Promise<{ id: string }> } + +const POLL_MS = 250 +const HEARTBEAT_MS = 15_000 +const MAX_EVENTS_PER_POLL = 50 + +export async function GET(request: Request, { params }: RouteParams) { + const { id } = await params + const store = await getSceneStore() + + if (!store.listSceneEvents) { + return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 }) + } + + const scene = await store.load(id) + if (!scene) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + + const url = new URL(request.url) + const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10) + const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10) + let cursor = Math.max( + 0, + Number.isFinite(afterFromQuery) ? afterFromQuery : 0, + Number.isFinite(afterFromHeader) ? afterFromHeader : 0, + ) + + const encoder = new TextEncoder() + let closed = false + let pollTimer: ReturnType | undefined + let heartbeatTimer: ReturnType | undefined + + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: string) => { + if (!closed) controller.enqueue(encoder.encode(chunk)) + } + + const close = () => { + if (closed) return + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + try { + controller.close() + } catch { + // The client may have already closed the stream. + } + } + + request.signal.addEventListener('abort', close, { once: true }) + enqueue('retry: 1000\n\n') + + const poll = async () => { + if (closed) return + try { + const events = await store.listSceneEvents!(id, { + afterEventId: cursor, + limit: MAX_EVENTS_PER_POLL, + }) + for (const event of events) { + cursor = event.eventId + enqueue(`id: ${event.eventId}\n`) + enqueue('event: scene\n') + enqueue(`data: ${JSON.stringify(event)}\n\n`) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + enqueue('event: error\n') + enqueue(`data: ${JSON.stringify({ message })}\n\n`) + } finally { + if (!closed) pollTimer = setTimeout(poll, POLL_MS) + } + } + + heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS) + void poll() + }, + cancel() { + closed = true + if (pollTimer) clearTimeout(pollTimer) + if (heartbeatTimer) clearInterval(heartbeatTimer) + }, + }) + + return new Response(stream, { + headers: { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream; charset=utf-8', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 6ae6962a..5546c032 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -1,6 +1,7 @@ 'use client' import { + applySceneGraphToEditor, Editor, type SceneGraph, type SidebarTab, @@ -9,7 +10,7 @@ import { } from '@pascal-app/editor' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' export interface SceneMeta { id: string @@ -37,9 +38,32 @@ interface SceneLoaderProps { meta: SceneMeta } +type SceneGraphWithCollections = SceneGraph & { + collections?: Record +} + +interface LiveSceneEvent { + eventId: number + sceneId: string + version: number + kind: string + createdAt: string + graph: SceneGraphWithCollections +} + +function sceneGraphSignature(graph: SceneGraphWithCollections): string { + return JSON.stringify({ + nodes: graph.nodes, + rootNodeIds: graph.rootNodeIds, + collections: graph.collections, + }) +} + export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const router = useRouter() const versionRef = useRef(meta.version) + const lastRemoteGraphJsonRef = useRef(null) + const suppressRemoteSaveUntilRef = useRef(0) const [conflict, setConflict] = useState(false) const [saveError, setSaveError] = useState(null) @@ -47,6 +71,15 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const handleSave = useCallback( async (graph: SceneGraph) => { + const graphJson = sceneGraphSignature(graph) + const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current + if (lastRemoteGraphJsonRef.current === graphJson) { + lastRemoteGraphJsonRef.current = null + suppressRemoteSaveUntilRef.current = 0 + return + } + if (isRecentRemoteApply) return + try { const response = await fetch(`/api/scenes/${meta.id}`, { method: 'PUT', @@ -77,6 +110,36 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { [meta.id, meta.name], ) + useEffect(() => { + const source = new EventSource(`/api/scenes/${meta.id}/events`) + + source.addEventListener('scene', (event) => { + let payload: LiveSceneEvent + try { + payload = JSON.parse((event as MessageEvent).data) as LiveSceneEvent + } catch { + return + } + if (payload.sceneId !== meta.id) return + if (payload.version <= versionRef.current) return + + versionRef.current = payload.version + lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph) + suppressRemoteSaveUntilRef.current = Date.now() + 2500 + applySceneGraphToEditor(payload.graph) + setConflict(false) + setSaveError(null) + }) + + source.addEventListener('error', () => { + if (source.readyState === EventSource.CLOSED) { + setSaveError('Live scene connection closed') + } + }) + + return () => source.close() + }, [meta.id]) + const handleThumb = useCallback( async (_blob: Blob) => { // TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail. diff --git a/packages/core/package.json b/packages/core/package.json index 6fef1afd..7519b073 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,11 @@ "types": "./dist/systems/wall/wall-footprint.d.ts", "import": "./dist/systems/wall/wall-footprint.js", "default": "./dist/systems/wall/wall-footprint.js" + }, + "./stair-openings": { + "types": "./dist/systems/stair/stair-opening-sync.d.ts", + "import": "./dist/systems/stair/stair-opening-sync.js", + "default": "./dist/systems/stair/stair-opening-sync.js" } }, "files": [ diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 4761161c..d351b77d 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' import { FenceNode } from './fence' import { GuideNode } from './guide' +import { ItemNode } from './item' import { RoofNode } from './roof' import { ScanNode } from './scan' import { SlabNode } from './slab' @@ -19,6 +20,7 @@ export const LevelNode = BaseNode.extend({ z.union([ WallNode.shape.id, FenceNode.shape.id, + ItemNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts new file mode 100644 index 00000000..80221b12 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -0,0 +1,71 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '../../schema' +import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { syncAutoStairOpenings } from './stair-opening-sync' + +describe('syncAutoStairOpenings', () => { + test('only applies stair holes to destination slabs that contain the opening', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const bedroomSlab = SlabNode.parse({ + name: 'Bedroom Slab', + parentId: upper.id, + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_main', + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + id: 'stair_main', + name: 'Main Stair', + parentId: ground.id, + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [ + building, + ground, + upper, + landingSlab, + bedroomSlab, + stair, + { ...segment, parentId: stair.id }, + ].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const bedroomUpdate = updates.find((update) => update.id === bedroomSlab.id) + + expect(landingUpdate?.data.holes).toHaveLength(1) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + expect(bedroomUpdate).toBeUndefined() + }) +}) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 3cf828f8..36dd6c15 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,5 +1,12 @@ -import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + SlabNode, + StairNode, + StairSegmentNode, +} from '../../schema' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' type Point2D = [number, number] @@ -58,7 +65,8 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[] if (left.length !== right.length) return false return left.every( (entry, index) => - entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null), + entry.source === right[index]?.source && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), ) } @@ -178,7 +186,10 @@ function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { return (stair.children ?? []) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) - .filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) } function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D { @@ -186,7 +197,10 @@ function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Poi return [stair.position[0] + worldX, stair.position[2] + worldZ] } -function getStraightStairLayouts(stair: StairNode, nodes: Record): StraightStairLayout[] { +function getStraightStairLayouts( + stair: StairNode, + nodes: Record, +): StraightStairLayout[] { const segments = resolveStraightSegments(stair, nodes) const transforms = computeSegmentTransforms(segments) @@ -204,7 +218,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record toWorldPlanPoint(stair, x, z)) + return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => + toWorldPlanPoint(stair, x, z), + ) } function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) { - const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1)) + const treadDepth = Math.max( + 0.2, + segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1), + ) return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8)) } @@ -261,6 +283,36 @@ function polygonArea(points: Point2D[]) { return area / 2 } +function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > tolerance) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -tolerance) return false + const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lenSq + tolerance +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + let inside = false + const [x, z] = point + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + if (pointOnSegment(point, a, b)) return true + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + + return inside +} + +function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) { + return inner.every((point) => pointInPolygon(point, outer)) +} + function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null { if (polygon.length < 4) return null const xs = polygon.map(([x]) => x) @@ -289,12 +341,16 @@ function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect { function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { if (rects.length === 0) return [] - const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) - const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) + const xs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) + const zs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) if (xs.length < 2 || zs.length < 2) return [] const occupied = new Set() @@ -393,13 +449,17 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { for (let index = 0; index <= segmentCount; index++) { const t = index / segmentCount const angle = startAngle + (endAngle - startAngle) * t - outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius)) + outerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), + ) } for (let index = segmentCount; index >= 0; index--) { const t = index / segmentCount const angle = startAngle + (endAngle - startAngle) * t - innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius)) + innerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), + ) } return [...outerPoints, ...innerPoints] @@ -440,7 +500,11 @@ function getStraightOpeningPolygonsForSurface( if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) { const openingDepth = getStraightFlightOpeningDepth(stair, segment) const flightRect = getAxisAlignedRectFromPolygon( - getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length), + getStraightSegmentLocalSlicePolygon( + layout, + Math.max(0, segment.length - openingDepth), + segment.length, + ), ) if (flightRect) openingRects.push(expandRect(flightRect, openingOffset)) } @@ -452,7 +516,9 @@ function getStraightOpeningPolygonsForSurface( } const landingRects: AxisAlignedRect[] = [] - const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length)) + const landingRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length), + ) if (landingRect) landingRects.push(expandRect(landingRect, openingOffset)) const previous = layouts[index - 1] if (previous?.segment.segmentType === 'stair') { @@ -556,10 +622,18 @@ function getTargetCeilingElevationForStair( return ceiling.height ?? DEFAULT_WALL_HEIGHT } - return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0) + return ( + (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - + (stair.position[1] ?? 0) + ) } -function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record) { +function shouldApplyStairToSlab( + stair: StairNode, + slabLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -578,7 +652,11 @@ function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Re return slabLevel > minLevel && slabLevel <= maxLevel } -function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record) { +function shouldApplyStairToCeiling( + stair: StairNode, + ceilingLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -598,16 +676,22 @@ function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nod } export function syncAutoStairOpenings(nodes: Record) { - const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false) + const stairs = Object.values(nodes).filter( + (node): node is StairNode => node.type === 'stair' && node.visible !== false, + ) const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') - const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) const updates: Array<{ id: AnyNodeId; data: Partial }> = [] for (const slab of slabs) { const slabLevelId = resolveLevelId(slab, nodes) const existingHoles = slab.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -633,11 +717,15 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) + .filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon)) const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: slab.id, data: { @@ -652,7 +740,9 @@ export function syncAutoStairOpenings(nodes: Record) { const ceilingLevelId = resolveLevelId(ceiling, nodes) const existingHoles = ceiling.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -678,11 +768,15 @@ export function syncAutoStairOpenings(nodes: Record) { }, })), ) + .filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon)) const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: ceiling.id, data: { diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4324b890..48dece7f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -62,6 +62,26 @@ PASCAL_DATA_DIR="$HOME/.pascal/data" bun run dev PASCAL_DATA_DIR="$HOME/.pascal/data" bun packages/mcp/dist/bin/pascal-mcp.js ``` +## Live editor updates + +When the editor and MCP server share the same `PASCAL_DATA_DIR`, MCP mutations +against a loaded saved scene are persisted to SQLite and recorded in a local +`scene_events` stream. The editor page subscribes to that stream at +`/api/scenes/:id/events` with server-sent events, so an open browser tab can +apply scene graph snapshots as the agent edits the scene. + +The flow is intentionally local and lightweight: + +1. Open or create a scene in the editor so it is saved in the local database. +2. Load that scene through MCP with `load_scene`. +3. Run MCP mutation tools such as `create_room`, `add_door`, `furnish_room`, + `create_wall`, `place_item`, or `set_zone`. + +Each mutation version-checks the saved scene before writing. If the browser or +another MCP process saved a newer version first, the MCP tool returns +`live_sync_version_conflict`; reload the scene with `load_scene` before +continuing. + ## Claude Desktop config Edit `~/Library/Application Support/Claude/claude_desktop_config.json` @@ -211,12 +231,24 @@ captured by Zundo's temporal middleware as a single undoable step. | `get_node` | Fetch a node by id. | `{ id }` | the node, or `InvalidParams` if not found | | `describe_node` | Node summary with ancestry, children count and properties. | `{ id }` | `{ id, type, parentId, ancestry[], childrenCount, properties, description }` | | `find_nodes` | Filter nodes by type / parent / zone / level. | `{ type?, parentId?, zoneId?, levelId? }` | `{ nodes: AnyNode[] }` | +| `list_levels` | List levels with ids, floor indices, parent ids and child counts. | — | `{ activeSceneId, levels[] }` | +| `get_level_summary` | Compact summary of one level with counts, wall/opening lists, zones, slabs, ceilings and items. | `{ levelId? }` | `{ levelId, counts, walls, zones, items, slabs, ceilings }` | +| `get_walls` | Walls on a level with length and child doors/windows. | `{ levelId? }` | `{ levelId, walls[] }` | +| `get_zones` | Room/zone polygons with approximate areas and bounds. | `{ levelId? }` | `{ levelId, zones[] }` | | `measure` | Distance between two nodes; area when applicable. | `{ fromId, toId }` | `{ distanceMeters, areaSqMeters?, units: 'meters' }` | +| `search_assets` | Search the built-in MCP item catalog. | `{ query, category? }` | `{ results, total }` | +| `create_story_shell` | Create one level-owned story shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story. | `{ levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? }` | `{ wallIds, slabId, ceilingId, createdIds }` | +| `create_stair_between_levels` | Create a straight stair and one rectangular manual opening in the destination slab/source ceiling, with auto-opening disabled. | `{ fromLevelId, toLevelId, position, width?, runLength?, totalRise? }` | `{ stairId, stairSegmentId, openingPolygon }` | +| `create_roof` | Create a roof container and one roof segment. By default creates a dedicated roof level above the reference occupied level for solo/exploded views. | `{ levelId, width, depth, roofType?, roofHeight?, roofLevelId?, useDedicatedRoofLevel? }` | `{ roofLevelId, createdRoofLevelId, roofId, roofSegmentId }` | +| `create_room` | Create a zone, slab, ceiling, and walls from a polygon. | `{ levelId, name, polygon, color?, wallHeight?, wallThickness? }` | `{ zoneId, slabId, ceilingId, wallIds, areaSqMeters }` | +| `add_door` | Add a door to a wall using parametric placement. | `{ wallId, t, width?, height?, hingesSide?, swingDirection? }` | `{ doorId, localX }` | +| `add_window` | Add a window to a wall using parametric placement and sill height. | `{ wallId, t, width?, height?, sillHeight? }` | `{ windowId, localX, sillHeight }` | +| `furnish_room` | Place realistic furniture for a room type inside a polygon. | `{ levelId, roomType, polygon, doorWallIndex? }` | `{ placed, itemIds, skipped }` | | `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. | `{ patches: Patch[] }` | `{ applied: number }` | | `create_level` | Add a new level to a building. | `{ buildingId, elevation, height, label? }` | `{ levelId }` | | `create_wall` | Add a wall to a level. | `{ levelId, start, end, thickness?, height? }` | `{ wallId }` | -| `place_item` | Place a catalog item on a slab, ceiling, or wall with placement validation. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId }` or `{ error: 'invalid_placement', reason }` | -| `cut_opening` | Cut a door or window opening into a wall. | `{ wallId, type: 'door' \| 'window', position, width, height }` | `{ openingId }` | +| `place_item` | Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId, status }` | +| `cut_opening` | Cut a door or window opening into a wall. `position` is 0..1 along the wall and is stored as wall-local meters. | `{ wallId, type: 'door' \| 'window', position, width, height }` | `{ openingId }` | | `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` | | `duplicate_level` | Clone a level and all of its descendants. | `{ levelId }` | `{ newLevelId, newNodeIds[] }` | | `delete_node` | Delete a node; cascades when `cascade: true`. | `{ id, cascade? }` | `{ deletedIds: [] }` | @@ -225,6 +257,7 @@ captured by Zundo's temporal middleware as a single undoable step. | `export_json` | Serialize the scene graph as JSON. | `{ pretty? }` | `{ json: string }` | | `export_glb` | Stubbed: GLB export requires the browser renderer. | — | throws `not_implemented` | | `validate_scene` | Zod-validate every node and parent-child integrity. | — | `{ valid, errors: { nodeId, path, message }[] }` | +| `verify_scene` | High-level layout check with validation status, per-level counts, empty levels and practical issues. | — | `{ valid, levels[], issues, hasIssues }` | | `check_collisions` | Find overlapping items and out-of-bounds placements. | `{ levelId? }` | `{ collisions: { aId, bId, kind }[] }` | | `analyze_floorplan_image` | Vision tool: extract walls, rooms, and approximate dimensions from a floorplan image. | `{ image, scaleHint? }` | `{ walls, rooms, approximateDimensions, confidence }` | | `analyze_room_photo` | Vision tool: extract approximate dimensions and fixtures from a room photo. | `{ image }` | `{ approximateDimensions, identifiedFixtures, identifiedWindows }` | @@ -239,7 +272,8 @@ The vision tools require the MCP host to support the sampling capability | --- | --- | --- | | `pascal://scene/current` | `application/json` | Full `{ nodes, rootNodeIds, collections }` snapshot. | | `pascal://scene/current/summary` | `text/markdown` | Human-readable summary with node counts, bounding box, and level areas. | -| `pascal://catalog/items` | `application/json` | Item catalog; returns `{ status: 'catalog_unavailable', items: [] }` in headless mode if no catalog is provided. | +| `pascal://agent/guide` | `text/markdown` | MCP-first construction workflow, scene invariants, and tool preferences for agents. | +| `pascal://catalog/items` | `application/json` | Dependency-free built-in catalog subset for common residential furniture and fixtures. | | `pascal://constraints/{levelId}` | `application/json` | Slab footprints and wall polygons for the given level — useful as planner context. | ## Prompts @@ -256,6 +290,9 @@ The vision tools require the MCP host to support the sampling capability renderer and isn't reachable headlessly without a large additional effort. - Vision tools require MCP host sampling support. Claude Desktop supports this; some MCP clients don't. +- The built-in MCP catalog is intentionally small. Host applications can expose + their own richer catalog through additional tools/resources without requiring + the MCP package to depend on the editor UI bundle. - Systems (wall mitering, slab triangulation, CSG cutouts, roof / stair generation) run inside React hooks in the editor. Headless mode doesn't regenerate derived geometry — but all node data remains fully manipulable. diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index c03eb557..c355ff22 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -6,6 +6,7 @@ import type { AnyNode } from '@pascal-app/core/schema' import { type AnyNodeId, AnyNode as AnyNodeSchema, type AnyNodeType } from '@pascal-app/core/schema' // Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`. import useScene from '@pascal-app/core/store' +import type { SceneMeta } from '../storage/types' export type ValidationError = { nodeId: string; path: string; message: string } export type ValidationResult = { valid: boolean; errors: ValidationError[] } @@ -14,6 +15,10 @@ export type CreatePatch = { op: 'create'; node: AnyNode; parentId?: AnyNodeId } export type UpdatePatch = { op: 'update'; id: AnyNodeId; data: Partial } export type DeletePatch = { op: 'delete'; id: AnyNodeId; cascade?: boolean } export type Patch = CreatePatch | UpdatePatch | DeletePatch +export type ActiveSceneMeta = Pick< + SceneMeta, + 'id' | 'name' | 'projectId' | 'ownerId' | 'thumbnailUrl' | 'version' +> /** * Headless bridge to the `@pascal-app/core` Zustand store. @@ -23,6 +28,31 @@ export type Patch = CreatePatch | UpdatePatch | DeletePatch * `flushDirty()` for observability. */ export class SceneBridge { + private activeScene: ActiveSceneMeta | null = null + + /** + * Scene identity currently bound to this bridge. MCP tools use this to know + * which editor scene should receive live events after mutations. + */ + setActiveScene(meta: ActiveSceneMeta): void { + this.activeScene = { + id: meta.id, + name: meta.name, + projectId: meta.projectId, + ownerId: meta.ownerId, + thumbnailUrl: meta.thumbnailUrl, + version: meta.version, + } + } + + getActiveScene(): ActiveSceneMeta | null { + return this.activeScene + } + + clearActiveScene(): void { + this.activeScene = null + } + /** Load initial state; if empty, creates default Site → Building → Level. */ loadDefault(): void { useScene.getState().loadScene() diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index f409757b..73f7abaa 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -1,17 +1,20 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ 'You are a Pascal 3D scene designer.', - 'You have access to the `apply_patch` tool for all scene mutations. Prefer it over individual create_* tools so that your changes land as a single undoable step.', - 'Build incrementally. Starting from an empty scene, first create a Site, then a Building, then one or more Levels; only after that do you create walls, zones, slabs, items, and openings.', + 'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.', + 'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.', 'Respect these invariants:', ' - Levels live under a Building.', ' - Walls, fences, zones, slabs, ceilings, roofs, stairs live under a Level.', + ' - Multi-story exterior walls are per-level story walls; never make lower-level walls taller to stand in for upper-level walls.', ' - Doors and windows live under a Wall (parentId = wallId).', - ' - Items live under a Wall, Ceiling, or Site.', + ' - Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling; outdoor items can live under a Site.', 'Use realistic dimensions in meters. Keep wall thickness small (0.1–0.3 m) and ceiling height 2.4–3.0 m unless the brief dictates otherwise.', + SCENE_DESIGN_GUIDANCE, 'Respond ONLY with tool calls. Do not produce verbose narrative or prose; keep any explanations in short tool-call arguments.', ].join('\n') @@ -29,7 +32,7 @@ export function buildFromBriefPrompt(args: { parts.push( '', '## Task', - 'Produce a plan of `apply_patch` calls that realises the brief within the stated constraints. Start from an empty site. Call the vision / query tools only if you need extra context.', + 'Produce tool calls that realise the brief within the stated constraints. Start from an empty site. Prefer create_story_shell/create_room/add_door/add_window/create_stair_between_levels/create_roof/furnish_room for architectural layout, use apply_patch for exact bulk graph work, and call validate_scene plus verify_scene after complex layouts.', ) return parts.join('\n') } diff --git a/packages/mcp/src/prompts/iterate-on-feedback.ts b/packages/mcp/src/prompts/iterate-on-feedback.ts index ecf52d04..80de7192 100644 --- a/packages/mcp/src/prompts/iterate-on-feedback.ts +++ b/packages/mcp/src/prompts/iterate-on-feedback.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ 'You are iterating on an existing Pascal scene based on user feedback.', @@ -9,7 +10,10 @@ const PREAMBLE = [ ' - Prefer updates over create+delete pairs when a field change will do.', ' - Do not re-create nodes that already exist.', ' - Do not touch nodes that are unrelated to the feedback.', + ' - Prefer semantic tools such as create_room, add_door, add_window, furnish_room, and place_item when they match the request.', ' - Bundle related mutations into a single `apply_patch` call so they share one undo step.', + ' - For multi-room changes, call verify_scene after the mutation and fix reported issues.', + SCENE_DESIGN_GUIDANCE, ' - Respond ONLY with tool calls. No prose.', ].join('\n') diff --git a/packages/mcp/src/prompts/prompts.test.ts b/packages/mcp/src/prompts/prompts.test.ts index 31d65a55..f35aa1d8 100644 --- a/packages/mcp/src/prompts/prompts.test.ts +++ b/packages/mcp/src/prompts/prompts.test.ts @@ -62,6 +62,9 @@ describe('from_brief', () => { if (m.content.type === 'text') { expect(m.content.text).toContain('60 sqm studio') expect(m.content.text).toContain('apply_patch') + expect(m.content.text).toContain('create_story_shell') + expect(m.content.text).toContain('pascal://agent/guide') + expect(m.content.text).toContain('dedicated roof level') } } finally { await pair.close() diff --git a/packages/mcp/src/prompts/scene-guidance.ts b/packages/mcp/src/prompts/scene-guidance.ts new file mode 100644 index 00000000..a9dade36 --- /dev/null +++ b/packages/mcp/src/prompts/scene-guidance.ts @@ -0,0 +1,22 @@ +export const SCENE_DESIGN_GUIDANCE = [ + 'Scene design workflow:', + ' - Use meters. X/Z are horizontal floor-plan axes; Y is vertical.', + ' - Read `pascal://agent/guide` when you need construction rules; do not inspect repository code for ordinary scene editing.', + ' - Door default: 0.9m wide by 2.1m high, floor-mounted.', + ' - Window default: 1.5m wide by 1.5m high with a 0.9m sill height.', + ' - For clear concrete requests, act with reasonable defaults instead of asking for clarification.', + ' - For full homes/apartments, include realistic support spaces: kitchen, living/dining, bathrooms, hallway/entry, storage/laundry where appropriate.', + ' - For multi-story buildings, create separate level-owned story shells. Do not stretch first-floor exterior walls to cover upper floors.', + '', + 'Preferred phased tool workflow:', + ' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.', + ' - Create visible massing early: create_level as needed, then create_story_shell once per story.', + ' - For rooms, prefer create_room, then add_door/add_window, then furnish_room.', + ' - For stairs between floors, prefer create_stair_between_levels so slab/ceiling openings stay rectangular and do not duplicate auto-generated holes.', + ' - For roofs, prefer create_roof and let it create/use a dedicated roof level above the top occupied story so solo/exploded level views can isolate the roof.', + ' - add_door/add_window use t = 0..1 along a wall: 0 is start, 0.5 is center, 1 is end.', + ' - Use search_assets before place_item when placing a specific catalog item.', + ' - Use apply_patch for precise bulk edits that the semantic tools cannot express.', + ' - After each major phase, call get_level_summary or pascal://scene/current/summary so progress is visible and errors are easier to localize.', + ' - After multi-room or full-floor work, call validate_scene and verify_scene, then fix reported issues before finishing.', +].join('\n') diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts new file mode 100644 index 00000000..e3c9e0b7 --- /dev/null +++ b/packages/mcp/src/resources/agent-guide.ts @@ -0,0 +1,67 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneBridge } from '../bridge/scene-bridge' + +export const AGENT_GUIDE = [ + '# Pascal MCP agent guide', + '', + 'Use this guide before inspecting application source code. The MCP surface is intended to expose the construction contract an agent needs for normal scene editing.', + '', + '## Fast visible-progress workflow', + '', + '1. Query `pascal://scene/current/summary` or `list_levels` to orient yourself.', + '2. Create visible massing first: `create_level` as needed, then `create_story_shell` once per story.', + '3. Add room semantics next: zones/rooms, interior walls, slabs, and ceilings. Prefer `create_room` for simple rooms and `apply_patch` only for exact multi-room partitions.', + '4. Add circulation and envelope details: `create_stair_between_levels`, then `add_door` and `add_window`.', + '5. Add `create_roof`, furniture with `furnish_room`/`place_item`, and exterior features such as fences, patios, driveways, lawns, and garden zones.', + '6. Run `validate_scene` and `verify_scene`; fix issues before handing off.', + '', + 'This sequence lets users see a recognizable building quickly instead of waiting for one large hidden planning pass.', + '', + '## Construction rules', + '', + '- Levels live under a Building.', + '- Walls, fences, zones, slabs, ceilings, roofs, and stairs live under a Level.', + '- Doors and windows live under their Wall. Use `add_door`/`add_window`; their `t` or `position` is 0..1 along the wall.', + '- Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling.', + '- For multi-story buildings, create separate level-owned exterior walls for each story. Do not make first-story walls taller to represent upper-story bearing walls.', + '- Use `create_story_shell` once per floor/story to avoid cross-level wall ownership mistakes.', + '- Use `create_stair_between_levels` for stairs. It creates a straight stair and one rectangular manual slab/ceiling opening while disabling automatic stair-opening mode, avoiding duplicate or irregular holes.', + '- Roofs are containers with roof segments and should be isolated on a dedicated roof level for solo/exploded level views. Use `create_roof`; by default it creates a roof level above the reference occupied level. Do not attach roofs directly to the top occupied floor unless explicitly requested.', + '- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.', + '', + '## Scene model facts exposed here so agents do not need repo inspection', + '', + '- X/Z are floor-plan axes and Y is vertical; dimensions are meters.', + '- A story wall height is normally 2.4-3.0m; wall thickness is normally 0.1-0.3m.', + '- Slab and ceiling holes are polygon arrays. Manual stair openings should have `holeMetadata` with source `manual` and a single rectangular polygon.', + '- Dedicated roof levels use metadata role `roof` and normally contain the roof only; the top occupied level keeps its own walls, rooms, slabs, and ceiling.', + '- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.', + '- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.', + '', + '## Tool preference', + '', + '- Prefer semantic tools first: `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_stair_between_levels`, `create_roof`, `furnish_room`, `place_item`.', + '- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.', +].join('\n') + +export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void { + server.registerResource( + 'agent-guide', + 'pascal://agent/guide', + { + title: 'Agent construction guide', + description: + 'MCP-first construction workflow, scene invariants, and tool preferences so agents do not need to inspect the Pascal codebase.', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: AGENT_GUIDE, + }, + ], + }), + ) +} diff --git a/packages/mcp/src/resources/catalog-items.ts b/packages/mcp/src/resources/catalog-items.ts index 6b7da031..ba697c36 100644 --- a/packages/mcp/src/resources/catalog-items.ts +++ b/packages/mcp/src/resources/catalog-items.ts @@ -1,13 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneBridge } from '../bridge/scene-bridge' +import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog' /** - * `pascal://catalog/items` — item catalog (if the host supplies one). + * `pascal://catalog/items` — small built-in item catalog for standalone MCP. * - * `@pascal-app/core` does NOT expose a runtime item catalog — that is the host - * app's responsibility. In headless / standalone MCP mode we therefore return - * a stable, machine-readable "unavailable" payload so agents can detect this - * and fall back to free-form item creation. + * The editor UI owns the full catalog. MCP intentionally keeps a dependency-free + * subset so headless agents can still place realistic furniture and fixtures. */ export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void { server.registerResource( @@ -16,14 +15,14 @@ export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): v { title: 'Item catalog', description: - 'Catalog of placeable items. Not available in core; the host app is expected to override this resource when it has a catalog.', + 'Dependency-free catalog subset of placeable items available in standalone MCP mode.', mimeType: 'application/json', }, async (uri) => { const payload = { - status: 'catalog_unavailable' as const, - items: [] as never[], - note: '@pascal-app/core does not ship a runtime item catalog; the host app is expected to provide one by overriding this resource.', + status: 'ok' as const, + items: MCP_CATALOG_ITEMS, + note: 'Standalone MCP catalog subset; host applications can still expose a larger catalog separately.', } return { contents: [ diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts index 583c5082..ac2436a9 100644 --- a/packages/mcp/src/resources/index.ts +++ b/packages/mcp/src/resources/index.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneBridge } from '../bridge/scene-bridge' +import { registerAgentGuide } from './agent-guide' import { registerCatalogItems } from './catalog-items' import { registerConstraints } from './constraints' import { registerSceneCurrent } from './scene-current' @@ -13,8 +14,10 @@ import { registerSceneSummary } from './scene-summary' * - `pascal://scene/current/summary` — text/markdown, human summary * - `pascal://catalog/items` — application/json, host-supplied catalog * - `pascal://constraints/{levelId}` — application/json, per-level constraints + * - `pascal://agent/guide` — text/markdown, MCP-first construction guide */ export function registerResources(server: McpServer, bridge: SceneBridge): void { + registerAgentGuide(server, bridge) registerSceneCurrent(server, bridge) registerSceneSummary(server, bridge) registerCatalogItems(server, bridge) diff --git a/packages/mcp/src/resources/resources.test.ts b/packages/mcp/src/resources/resources.test.ts index c2277572..cb18efd9 100644 --- a/packages/mcp/src/resources/resources.test.ts +++ b/packages/mcp/src/resources/resources.test.ts @@ -8,6 +8,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { WallNode, ZoneNode } from '@pascal-app/core/schema' import useScene from '@pascal-app/core/store' import { SceneBridge } from '../bridge/scene-bridge' +import { registerAgentGuide } from './agent-guide' import { registerCatalogItems } from './catalog-items' import { registerConstraints } from './constraints' import { registerSceneCurrent } from './scene-current' @@ -183,15 +184,16 @@ describe('pascal://scene/current/summary', () => { describe('pascal://catalog/items', () => { beforeEach(() => resetScene()) - test('returns catalog_unavailable payload', async () => { + test('returns built-in catalog subset', async () => { const pair = await spinUp(registerCatalogItems) try { const res = await pair.client.readResource({ uri: 'pascal://catalog/items' }) const content = res.contents[0] as { uri: string; mimeType?: string; text?: string } expect(content.mimeType).toBe('application/json') const parsed = JSON.parse(content.text ?? '{}') - expect(parsed.status).toBe('catalog_unavailable') - expect(parsed.items).toEqual([]) + expect(parsed.status).toBe('ok') + expect(parsed.items.length).toBeGreaterThan(0) + expect(parsed.items.map((item: { id: string }) => item.id)).toContain('sofa') expect(typeof parsed.note).toBe('string') } finally { await pair.close() @@ -199,6 +201,27 @@ describe('pascal://catalog/items', () => { }) }) +describe('pascal://agent/guide', () => { + beforeEach(() => resetScene()) + + test('returns MCP-first construction guidance', async () => { + const pair = await spinUp(registerAgentGuide) + try { + const res = await pair.client.readResource({ uri: 'pascal://agent/guide' }) + const content = res.contents[0] as { uri: string; mimeType?: string; text?: string } + expect(content.mimeType).toBe('text/markdown') + const text = content.text ?? '' + expect(text).toContain('create_story_shell') + expect(text).toContain('create_stair_between_levels') + expect(text).toContain('dedicated roof level') + expect(text).toContain('Do not make first-story walls taller') + expect(text).toContain('Run `validate_scene` and `verify_scene`') + } finally { + await pair.close() + } + }) +}) + describe('pascal://constraints/{levelId}', () => { beforeEach(() => resetScene()) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index a0d07f2c..49b3b3d0 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -4,6 +4,9 @@ import { registerPrompts } from './prompts' import { registerResources } from './resources' import { createSceneStore } from './storage' import type { + SceneEvent, + SceneEventAppendOptions, + SceneEventListOptions, SceneListOptions, SceneMeta, SceneMutateOptions, @@ -70,5 +73,19 @@ function createLazySceneStore(): SceneStore { const real = await resolve() return real.rename(id, newName, options) }, + async appendSceneEvent(options: SceneEventAppendOptions): Promise { + const real = await resolve() + if (!real.appendSceneEvent) { + throw new Error('scene_events_unavailable') + } + return real.appendSceneEvent(options) + }, + async listSceneEvents(id: string, options?: SceneEventListOptions): Promise { + const real = await resolve() + if (!real.listSceneEvents) { + throw new Error('scene_events_unavailable') + } + return real.listSceneEvents(id, options) + }, } } diff --git a/packages/mcp/src/storage/sqlite-scene-store.test.ts b/packages/mcp/src/storage/sqlite-scene-store.test.ts index cf75a797..c97c2011 100644 --- a/packages/mcp/src/storage/sqlite-scene-store.test.ts +++ b/packages/mcp/src/storage/sqlite-scene-store.test.ts @@ -226,6 +226,53 @@ describe('SqliteSceneStore', () => { } }) + test('appends and lists scene events in order', async () => { + const graph = makeGraph() + const meta = await store.save({ id: 'live', name: 'Live', graph }) + const first = await store.appendSceneEvent({ + sceneId: meta.id, + version: meta.version, + kind: 'save_scene', + graph, + }) + const updatedGraph = makeGraph({ + nodes: { + ...graph.nodes, + wall_new: { + object: 'node', + id: 'wall_new', + type: 'wall', + parentId: 'building_def', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [1, 0], + thickness: 0.1, + height: 2.5, + frontSide: 'unknown', + backSide: 'unknown', + }, + } as SceneGraph['nodes'], + }) + const second = await store.appendSceneEvent({ + sceneId: meta.id, + version: meta.version, + kind: 'create_wall', + graph: updatedGraph, + }) + + expect(second.eventId).toBeGreaterThan(first.eventId) + expect((await store.listSceneEvents('live')).map((event) => event.kind)).toEqual([ + 'save_scene', + 'create_wall', + ]) + const afterFirst = await store.listSceneEvents('live', { afterEventId: first.eventId }) + expect(afterFirst).toHaveLength(1) + expect(afterFirst[0]!.eventId).toBe(second.eventId) + expect(afterFirst[0]!.graph.nodes.wall_new).toBeDefined() + }) + test('validates name and scene size', async () => { await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError) await expect(store.save({ name: 'x'.repeat(201), graph: makeGraph() })).rejects.toThrow( diff --git a/packages/mcp/src/storage/sqlite-scene-store.ts b/packages/mcp/src/storage/sqlite-scene-store.ts index 01ec88b0..96a71958 100644 --- a/packages/mcp/src/storage/sqlite-scene-store.ts +++ b/packages/mcp/src/storage/sqlite-scene-store.ts @@ -6,6 +6,9 @@ import { z } from 'zod' import { generateSlug, isValidSlug, sanitizeSlug } from './slug' import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver' import { + type SceneEvent, + type SceneEventAppendOptions, + type SceneEventListOptions, SceneInvalidError, type SceneListOptions, type SceneMeta, @@ -46,6 +49,15 @@ interface SceneRow { graph_json: string } +interface SceneEventRow { + event_id: number + scene_id: string + version: number + kind: string + created_at: string + graph_json: string +} + const GraphSchema = z.object({ nodes: z.record(z.string(), z.unknown()), rootNodeIds: z.array(z.string()), @@ -170,6 +182,17 @@ function asSceneRow(value: unknown): SceneRow | null { return value as SceneRow } +function rowToSceneEvent(row: SceneEventRow): SceneEvent { + return { + eventId: Number(row.event_id), + sceneId: row.scene_id, + version: Number(row.version), + kind: row.kind, + createdAt: row.created_at, + graph: parseGraph(row.graph_json, `${row.scene_id}@${row.version}`), + } +} + /** * SQLite-backed implementation of `SceneStore`. * @@ -397,6 +420,54 @@ export class SqliteSceneStore implements SceneStore { }) } + async appendSceneEvent(opts: SceneEventAppendOptions): Promise { + return this.withWriteTransaction((db) => { + const safeId = sanitizeSlug(opts.sceneId) + const existing = this.getRow(db, safeId) + if (!existing) { + throw new SceneNotFoundError(`Scene "${safeId}" not found`) + } + + const graphJson = serializeGraph(opts.graph) + const now = new Date().toISOString() + const result = db + .query( + `INSERT INTO scene_events ( + scene_id, version, kind, created_at, graph_json + ) VALUES (?, ?, ?, ?, ?)`, + ) + .run(safeId, opts.version, opts.kind, now, graphJson) + + return { + eventId: Number(result.lastInsertRowid), + sceneId: safeId, + version: opts.version, + kind: opts.kind, + createdAt: now, + graph: opts.graph, + } + }) + } + + async listSceneEvents(sceneId: string, opts: SceneEventListOptions = {}): Promise { + const afterEventId = Math.max(0, opts.afterEventId ?? 0) + const requestedLimit = opts.limit ?? 100 + const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100 + const db = await this.database() + const rows = db + .query( + `SELECT event_id, scene_id, version, kind, created_at, graph_json + FROM scene_events + WHERE scene_id = ? + AND event_id > ? + ORDER BY event_id ASC + LIMIT ?`, + ) + .all(sanitizeSlug(sceneId), afterEventId, limit) + + return rows.map((row) => rowToSceneEvent(row as SceneEventRow)) + } + close(): void { this.db?.close() this.db = null @@ -452,6 +523,19 @@ export class SqliteSceneStore implements SceneStore { PRIMARY KEY (scene_id, version), FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS scene_events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + scene_id TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version >= 1), + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + graph_json TEXT NOT NULL, + FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS scene_events_scene_event_idx + ON scene_events(scene_id, event_id); `) } diff --git a/packages/mcp/src/storage/types.ts b/packages/mcp/src/storage/types.ts index 932b2032..6cab57a7 100644 --- a/packages/mcp/src/storage/types.ts +++ b/packages/mcp/src/storage/types.ts @@ -25,6 +25,15 @@ export interface SceneWithGraph extends SceneMeta { graph: SceneGraph } +export interface SceneEvent { + eventId: number + sceneId: SceneId + version: number + kind: string + createdAt: string + graph: SceneGraph +} + export interface SceneSaveOptions { id?: SceneId name: string @@ -46,6 +55,18 @@ export interface SceneMutateOptions { expectedVersion?: number } +export interface SceneEventAppendOptions { + sceneId: SceneId + version: number + kind: string + graph: SceneGraph +} + +export interface SceneEventListOptions { + afterEventId?: number + limit?: number +} + export interface SceneStore { readonly backend: 'sqlite' save(opts: SceneSaveOptions): Promise @@ -53,6 +74,8 @@ export interface SceneStore { list(opts?: SceneListOptions): Promise delete(id: SceneId, opts?: SceneMutateOptions): Promise rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise + appendSceneEvent?(opts: SceneEventAppendOptions): Promise + listSceneEvents?(sceneId: SceneId, opts?: SceneEventListOptions): Promise } export class SceneNotFoundError extends Error { diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts index f834224a..0e2c9082 100644 --- a/packages/mcp/src/tools/apply-patch.test.ts +++ b/packages/mcp/src/tools/apply-patch.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 } from '@pascal-app/core/schema' +import { LevelNode, SlabNode, StairNode, StairSegmentNode, WallNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' import { registerApplyPatch } from './apply-patch' @@ -45,6 +45,55 @@ describe('apply_patch', () => { expect((stored as { thickness?: number }).thickness).toBe(0.2) }) + test('syncs derived stair openings after stair patches', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Upper Floor', level: 1 }) + const upperSlab = SlabNode.parse({ + name: 'Upper Floor Slab', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + const segment = StairSegmentNode.parse({ + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + name: 'Main Stair', + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + openingOffset: 0.1, + children: [segment.id], + }) + + const result = await client.callTool({ + name: 'apply_patch', + arguments: { + patches: [ + { op: 'create', node: upper, parentId: building.id }, + { op: 'create', node: upperSlab, parentId: upper.id }, + { op: 'create', node: stair, parentId: ground.id }, + { op: 'create', node: segment, parentId: stair.id }, + ], + }, + }) + expect(result.isError).toBeFalsy() + const slab = bridge.getNode(upperSlab.id) + expect(slab?.type).toBe('slab') + if (slab?.type !== 'slab') return + expect(slab.holes).toHaveLength(1) + expect(slab.holeMetadata[0]).toEqual({ source: 'stair', stairId: stair.id }) + }) + test('rejects update to a non-existent node', async () => { const result = await client.callTool({ name: 'apply_patch', diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts index 76ac54c5..2b61f677 100644 --- a/packages/mcp/src/tools/apply-patch.ts +++ b/packages/mcp/src/tools/apply-patch.ts @@ -2,7 +2,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { PatchSchema } from './schemas' export const applyPatchInput = { @@ -15,7 +17,11 @@ export const applyPatchOutput = { createdIds: z.array(z.string()), } -export function registerApplyPatch(server: McpServer, bridge: SceneBridge): void { +export function registerApplyPatch( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'apply_patch', { @@ -50,6 +56,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneBridge): void try { const result = bridge.applyPatch(bridgePatches) + await publishLiveSceneSnapshot(bridge, store, 'apply_patch') const payload = { appliedOps: result.appliedOps, deletedIds: result.deletedIds as unknown as string[], diff --git a/packages/mcp/src/tools/asset-catalog.ts b/packages/mcp/src/tools/asset-catalog.ts new file mode 100644 index 00000000..e2cf3100 --- /dev/null +++ b/packages/mcp/src/tools/asset-catalog.ts @@ -0,0 +1,315 @@ +import type { AssetInput } from '@pascal-app/core/schema' + +/** + * Small built-in catalog for standalone/headless MCP use. + * + * The editor has a much larger UI catalog, but depending on `@pascal-app/editor` + * from the MCP package would pull browser/React code into the headless server. + * These entries mirror the stable IDs and asset paths used by the editor for + * common AI-generated residential layouts. + */ +export const MCP_CATALOG_ITEMS: AssetInput[] = [ + { + id: 'double-bed', + category: 'furniture', + tags: ['floor', 'bedroom'], + name: 'Double Bed', + thumbnail: '/items/double-bed/thumbnail.webp', + src: '/items/double-bed/model.glb', + dimensions: [2, 0.8, 2.5], + offset: [0, 0, -0.03], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'single-bed', + category: 'furniture', + tags: ['floor', 'bedroom'], + name: 'Single Bed', + thumbnail: '/items/single-bed/thumbnail.webp', + src: '/items/single-bed/model.glb', + dimensions: [1.5, 0.7, 2.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'bedside-table', + category: 'furniture', + tags: ['floor', 'bedroom'], + name: 'Bedside Table', + thumbnail: '/items/bedside-table/thumbnail.webp', + src: '/items/bedside-table/model.glb', + dimensions: [0.5, 0.5, 0.5], + offset: [0, 0, -0.01], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.5 }, + }, + { + id: 'dresser', + category: 'furniture', + tags: ['floor', 'storage', 'bedroom'], + name: 'Dresser', + thumbnail: '/items/dresser/thumbnail.webp', + src: '/items/dresser/model.glb', + dimensions: [1.5, 0.8, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.8 }, + }, + { + id: 'closet', + category: 'furniture', + tags: ['floor', 'storage', 'bedroom'], + name: 'Closet', + thumbnail: '/items/closet/thumbnail.webp', + src: '/items/closet/model.glb', + dimensions: [2, 2.5, 1], + offset: [0, 0, -0.01], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'sofa', + category: 'furniture', + tags: ['floor', 'seating', 'living'], + name: 'Sofa', + thumbnail: '/items/sofa/thumbnail.webp', + src: '/items/sofa/model.glb', + dimensions: [2.5, 0.8, 1.5], + offset: [0, 0, 0.04], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'livingroom-chair', + category: 'furniture', + tags: ['floor', 'seating', 'living'], + name: 'Livingroom Chair', + thumbnail: '/items/livingroom-chair/thumbnail.webp', + src: '/items/livingroom-chair/model.glb', + dimensions: [1.5, 0.8, 1.5], + offset: [0.01, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'coffee-table', + category: 'furniture', + tags: ['floor', 'table', 'living'], + name: 'Coffee Table', + thumbnail: '/items/coffee-table/thumbnail.webp', + src: '/items/coffee-table/model.glb', + dimensions: [2, 0.4, 1.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.3 }, + }, + { + id: 'tv-stand', + category: 'furniture', + tags: ['floor', 'storage', 'living'], + name: 'TV Stand', + thumbnail: '/items/tv-stand/thumbnail.webp', + src: '/items/tv-stand/model.glb', + dimensions: [2, 0.4, 0.5], + offset: [0, 0.21, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.36 }, + }, + { + id: 'shelf', + category: 'furniture', + tags: ['wall', 'storage'], + name: 'Shelf', + thumbnail: '/items/shelf/thumbnail.webp', + src: '/items/shelf/model.glb', + dimensions: [1, 0.5, 0.7], + offset: [0, 0.1, 0.01], + rotation: [0, 0, 0], + scale: [1, 1, 1], + attachTo: 'wall-side', + surface: { height: 0.12 }, + }, + { + id: 'dining-table', + category: 'furniture', + tags: ['floor', 'table', 'dining'], + name: 'Dining Table', + thumbnail: '/items/dining-table/thumbnail.webp', + src: '/items/dining-table/model.glb', + dimensions: [2.5, 0.8, 1], + offset: [0, 0, -0.01], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.8 }, + }, + { + id: 'dining-chair', + category: 'furniture', + tags: ['floor', 'seating', 'dining'], + name: 'Dining Chair', + thumbnail: '/items/dining-chair/thumbnail.webp', + src: '/items/dining-chair/model.glb', + dimensions: [0.5, 1, 0.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'kitchen', + category: 'kitchen', + tags: ['floor', 'large', 'kitchen'], + name: 'Kitchen', + thumbnail: '/items/kitchen/thumbnail.webp', + src: '/items/kitchen/model.glb', + dimensions: [2.5, 1.1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'kitchen-counter', + category: 'kitchen', + tags: ['floor', 'large', 'storage', 'kitchen'], + name: 'Kitchen Counter', + thumbnail: '/items/kitchen-counter/thumbnail.webp', + src: '/items/kitchen-counter/model.glb', + dimensions: [2, 0.8, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 0.75 }, + }, + { + id: 'stove', + category: 'kitchen', + tags: ['floor', 'large', 'kitchen'], + name: 'Stove', + thumbnail: '/items/stove/thumbnail.webp', + src: '/items/stove/model.glb', + dimensions: [1, 1, 1], + offset: [0, 0, -0.05], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'fridge', + category: 'kitchen', + tags: ['floor', 'large', 'kitchen'], + name: 'Fridge', + thumbnail: '/items/fridge/thumbnail.webp', + src: '/items/fridge/model.glb', + dimensions: [1, 2, 1], + offset: [0.01, 0, -0.05], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'toilet', + category: 'bathroom', + tags: ['floor', 'large', 'bathroom'], + name: 'Toilet', + thumbnail: '/items/toilet/thumbnail.webp', + src: '/items/toilet/model.glb', + dimensions: [1, 0.9, 1], + offset: [0, 0, -0.23], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'bathroom-sink', + category: 'bathroom', + tags: ['floor', 'large', 'bathroom'], + name: 'Bathroom Sink', + thumbnail: '/items/bathroom-sink/thumbnail.webp', + src: '/items/bathroom-sink/model.glb', + dimensions: [2, 1, 1.5], + offset: [0.11, 0, 0.02], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'shower-square', + category: 'bathroom', + tags: ['floor', 'large', 'bathroom'], + name: 'Squared Shower', + thumbnail: '/items/shower-square/thumbnail.webp', + src: '/items/shower-square/model.glb', + dimensions: [1, 2, 1], + offset: [0.41, 0, -0.42], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'bathtub', + category: 'bathroom', + tags: ['floor', 'large', 'bathroom'], + name: 'Bathtub', + thumbnail: '/items/bathtub/thumbnail.webp', + src: '/items/bathtub/model.glb', + dimensions: [2.5, 0.8, 1.5], + offset: [0, 0, 0.01], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'washing-machine', + category: 'bathroom', + tags: ['floor', 'large', 'electronics', 'laundry'], + name: 'Washing Machine', + thumbnail: '/items/washing-machine/thumbnail.webp', + src: '/items/washing-machine/model.glb', + dimensions: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'drying-rack', + category: 'bathroom', + tags: ['floor', 'laundry'], + name: 'Drying Rack', + thumbnail: '/items/drying-rack/thumbnail.webp', + src: '/items/drying-rack/model.glb', + dimensions: [2, 1.1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + { + id: 'coat-rack', + category: 'furniture', + tags: ['floor', 'storage', 'entry'], + name: 'Coat Rack', + thumbnail: '/items/coat-rack/thumbnail.webp', + src: '/items/coat-rack/model.glb', + dimensions: [0.5, 1.8, 0.5], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, +] + +export function findCatalogItem(id: string): AssetInput | undefined { + return MCP_CATALOG_ITEMS.find((item) => item.id === id) +} + +export function searchCatalogItems(args: { + query: string + category?: string | undefined +}): AssetInput[] { + const terms = args.query.trim().toLowerCase().split(/\s+/).filter(Boolean) + + return MCP_CATALOG_ITEMS.filter((item) => { + if (args.category && item.category !== args.category) return false + const haystack = [item.id, item.name, item.category, ...(item.tags ?? [])] + .join(' ') + .toLowerCase() + return terms.every((term) => haystack.includes(term)) + }) +} diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts new file mode 100644 index 00000000..05c3410f --- /dev/null +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -0,0 +1,208 @@ +import { afterEach, 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 { LevelNode } from '@pascal-app/core/schema' +import { SceneBridge } from '../bridge/scene-bridge' +import { registerSceneQueryTools } from './scene-query' +import { registerConstructionTools } from './construction-tools' + +describe('construction tools', () => { + let client: Client + let server: McpServer + let bridge: SceneBridge + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + server = new McpServer({ name: 'test', version: '0.0.0' }) + registerConstructionTools(server, bridge) + registerSceneQueryTools(server, bridge) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + }) + + afterEach(async () => { + await client.close() + await server.close() + }) + + test('create_story_shell creates level-owned walls plus slab and ceiling', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: level.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + wallHeight: 2.8, + namePrefix: 'Ground', + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.wallIds).toHaveLength(4) + expect(parsed.slabId).toMatch(/^slab_/) + expect(parsed.ceilingId).toMatch(/^ceiling_/) + + for (const wallId of parsed.wallIds) { + const wall = bridge.getNode(wallId) + expect(wall?.parentId).toBe(level.id) + expect(wall?.type).toBe('wall') + if (wall?.type === 'wall') expect(wall.height).toBe(2.8) + } + expect(bridge.validateScene().valid).toBe(true) + }) + + test('create_stair_between_levels creates one rectangular manual opening', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) + bridge.createNode(upper, building.id) + + for (const level of [ground, upper]) { + const result = await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: level.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + wallHeight: 2.8, + }, + }) + expect(result.isError).toBeFalsy() + } + + const result = await client.callTool({ + name: 'create_stair_between_levels', + arguments: { + fromLevelId: ground.id, + toLevelId: upper.id, + position: [0, 0, -1], + width: 1, + runLength: 3, + totalRise: 2.8, + openingOffset: 0.2, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.openingPolygon).toHaveLength(4) + + const stair = bridge.getNode(parsed.stairId) + expect(stair?.type).toBe('stair') + if (stair?.type === 'stair') expect(stair.slabOpeningMode).toBe('none') + + const destinationSlab = bridge.getNode(parsed.destinationSlabId) + expect(destinationSlab?.type).toBe('slab') + if (destinationSlab?.type === 'slab') { + expect(destinationSlab.holes).toHaveLength(1) + expect(destinationSlab.holes[0]).toHaveLength(4) + expect(destinationSlab.holeMetadata).toEqual([{ source: 'manual' }]) + } + + const sourceCeiling = bridge.getNode(parsed.sourceCeilingId) + expect(sourceCeiling?.type).toBe('ceiling') + if (sourceCeiling?.type === 'ceiling') { + expect(sourceCeiling.holes).toHaveLength(1) + expect(sourceCeiling.holes[0]).toHaveLength(4) + expect(sourceCeiling.holeMetadata).toEqual([{ source: 'manual' }]) + } + expect(bridge.validateScene().valid).toBe(true) + }) + + test('verify_scene flags suspicious multi-story wall heights', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) + bridge.createNode(upper, building.id) + + const shell = await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: ground.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + wallHeight: 5.6, + }, + }) + expect(shell.isError).toBeFalsy() + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toContain('multi-story exterior walls should be split') + }) + + test('create_roof creates a dedicated roof level by default', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_roof', + arguments: { levelId: level.id, width: 8, depth: 6, roofType: 'gable' }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const roofLevel = bridge.getNode(parsed.roofLevelId) + const roof = bridge.getNode(parsed.roofId) + const segment = bridge.getNode(parsed.roofSegmentId) + expect(parsed.createdRoofLevelId).toBe(parsed.roofLevelId) + expect(roofLevel?.parentId).toBe(building.id) + expect(roofLevel?.type).toBe('level') + if (roofLevel?.type === 'level') { + expect(roofLevel.level).toBe(level.type === 'level' ? level.level + 1 : 1) + expect(roofLevel.metadata).toMatchObject({ role: 'roof', referenceLevelId: level.id }) + } + expect(roof?.parentId).toBe(parsed.roofLevelId) + expect(roof?.type).toBe('roof') + expect(segment?.parentId).toBe(parsed.roofId) + expect(segment?.type).toBe('roof-segment') + expect(bridge.validateScene().valid).toBe(true) + }) + + test('verify_scene flags roofs mixed into occupied levels', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: level.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + }, + }) + + const roof = await client.callTool({ + name: 'create_roof', + arguments: { + levelId: level.id, + width: 8, + depth: 6, + useDedicatedRoofLevel: false, + }, + }) + expect(roof.isError).toBeFalsy() + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toContain('dedicated roof level') + }) +}) diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts new file mode 100644 index 00000000..8150c29c --- /dev/null +++ b/packages/mcp/src/tools/construction-tools.ts @@ -0,0 +1,498 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { + CeilingNode, + LevelNode, + RoofNode, + RoofSegmentNode, + SlabNode, + StairNode, + StairSegmentNode, + WallNode, +} from '@pascal-app/core/schema' +import { z } from 'zod' +import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' +import { publishLiveSceneSnapshot } from './live-sync' +import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' + +const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const +const RAILING_MODES = ['none', 'left', 'right', 'both'] as const + +export const createStoryShellInput = { + levelId: NodeIdSchema, + footprint: z.array(Vec2Schema).min(3), + wallHeight: z.number().positive().default(2.8), + wallThickness: z.number().positive().default(0.16), + createSlab: z.boolean().default(true), + createCeiling: z.boolean().default(true), + slabElevation: z.number().default(0.1), + ceilingHeight: z.number().positive().optional(), + namePrefix: z.string().optional(), + wallMaterialPreset: z.string().optional(), + slabMaterialPreset: z.string().optional(), + ceilingMaterialPreset: z.string().optional(), +} + +export const createStoryShellOutput = { + levelId: z.string(), + wallIds: z.array(z.string()), + slabId: z.string().nullable(), + ceilingId: z.string().nullable(), + createdIds: z.array(z.string()), +} + +export const createRoofInput = { + levelId: NodeIdSchema, + roofLevelId: NodeIdSchema.optional(), + useDedicatedRoofLevel: z.boolean().default(true), + roofLevelLabel: z.string().default('Roof'), + roofLevelElevation: z.number().optional(), + roofLevelHeight: z.number().positive().optional(), + center: Vec3Schema.optional(), + width: z.number().positive(), + depth: z.number().positive(), + roofType: z.enum(ROOF_TYPES).default('hip'), + roofHeight: z.number().positive().default(1.8), + wallHeight: z.number().min(0).default(0.35), + wallThickness: z.number().positive().default(0.16), + overhang: z.number().min(0).default(0.45), + materialPreset: z.string().optional(), + name: z.string().optional(), +} + +export const createRoofOutput = { + referenceLevelId: z.string(), + roofLevelId: z.string(), + createdRoofLevelId: z.string().nullable(), + roofId: z.string(), + roofSegmentId: z.string(), +} + +export const createStairBetweenLevelsInput = { + fromLevelId: NodeIdSchema, + toLevelId: NodeIdSchema, + position: Vec3Schema, + rotation: z.number().default(0), + width: z.number().positive().default(1), + runLength: z.number().positive().default(3), + totalRise: z.number().positive().default(2.8), + stepCount: z.number().int().positive().default(14), + railingMode: z.enum(RAILING_MODES).default('both'), + destinationSlabId: NodeIdSchema.optional(), + sourceCeilingId: NodeIdSchema.optional(), + createDestinationSlabOpening: z.boolean().default(true), + createSourceCeilingOpening: z.boolean().default(true), + openingWidth: z.number().positive().optional(), + openingLength: z.number().positive().optional(), + openingOffset: z.number().min(0).default(0.15), + openingCenter: Vec2Schema.optional(), + openingRotation: z.number().optional(), + materialPreset: z.string().optional(), + name: z.string().optional(), +} + +export const createStairBetweenLevelsOutput = { + stairId: z.string(), + stairSegmentId: z.string(), + destinationSlabId: z.string().nullable(), + sourceCeilingId: z.string().nullable(), + openingPolygon: z.array(Vec2Schema), +} + +function textResult>(payload: T) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } +} + +function assertNode(bridge: SceneBridge, id: string, type: AnyNode['type']): AnyNode { + const node = bridge.getNode(id as AnyNodeId) + if (!node) throw new Error(`${type} not found: ${id}`) + if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`) + return node +} + +function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId { + const building = bridge + .getAncestry(levelId as AnyNodeId) + .find((node) => node.type === 'building') + if (!building) { + throw new Error(`Building ancestor not found for level: ${levelId}`) + } + return building.id as AnyNodeId +} + +function isRoofLevel(level: AnyNode): boolean { + return ( + level.type === 'level' && + typeof level.metadata === 'object' && + level.metadata !== null && + 'role' in level.metadata && + level.metadata.role === 'roof' + ) +} + +function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLevel: AnyNode): number { + const existing = bridge + .getChildren(buildingId) + .filter((node): node is AnyNode & { type: 'level' } => node.type === 'level') + .map((level) => level.level) + const referenceIndex = referenceLevel.type === 'level' ? referenceLevel.level : 0 + const candidate = referenceIndex + 1 + return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate +} + +function nodesOnLevel(bridge: SceneBridge, levelId: string): AnyNode[] { + return Object.values(bridge.getNodes()).filter( + (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, + ) +} + +function firstNodeOnLevel( + bridge: SceneBridge, + levelId: string, + type: 'slab' | 'ceiling', +): AnyNode | null { + return nodesOnLevel(bridge, levelId).find((node) => node.type === type) ?? null +} + +function rotatePoint(x: number, z: number, rotation: number): [number, number] { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function rectangularOpening(args: { + position: [number, number, number] + rotation: number + width: number + length: number + offset: number + center?: [number, number] | undefined + openingRotation?: number | undefined +}): [number, number][] { + const width = args.width + args.offset * 2 + const length = args.length + args.offset * 2 + const center: [number, number] = args.center ?? [ + args.position[0], + args.position[2] + args.length / 2, + ] + const rotation = args.openingRotation ?? args.rotation + const halfW = width / 2 + const halfL = length / 2 + const local: [number, number][] = [ + [-halfW, -halfL], + [halfW, -halfL], + [halfW, halfL], + [-halfW, halfL], + ] + return local.map(([x, z]) => { + const [rx, rz] = rotatePoint(x, z, rotation) + return [center[0] + rx, center[1] + rz] + }) +} + +function withHole( + surface: AnyNode & { type: 'slab' | 'ceiling' }, + hole: [number, number][], +): Partial { + return { + holes: [...(surface.holes ?? []), hole], + holeMetadata: [...(surface.holeMetadata ?? []), { source: 'manual' }], + } as Partial +} + +export function registerConstructionTools( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + server.registerTool( + 'create_story_shell', + { + title: 'Create story shell', + description: + 'Create one level-owned building shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story; do not make first-floor walls span multiple stories.', + inputSchema: createStoryShellInput, + outputSchema: createStoryShellOutput, + }, + async ({ + levelId, + footprint, + wallHeight, + wallThickness, + createSlab, + createCeiling, + slabElevation, + ceilingHeight, + namePrefix, + wallMaterialPreset, + slabMaterialPreset, + ceilingMaterialPreset, + }) => { + assertNode(bridge, levelId, 'level') + const points = footprint as [number, number][] + const wallIds: string[] = [] + const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = [] + + for (let i = 0; i < points.length; i++) { + const wall = WallNode.parse({ + name: namePrefix ? `${namePrefix} Wall ${i + 1}` : undefined, + start: points[i], + end: points[(i + 1) % points.length], + thickness: wallThickness, + height: wallHeight, + frontSide: 'exterior', + backSide: 'interior', + ...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}), + metadata: { role: 'exterior', storyShell: true }, + }) + wallIds.push(wall.id) + patches.push({ op: 'create', node: wall, parentId: levelId as AnyNodeId }) + } + + let slabId: string | null = null + if (createSlab) { + const slab = SlabNode.parse({ + name: namePrefix ? `${namePrefix} Slab` : undefined, + polygon: points, + elevation: slabElevation, + ...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}), + metadata: { role: 'story-slab' }, + }) + slabId = slab.id + patches.push({ op: 'create', node: slab, parentId: levelId as AnyNodeId }) + } + + let ceilingId: string | null = null + if (createCeiling) { + const ceiling = CeilingNode.parse({ + name: namePrefix ? `${namePrefix} Ceiling` : undefined, + polygon: points, + height: ceilingHeight ?? wallHeight, + ...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}), + metadata: { role: 'story-ceiling' }, + }) + ceilingId = ceiling.id + patches.push({ op: 'create', node: ceiling, parentId: levelId as AnyNodeId }) + } + + const result = bridge.applyPatch(patches) + await publishLiveSceneSnapshot(bridge, store, 'create_story_shell') + return textResult({ + levelId, + wallIds, + slabId, + ceilingId, + createdIds: result.createdIds as string[], + }) + }, + ) + + server.registerTool( + 'create_roof', + { + title: 'Create roof', + description: + 'Create a roof container with one roof segment. By default creates a dedicated roof level above the reference level so exploded/solo level views can isolate the roof.', + inputSchema: createRoofInput, + outputSchema: createRoofOutput, + }, + async ({ + levelId, + roofLevelId, + useDedicatedRoofLevel, + roofLevelLabel, + roofLevelElevation, + roofLevelHeight, + center, + width, + depth, + roofType, + roofHeight, + wallHeight, + wallThickness, + overhang, + materialPreset, + name, + }) => { + const referenceLevel = assertNode(bridge, levelId, 'level') + const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = [] + let targetRoofLevelId = levelId as AnyNodeId + let createdRoofLevelId: string | null = null + + if (roofLevelId !== undefined) { + assertNode(bridge, roofLevelId, 'level') + targetRoofLevelId = roofLevelId as AnyNodeId + } else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) { + const buildingId = getBuildingIdForLevel(bridge, levelId) + const roofLevel = LevelNode.parse({ + name: roofLevelLabel, + level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel), + children: [], + metadata: { + role: 'roof', + label: roofLevelLabel, + referenceLevelId: levelId, + height: roofLevelHeight ?? Math.max(wallHeight + roofHeight, 0.2), + }, + }) + targetRoofLevelId = roofLevel.id as AnyNodeId + createdRoofLevelId = roofLevel.id + patches.push({ op: 'create', node: roofLevel, parentId: buildingId }) + } + + const segment = RoofSegmentNode.parse({ + roofType, + width, + depth, + wallHeight, + roofHeight, + wallThickness, + overhang, + ...(materialPreset ? { materialPreset } : {}), + }) + const roof = RoofNode.parse({ + name: name ?? 'Roof', + position: (center as [number, number, number] | undefined) ?? [0, 0, 0], + children: [segment.id], + ...(materialPreset ? { materialPreset } : {}), + metadata: { + referenceLevelId: levelId, + roofLevelId: targetRoofLevelId, + }, + }) + bridge.applyPatch([ + ...patches, + { op: 'create', node: roof, parentId: targetRoofLevelId }, + { op: 'create', node: segment, parentId: roof.id as AnyNodeId }, + ]) + await publishLiveSceneSnapshot(bridge, store, 'create_roof') + return textResult({ + referenceLevelId: levelId, + roofLevelId: targetRoofLevelId, + createdRoofLevelId, + roofId: roof.id, + roofSegmentId: segment.id, + }) + }, + ) + + server.registerTool( + 'create_stair_between_levels', + { + title: 'Create stair between levels', + description: + 'Create a straight stair and a single rectangular manual opening in the destination slab/source ceiling. This disables stair auto-opening mode to avoid duplicate or irregular holes.', + inputSchema: createStairBetweenLevelsInput, + outputSchema: createStairBetweenLevelsOutput, + }, + async ({ + fromLevelId, + toLevelId, + position, + rotation, + width, + runLength, + totalRise, + stepCount, + railingMode, + destinationSlabId, + sourceCeilingId, + createDestinationSlabOpening, + createSourceCeilingOpening, + openingWidth, + openingLength, + openingOffset, + openingCenter, + openingRotation, + materialPreset, + name, + }) => { + assertNode(bridge, fromLevelId, 'level') + assertNode(bridge, toLevelId, 'level') + + const segment = StairSegmentNode.parse({ + segmentType: 'stair', + width, + length: runLength, + height: totalRise, + stepCount, + ...(materialPreset ? { materialPreset } : {}), + }) + const stair = StairNode.parse({ + name: name ?? 'Stair', + position: position as [number, number, number], + rotation, + stairType: 'straight', + fromLevelId, + toLevelId, + slabOpeningMode: 'none', + openingOffset, + width, + totalRise, + stepCount, + railingMode, + children: [segment.id], + ...(materialPreset ? { materialPreset } : {}), + metadata: { + openingManaged: 'manual-rectangular', + }, + }) + + const openingPolygon = rectangularOpening({ + position: position as [number, number, number], + rotation, + width: openingWidth ?? width, + length: openingLength ?? runLength, + offset: openingOffset, + center: openingCenter as [number, number] | undefined, + openingRotation, + }) + + const patches: Array< + | { op: 'create'; node: AnyNode; parentId: AnyNodeId } + | { op: 'update'; id: AnyNodeId; data: Partial } + > = [ + { op: 'create', node: stair, parentId: fromLevelId as AnyNodeId }, + { op: 'create', node: segment, parentId: stair.id as AnyNodeId }, + ] + + const destinationSlab = + destinationSlabId !== undefined + ? assertNode(bridge, destinationSlabId, 'slab') + : firstNodeOnLevel(bridge, toLevelId, 'slab') + if (createDestinationSlabOpening && destinationSlab?.type === 'slab') { + patches.push({ + op: 'update', + id: destinationSlab.id as AnyNodeId, + data: withHole(destinationSlab, openingPolygon), + }) + } + + const sourceCeiling = + sourceCeilingId !== undefined + ? assertNode(bridge, sourceCeilingId, 'ceiling') + : firstNodeOnLevel(bridge, fromLevelId, 'ceiling') + if (createSourceCeilingOpening && sourceCeiling?.type === 'ceiling') { + patches.push({ + op: 'update', + id: sourceCeiling.id as AnyNodeId, + data: withHole(sourceCeiling, openingPolygon), + }) + } + + bridge.applyPatch(patches) + await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels') + return textResult({ + stairId: stair.id, + stairSegmentId: segment.id, + destinationSlabId: destinationSlab?.id ?? null, + sourceCeilingId: sourceCeiling?.id ?? null, + openingPolygon, + }) + }, + ) +} diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index c9722118..1b099054 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' export const createLevelInput = { @@ -17,7 +19,11 @@ export const createLevelOutput = { levelId: z.string(), } -export function registerCreateLevel(server: McpServer, bridge: SceneBridge): void { +export function registerCreateLevel( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'create_level', { @@ -51,6 +57,7 @@ export function registerCreateLevel(server: McpServer, bridge: SceneBridge): voi }) const id = bridge.createNode(levelNode, buildingId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'create_level') const payload = { levelId: id as string } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts index 23dee6b0..568af01b 100644 --- a/packages/mcp/src/tools/create-wall.test.ts +++ b/packages/mcp/src/tools/create-wall.test.ts @@ -2,7 +2,9 @@ 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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { SceneBridge } from '../bridge/scene-bridge' +import type { SceneMeta, SceneStore } from '../storage/types' import { registerCreateWall } from './create-wall' describe('create_wall', () => { @@ -39,6 +41,85 @@ describe('create_wall', () => { expect((created as { thickness?: number }).thickness).toBe(0.15) }) + test('publishes a live scene snapshot when bound to a saved scene', async () => { + const now = new Date().toISOString() + const savedMeta: SceneMeta = { + id: 'live-scene', + name: 'Live Scene', + projectId: null, + thumbnailUrl: null, + version: 1, + createdAt: now, + updatedAt: now, + ownerId: null, + sizeBytes: 0, + nodeCount: Object.keys(bridge.getNodes()).length, + } + const savedGraphs: SceneGraph[] = [] + const eventKinds: string[] = [] + const store: SceneStore = { + backend: 'sqlite', + async save(opts) { + expect(opts.id).toBe(savedMeta.id) + expect(opts.expectedVersion).toBe(1) + savedGraphs.push(opts.graph) + return { + ...savedMeta, + version: 2, + updatedAt: new Date().toISOString(), + sizeBytes: JSON.stringify(opts.graph).length, + nodeCount: Object.keys(opts.graph.nodes).length, + } + }, + async load() { + return null + }, + async list() { + return [] + }, + async delete() { + return false + }, + async rename() { + return savedMeta + }, + async appendSceneEvent(opts) { + eventKinds.push(opts.kind) + return { + eventId: 1, + sceneId: opts.sceneId, + version: opts.version, + kind: opts.kind, + createdAt: new Date().toISOString(), + graph: opts.graph, + } + }, + } + const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' }) + const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' }) + bridge.setActiveScene(savedMeta) + registerCreateWall(liveServer, bridge, store) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)]) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await liveClient.callTool({ + name: 'create_wall', + arguments: { + levelId: level.id, + start: [0, 1], + end: [4, 1], + }, + }) + + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(savedGraphs).toHaveLength(1) + expect(savedGraphs[0]!.nodes[parsed.wallId]).toBeDefined() + expect(eventKinds).toEqual(['create_wall']) + expect(bridge.getActiveScene()?.version).toBe(2) + }) + test('rejects unknown level id', async () => { const result = await client.callTool({ name: 'create_wall', diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index cecb2313..add33965 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { WallNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema } from './schemas' export const createWallInput = { @@ -18,7 +20,11 @@ export const createWallOutput = { wallId: z.string(), } -export function registerCreateWall(server: McpServer, bridge: SceneBridge): void { +export function registerCreateWall( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'create_wall', { @@ -47,6 +53,7 @@ export function registerCreateWall(server: McpServer, bridge: SceneBridge): void ...(height !== undefined ? { height } : {}), }) const id = bridge.createNode(wall, levelId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'create_wall') const payload = { wallId: id as string } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/cut-opening.test.ts b/packages/mcp/src/tools/cut-opening.test.ts index 7fbbd6b7..75521837 100644 --- a/packages/mcp/src/tools/cut-opening.test.ts +++ b/packages/mcp/src/tools/cut-opening.test.ts @@ -42,6 +42,7 @@ describe('cut_opening', () => { const created = bridge.getNode(parsed.openingId) expect((created as { wallId?: string }).wallId).toBe(wall.id) expect((created as { width: number }).width).toBe(0.9) + expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(2.5, 3) }) test('creates a window opening on a wall', async () => { @@ -61,6 +62,9 @@ describe('cut_opening', () => { }) const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) expect(parsed.openingId).toMatch(/^window_/) + const created = bridge.getNode(parsed.openingId) + expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(1.25, 3) + expect((created as { position: [number, number, number] }).position[1]).toBeCloseTo(1.5, 3) }) test('rejects unknown wall id', async () => { diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts index 85f73797..22f0cf02 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -3,7 +3,10 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { DoorNode, WindowNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { wallLength, wallLocalXFromT } from './geometry' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' export const cutOpeningInput = { @@ -18,7 +21,11 @@ export const cutOpeningOutput = { openingId: z.string(), } -export function registerCutOpening(server: McpServer, bridge: SceneBridge): void { +export function registerCutOpening( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'cut_opening', { @@ -37,19 +44,36 @@ export function registerCutOpening(server: McpServer, bridge: SceneBridge): void throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`) } - // wallT is stored on door/window children via position in the schema; - // the core systems look up wallId and derive placement from `position[0]` - // being on the wall-local axis. We set wallId explicitly so the runtime - // can associate the opening with its parent wall. + const length = wallLength(wall) + if (length < width) { + throwMcpError( + ErrorCode.InvalidParams, + `Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m opening`, + ) + } + + // `position` is public MCP ergonomics: 0..1 along the wall. Door/window + // nodes store wall-local meters in position[0], so convert before writing. const base = { wallId, width, height, - position: [position, height / 2, 0] as [number, number, number], + position: [wallLocalXFromT(wall, position, width), height / 2, 0] as [ + number, + number, + number, + ], } - const opening = type === 'door' ? DoorNode.parse(base) : WindowNode.parse(base) + const opening = + type === 'door' + ? DoorNode.parse(base) + : WindowNode.parse({ + ...base, + position: [base.position[0], 0.9 + height / 2, 0], + }) const id = bridge.createNode(opening, wallId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'cut_opening') const payload = { openingId: id as string } return { diff --git a/packages/mcp/src/tools/delete-node.ts b/packages/mcp/src/tools/delete-node.ts index 528ee643..ab7a9991 100644 --- a/packages/mcp/src/tools/delete-node.ts +++ b/packages/mcp/src/tools/delete-node.ts @@ -2,7 +2,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' export const deleteNodeInput = { @@ -14,7 +16,11 @@ export const deleteNodeOutput = { deletedIds: z.array(z.string()), } -export function registerDeleteNode(server: McpServer, bridge: SceneBridge): void { +export function registerDeleteNode( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'delete_node', { @@ -31,6 +37,7 @@ export function registerDeleteNode(server: McpServer, bridge: SceneBridge): void } try { const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false) + await publishLiveSceneSnapshot(bridge, store, 'delete_node') const payload = { deletedIds: removed } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/duplicate-level.ts b/packages/mcp/src/tools/duplicate-level.ts index d50878b0..11015909 100644 --- a/packages/mcp/src/tools/duplicate-level.ts +++ b/packages/mcp/src/tools/duplicate-level.ts @@ -3,7 +3,9 @@ import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' export const duplicateLevelInput = { @@ -15,7 +17,11 @@ export const duplicateLevelOutput = { newNodeIds: z.array(z.string()), } -export function registerDuplicateLevel(server: McpServer, bridge: SceneBridge): void { +export function registerDuplicateLevel( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'duplicate_level', { @@ -57,6 +63,7 @@ export function registerDuplicateLevel(server: McpServer, bridge: SceneBridge): }) const result = bridge.applyPatch(patches) + await publishLiveSceneSnapshot(bridge, store, 'duplicate_level') const payload = { newLevelId: newLevelId as string, diff --git a/packages/mcp/src/tools/geometry.ts b/packages/mcp/src/tools/geometry.ts new file mode 100644 index 00000000..948be6e8 --- /dev/null +++ b/packages/mcp/src/tools/geometry.ts @@ -0,0 +1,128 @@ +import type { WallNode } from '@pascal-app/core/schema' + +export type Vec2 = [number, number] +export type Vec3 = [number, number, number] + +export function distance2D(a: Vec2, b: Vec2): number { + const dx = b[0] - a[0] + const dz = b[1] - a[1] + return Math.sqrt(dx * dx + dz * dz) +} + +export function wallLength(wall: Pick): number { + return distance2D(wall.start, wall.end) +} + +export function clamp(value: number, min: number, max: number): number { + if (max < min) return (min + max) / 2 + return Math.max(min, Math.min(max, value)) +} + +export function wallLocalXFromT( + wall: Pick, + t: number, + width: number, +): number { + const length = wallLength(wall) + return clamp(t * length, width / 2, length - width / 2) +} + +export function projectWorldPointToWallLocalX( + wall: Pick, + position: Vec3, +): number { + const [sx, sz] = wall.start + const [ex, ez] = wall.end + const dx = ex - sx + const dz = ez - sz + const len = Math.sqrt(dx * dx + dz * dz) + if (len === 0) return 0 + const px = position[0] - sx + const pz = position[2] - sz + const distance = px * (dx / len) + pz * (dz / len) + return clamp(distance, 0, len) +} + +export function polygonArea(points: Vec2[]): number { + if (points.length < 3) return 0 + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return Math.abs(area) / 2 +} + +export function polygonBounds(points: Vec2[]): { + minX: number + maxX: number + minZ: number + maxZ: number + width: number + depth: number + centerX: number + centerZ: number +} { + const xs = points.map((p) => p[0]) + const zs = points.map((p) => p[1]) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + return { + minX, + maxX, + minZ, + maxZ, + width: maxX - minX, + depth: maxZ - minZ, + centerX: (minX + maxX) / 2, + centerZ: (minZ + maxZ) / 2, + } +} + +export function pointInBoundsWithPadding( + x: number, + z: number, + bounds: ReturnType, + padding: number, +): boolean { + return ( + x >= bounds.minX + padding && + x <= bounds.maxX - padding && + z >= bounds.minZ + padding && + z <= bounds.maxZ - padding + ) +} + +export function pointOnSegment(point: Vec2, a: Vec2, b: Vec2, tolerance = 1e-6): boolean { + const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1]) + if (Math.abs(cross) > tolerance) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -tolerance) return false + const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + return dot <= lenSq + tolerance +} + +export function pointInPolygon(point: Vec2, polygon: Vec2[], includeBoundary = true): boolean { + if (polygon.length < 3) return false + let inside = false + const [x, z] = point + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + if (pointOnSegment(point, a, b)) return includeBoundary + + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + + return inside +} + +export function polygonContainsPolygon(outer: Vec2[], inner: Vec2[]): boolean { + return inner.every((point) => pointInPolygon(point, outer, true)) +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 51aee46e..a04d3ea3 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -3,6 +3,7 @@ import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneStore } from '../storage/types' import { registerApplyPatch } from './apply-patch' import { registerCheckCollisions } from './check-collisions' +import { registerConstructionTools } from './construction-tools' import { registerCreateLevel } from './create-level' import { registerCreateWall } from './create-wall' import { registerCutOpening } from './cut-opening' @@ -18,7 +19,9 @@ import { registerMeasure } from './measure' import { registerPhotoToSceneTool } from './photo-to-scene' import { registerPlaceItem } from './place-item' import { registerRedo } from './redo' +import { registerRoomTools } from './room-tools' import { registerSceneLifecycleTools } from './scene-lifecycle' +import { registerSceneQueryTools } from './scene-query' import { registerSetZone } from './set-zone' import { registerTemplateTools } from './templates' import { registerUndo } from './undo' @@ -38,17 +41,20 @@ export function registerTools(server: McpServer, bridge: SceneBridge, store?: Sc registerGetNode(server, bridge) registerDescribeNode(server, bridge) registerFindNodes(server, bridge) + registerSceneQueryTools(server, bridge) registerMeasure(server, bridge) - registerApplyPatch(server, bridge) - registerCreateLevel(server, bridge) - registerCreateWall(server, bridge) - registerPlaceItem(server, bridge) - registerCutOpening(server, bridge) - registerSetZone(server, bridge) - registerDuplicateLevel(server, bridge) - registerDeleteNode(server, bridge) - registerUndo(server, bridge) - registerRedo(server, bridge) + registerConstructionTools(server, bridge, store) + registerRoomTools(server, bridge, store) + registerApplyPatch(server, bridge, store) + registerCreateLevel(server, bridge, store) + registerCreateWall(server, bridge, store) + registerPlaceItem(server, bridge, store) + registerCutOpening(server, bridge, store) + registerSetZone(server, bridge, store) + registerDuplicateLevel(server, bridge, store) + registerDeleteNode(server, bridge, store) + registerUndo(server, bridge, store) + registerRedo(server, bridge, store) registerExportJson(server, bridge) registerExportGlb(server, bridge) registerValidateScene(server, bridge) diff --git a/packages/mcp/src/tools/live-sync.ts b/packages/mcp/src/tools/live-sync.ts new file mode 100644 index 00000000..8574dc1b --- /dev/null +++ b/packages/mcp/src/tools/live-sync.ts @@ -0,0 +1,80 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings' +import type { SceneBridge } from '../bridge/scene-bridge' +import { type SceneStore, SceneVersionConflictError } from '../storage/types' +import { ErrorCode, throwMcpError } from './errors' + +export function syncDerivedStairOpenings(bridge: SceneBridge): number { + const updates = syncAutoStairOpenings(bridge.getNodes()) + if (updates.length === 0) return 0 + bridge.applyPatch( + updates.map((update) => ({ + op: 'update' as const, + id: update.id, + data: update.data, + })), + ) + return updates.length +} + +/** + * Persist the bridge's current graph to the active scene and append a live + * event for browser subscribers. No-ops when the MCP session is not currently + * bound to a saved scene. + */ +export async function publishLiveSceneSnapshot( + bridge: SceneBridge, + store: SceneStore | undefined, + kind: string, +): Promise { + syncDerivedStairOpenings(bridge) + + const active = bridge.getActiveScene() + if (!active || !store?.appendSceneEvent) return + + const exported = bridge.exportJSON() + const graph: SceneGraph = { + nodes: exported.nodes, + rootNodeIds: exported.rootNodeIds, + collections: exported.collections as SceneGraph['collections'], + } + + try { + const meta = await store.save({ + id: active.id, + name: active.name, + projectId: active.projectId, + ownerId: active.ownerId, + thumbnailUrl: active.thumbnailUrl, + graph, + expectedVersion: active.version, + }) + bridge.setActiveScene(meta) + await store.appendSceneEvent({ + sceneId: meta.id, + version: meta.version, + kind, + graph, + }) + } catch (error) { + if (error instanceof SceneVersionConflictError) { + throwMcpError(ErrorCode.InvalidRequest, 'live_sync_version_conflict', { + sceneId: active.id, + expectedVersion: active.version, + }) + } + const message = error instanceof Error ? error.message : String(error) + throwMcpError(ErrorCode.InternalError, `live_sync_failed: ${message}`) + } +} + +export async function appendLiveSceneEvent( + store: SceneStore, + sceneId: string, + version: number, + kind: string, + graph: SceneGraph, +): Promise { + if (!store.appendSceneEvent) return + await store.appendSceneEvent({ sceneId, version, kind, graph }) +} diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 8ee25c15..beda468d 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -13,6 +13,7 @@ import { import { z } from 'zod' import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneStore } from '../../storage/types' +import { appendLiveSceneEvent } from '../live-sync' /** * Input shape for the `photo_to_scene` orchestrator. `image` matches the @@ -378,6 +379,8 @@ export function registerPhotoToScene( name, graph, }) + bridge.setActiveScene(meta) + await appendLiveSceneEvent(store, meta.id, meta.version, 'photo_to_scene', graph) const payload: { sceneId: string url: string @@ -411,6 +414,7 @@ export function registerPhotoToScene( confidence: vision.confidence, graph, } + bridge.clearActiveScene() if (notes) payload.notes = notes return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/place-item.test.ts b/packages/mcp/src/tools/place-item.test.ts index bb79c65d..1d0c10c7 100644 --- a/packages/mcp/src/tools/place-item.test.ts +++ b/packages/mcp/src/tools/place-item.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 } from '@pascal-app/core/schema' +import { SlabNode, WallNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' import { registerPlaceItem } from './place-item' @@ -29,7 +29,7 @@ describe('place_item', () => { const result = await client.callTool({ name: 'place_item', arguments: { - catalogItemId: 'chair:basic', + catalogItemId: 'shelf', targetNodeId: wall.id, position: [5, 0, 0], }, @@ -37,20 +37,48 @@ describe('place_item', () => { expect(result.isError).toBeFalsy() const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) expect(parsed.itemId).toMatch(/^item_/) - expect(parsed.status).toBe('catalog_unavailable') + expect(parsed.status).toBe('ok') const item = bridge.getNode(parsed.itemId) expect(item).not.toBeNull() // Midpoint of a [0..10] wall at x=5 → wallT = 0.5. expect((item as { wallT?: number }).wallT).toBeCloseTo(0.5, 3) + expect((item as { position: [number, number, number] }).position[0]).toBeCloseTo(5, 3) }) - test('rejects placement on a level', async () => { + test('places a floor item through a slab target but parents it to the level for rendering', async () => { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const slab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + }) + bridge.createNode(slab, level.id) + + const result = await client.callTool({ + name: 'place_item', + arguments: { + catalogItemId: 'sofa', + targetNodeId: slab.id, + position: [2, 0, 2], + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const item = bridge.getNode(parsed.itemId) + expect(item?.parentId).toBe(level.id) + expect(bridge.validateScene().valid).toBe(true) + }) + + test('rejects placement on an unsupported node', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const result = await client.callTool({ name: 'place_item', arguments: { catalogItemId: 'foo', - targetNodeId: level.id, + targetNodeId: building.id, position: [0, 0, 0], }, }) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 735c8803..053fe614 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -3,7 +3,11 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' +import { findCatalogItem } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' +import { projectWorldPointToWallLocalX, wallLength } from './geometry' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec3Schema } from './schemas' export const placeItemInput = { @@ -18,31 +22,17 @@ export const placeItemOutput = { status: z.string().optional(), } -/** Compute wallT (0..1) from a 3D position projected onto the wall centreline. */ -function computeWallT( - start: [number, number], - end: [number, number], - position: [number, number, number], -): number { - const [sx, sz] = start - const [ex, ez] = end - const dx = ex - sx - const dz = ez - sz - const lenSq = dx * dx + dz * dz - if (lenSq === 0) return 0 - const px = position[0] - sx - const pz = position[2] - sz - const t = (px * dx + pz * dz) / lenSq - return Math.max(0, Math.min(1, t)) -} - -export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void { +export function registerPlaceItem( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { server.registerTool( 'place_item', { title: 'Place item', description: - 'Place a catalog item into the scene, attaching it to a wall, ceiling, or site. In headless mode the catalog is unavailable, so the asset payload is a placeholder — `status: "catalog_unavailable"` indicates this.', + 'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, a ceiling for ceiling-attached items, or the site for outdoor items.', inputSchema: placeItemInput, outputSchema: placeItemOutput, }, @@ -52,14 +42,22 @@ export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void throwMcpError(ErrorCode.InvalidParams, `Target node not found: ${targetNodeId}`) } const targetType = target.type - if (targetType !== 'wall' && targetType !== 'ceiling' && targetType !== 'site') { + if ( + targetType !== 'level' && + targetType !== 'slab' && + targetType !== 'zone' && + targetType !== 'wall' && + targetType !== 'ceiling' && + targetType !== 'site' + ) { throwMcpError( ErrorCode.InvalidRequest, - `Cannot place item on ${targetType}; target must be a wall, ceiling, or site`, + `Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling, or site`, ) } - const baseAsset = { + const catalogAsset = findCatalogItem(catalogItemId) + const baseAsset = catalogAsset ?? { id: catalogItemId, name: catalogItemId, category: 'unknown', @@ -71,28 +69,43 @@ export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void scale: [1, 1, 1] as [number, number, number], } - const wallExtras: { wallId: string; wallT: number } | Record = - targetType === 'wall' - ? { - wallId: targetNodeId, - wallT: computeWallT( - (target as { start: [number, number] }).start, - (target as { end: [number, number] }).end, - position as [number, number, number], - ), - } - : {} + const requestedPosition = position as [number, number, number] + const parentId = + targetType === 'slab' || targetType === 'zone' + ? bridge.resolveLevelId(targetNodeId as AnyNodeId) + : targetNodeId + + if (!parentId) { + throwMcpError( + ErrorCode.InvalidParams, + `Could not resolve a level parent for target ${targetNodeId}`, + ) + } + + const wallExtras: { wallId: string; wallT: number } | Record = {} + let itemPosition = requestedPosition + + if (targetType === 'wall') { + const localX = projectWorldPointToWallLocalX(target, requestedPosition) + const length = wallLength(target) + itemPosition = [localX, requestedPosition[1], 0] + Object.assign(wallExtras, { + wallId: targetNodeId, + wallT: length === 0 ? 0 : localX / length, + }) + } const item = ItemNode.parse({ - position: position as [number, number, number], + position: itemPosition, rotation: [0, rotation ?? 0, 0], asset: baseAsset, ...wallExtras, }) - const id = bridge.createNode(item, targetNodeId as AnyNodeId) + const id = bridge.createNode(item, parentId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'place_item') const payload = { itemId: id as string, - status: 'catalog_unavailable', + status: catalogAsset ? 'ok' : 'catalog_unavailable', } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/redo.ts b/packages/mcp/src/tools/redo.ts index 859486b2..a97aa3b6 100644 --- a/packages/mcp/src/tools/redo.ts +++ b/packages/mcp/src/tools/redo.ts @@ -1,6 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' +import { publishLiveSceneSnapshot } from './live-sync' export const redoInput = { steps: z.number().int().positive().optional(), @@ -10,7 +12,7 @@ export const redoOutput = { redone: z.number(), } -export function registerRedo(server: McpServer, bridge: SceneBridge): void { +export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { server.registerTool( 'redo', { @@ -22,6 +24,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge): void { }, async ({ steps }) => { const redone = bridge.redo(steps ?? 1) + if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo') const payload = { redone } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts new file mode 100644 index 00000000..be82b964 --- /dev/null +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -0,0 +1,189 @@ +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 { SceneBridge } from '../bridge/scene-bridge' +import { registerRoomTools } from './room-tools' + +describe('room tools', () => { + let client: Client + let bridge: SceneBridge + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerRoomTools(server, bridge) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + }) + + test('search_assets returns built-in catalog matches', async () => { + const result = await client.callTool({ + name: 'search_assets', + arguments: { query: 'sofa' }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.total).toBeGreaterThan(0) + expect(parsed.results.map((item: { id: string }) => item.id)).toContain('sofa') + }) + + test('create_room creates a valid zone/slab/ceiling/wall bundle', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bedroom', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.zoneId).toMatch(/^zone_/) + expect(parsed.slabId).toMatch(/^slab_/) + expect(parsed.ceilingId).toMatch(/^ceiling_/) + expect(parsed.wallIds).toHaveLength(4) + expect(parsed.areaSqMeters).toBe(12) + expect(bridge.validateScene().valid).toBe(true) + }) + + test('add_door and add_window convert t to wall-local meters', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Living', + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + const wallId = room.wallIds[0] + + const doorResult = await client.callTool({ + name: 'add_door', + arguments: { wallId, t: 0.5 }, + }) + const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text) + expect(door.localX).toBeCloseTo(2.5, 3) + expect( + (bridge.getNode(door.doorId) as { position: [number, number, number] }).position[0], + ).toBeCloseTo(2.5, 3) + + const windowResult = await client.callTool({ + name: 'add_window', + arguments: { wallId, t: 0.25, width: 1, height: 1, sillHeight: 1 }, + }) + const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text) + expect(win.localX).toBeCloseTo(1.25, 3) + expect( + (bridge.getNode(win.windowId) as { position: [number, number, number] }).position[1], + ).toBe(1.5) + }) + + test('add_door and add_window accept position as a t alias', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Entry', + polygon: [ + [0, 0], + [6, 0], + [6, 3], + [0, 3], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + const wallId = room.wallIds[0] + + const doorResult = await client.callTool({ + name: 'add_door', + arguments: { wallId, position: 0.25 }, + }) + const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text) + expect(door.localX).toBeCloseTo(1.5, 3) + + const windowResult = await client.callTool({ + name: 'add_window', + arguments: { wallId, position: 0.75, width: 1 }, + }) + const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text) + expect(win.localX).toBeCloseTo(4.5, 3) + expect(bridge.validateScene().valid).toBe(true) + }) + + test('furnish_room parents floor items to the level and keeps the scene valid', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const result = await client.callTool({ + name: 'furnish_room', + arguments: { + levelId: level.id, + roomType: 'bedroom', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + doorWallIndex: 0, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.placed).toBeGreaterThan(0) + for (const itemId of parsed.itemIds) { + expect(bridge.getNode(itemId)?.parentId).toBe(level.id) + } + expect(bridge.validateScene().valid).toBe(true) + }) + + test('furnish_room can infer level and polygon from zoneId', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bedroom', + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + }, + }) + const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text) + const result = await client.callTool({ + name: 'furnish_room', + arguments: { + zoneId: room.zoneId, + roomType: 'bedroom', + doorWallIndex: 0, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.placed).toBeGreaterThan(0) + for (const itemId of parsed.itemIds) { + expect(bridge.getNode(itemId)?.parentId).toBe(level.id) + } + expect(bridge.validateScene().valid).toBe(true) + }) +}) diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts new file mode 100644 index 00000000..65dd53c3 --- /dev/null +++ b/packages/mcp/src/tools/room-tools.ts @@ -0,0 +1,596 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { AnyNode, AnyNodeId, AssetInput } from '@pascal-app/core/schema' +import { + CeilingNode, + DoorNode, + ItemNode, + SlabNode, + WallNode, + WindowNode, + ZoneNode, +} from '@pascal-app/core/schema' +import { z } from 'zod' +import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' +import { findCatalogItem, searchCatalogItems } from './asset-catalog' +import { ErrorCode, throwMcpError } from './errors' +import { + pointInBoundsWithPadding, + polygonArea, + polygonBounds, + type Vec2, + wallLength, + wallLocalXFromT, +} from './geometry' +import { publishLiveSceneSnapshot } from './live-sync' +import { NodeIdSchema, Vec2Schema } from './schemas' + +const ROOM_TYPES = [ + 'bedroom', + 'kitchen', + 'bathroom', + 'living', + 'dining', + 'hallway', + 'entry', + 'laundry', + 'storage', +] as const + +export const searchAssetsInput = { + query: z.string().min(1), + category: z.string().optional(), +} + +export const searchAssetsOutput = { + results: z.array(z.record(z.string(), z.unknown())), + total: z.number(), +} + +export const createRoomInput = { + levelId: NodeIdSchema, + name: z.string().min(1), + polygon: z.array(Vec2Schema).min(3), + color: z.string().optional(), + wallHeight: z.number().positive().optional(), + wallThickness: z.number().positive().optional(), +} + +export const createRoomOutput = { + zoneId: z.string(), + slabId: z.string(), + ceilingId: z.string(), + wallIds: z.array(z.string()), + areaSqMeters: z.number(), +} + +export const addDoorInput = { + wallId: NodeIdSchema, + t: z.number().min(0).max(1).optional(), + position: z.number().min(0).max(1).optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + hingesSide: z.enum(['left', 'right']).optional(), + swingDirection: z.enum(['inward', 'outward']).optional(), +} + +export const addDoorOutput = { + doorId: z.string(), + localX: z.number(), +} + +export const addWindowInput = { + wallId: NodeIdSchema, + t: z.number().min(0).max(1).optional(), + position: z.number().min(0).max(1).optional(), + width: z.number().positive().optional(), + height: z.number().positive().optional(), + sillHeight: z.number().min(0).optional(), +} + +export const addWindowOutput = { + windowId: z.string(), + localX: z.number(), + sillHeight: z.number(), +} + +export const furnishRoomInput = { + levelId: NodeIdSchema.optional(), + zoneId: NodeIdSchema.optional(), + roomType: z.enum(ROOM_TYPES), + polygon: z.array(Vec2Schema).min(3).optional(), + doorWallIndex: z.number().int().min(0).optional(), +} + +export const furnishRoomOutput = { + placed: z.number(), + itemIds: z.array(z.string()), + skipped: z.array(z.string()), +} + +type Footprint = { minX: number; maxX: number; minZ: number; maxZ: number } +type Placement = { assetId: string; x: number; z: number; rotationDeg?: number } + +function textResult>(payload: T) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } +} + +function assertLevel(bridge: SceneBridge, levelId: string): AnyNode { + const level = bridge.getNode(levelId as AnyNodeId) + if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`) + if (level.type !== 'level') { + throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${level.type}, expected level`) + } + return level +} + +function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wall' } { + const wall = bridge.getNode(wallId as AnyNodeId) + if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`) + if (wall.type !== 'wall') { + throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`) + } + return wall +} + +function inferRoomGeometry( + bridge: SceneBridge, + levelId: string | undefined, + polygon: Vec2[] | undefined, + zoneId: string | undefined, +) { + if (levelId && polygon) return { levelId, polygon } + if (!zoneId) { + throwMcpError( + ErrorCode.InvalidParams, + 'Provide either levelId + polygon or zoneId so the room can be furnished', + ) + } + const zone = bridge.getNode(zoneId as AnyNodeId) + if (!zone) throwMcpError(ErrorCode.InvalidParams, `Zone not found: ${zoneId}`) + if (zone.type !== 'zone') { + throwMcpError(ErrorCode.InvalidParams, `Node ${zoneId} is a ${zone.type}, expected zone`) + } + const inferredLevelId = levelId ?? zone.parentId ?? undefined + if (!inferredLevelId) { + throwMcpError(ErrorCode.InvalidParams, `Zone ${zoneId} is missing a parent level`) + } + return { + levelId: inferredLevelId, + polygon: polygon ?? (zone.polygon as Vec2[]), + } +} + +function resolveWallT(toolName: string, t?: number, position?: number): number { + const resolved = t ?? position + if (resolved === undefined) { + throwMcpError(ErrorCode.InvalidParams, `${toolName} requires t or position in the 0..1 range`) + } + return resolved +} + +function makeItemAsset(asset: AssetInput) { + return { + id: asset.id, + name: asset.name, + category: asset.category, + thumbnail: asset.thumbnail ?? '', + src: asset.src, + dimensions: asset.dimensions ?? [1, 1, 1], + offset: asset.offset ?? [0, 0, 0], + rotation: asset.rotation ?? [0, 0, 0], + scale: asset.scale ?? [1, 1, 1], + ...(asset.attachTo ? { attachTo: asset.attachTo } : {}), + ...(asset.tags ? { tags: asset.tags } : {}), + ...(asset.surface ? { surface: asset.surface } : {}), + ...(asset.interactive ? { interactive: asset.interactive } : {}), + } +} + +function itemFootprint(asset: AssetInput, x: number, z: number, rotationDeg = 0): Footprint { + const [w = 1, , d = 1] = asset.dimensions ?? [1, 1, 1] + const rot = (rotationDeg * Math.PI) / 180 + const cos = Math.abs(Math.cos(rot)) + const sin = Math.abs(Math.sin(rot)) + const halfW = (w * cos + d * sin) / 2 + const halfD = (w * sin + d * cos) / 2 + return { minX: x - halfW, maxX: x + halfW, minZ: z - halfD, maxZ: z + halfD } +} + +function footprintsOverlap(a: Footprint, b: Footprint): boolean { + const gap = 0.08 + return ( + a.maxX - gap > b.minX && a.minX + gap < b.maxX && a.maxZ - gap > b.minZ && a.minZ + gap < b.maxZ + ) +} + +function buildRoomPlacements( + roomType: (typeof ROOM_TYPES)[number], + polygon: Vec2[], + doorWallIndex = 0, +) { + const bounds = polygonBounds(polygon) + const n = polygon.length + const backIdx = (doorWallIndex + Math.floor(n / 2)) % n + const backStart = polygon[backIdx]! + const backEnd = polygon[(backIdx + 1) % n]! + const backMidX = (backStart[0] + backEnd[0]) / 2 + const backMidZ = (backStart[1] + backEnd[1]) / 2 + const inwardX = bounds.centerX - backMidX + const inwardZ = bounds.centerZ - backMidZ + const inwardLen = Math.sqrt(inwardX * inwardX + inwardZ * inwardZ) || 1 + const inX = inwardX / inwardLen + const inZ = inwardZ / inwardLen + const facingRot = (Math.atan2(inX, inZ) * 180) / Math.PI + + const alongX = backEnd[0] - backStart[0] + const alongZ = backEnd[1] - backStart[1] + const alongLen = Math.sqrt(alongX * alongX + alongZ * alongZ) || 1 + const ax = alongX / alongLen + const az = alongZ / alongLen + + const backPos = (inset: number, lateral = 0): [number, number] => [ + backMidX + inX * inset + ax * lateral, + backMidZ + inZ * inset + az * lateral, + ] + + const sideIdx = (doorWallIndex + 1) % n + const sideStart = polygon[sideIdx]! + const sideEnd = polygon[(sideIdx + 1) % n]! + const sideMidX = (sideStart[0] + sideEnd[0]) / 2 + const sideMidZ = (sideStart[1] + sideEnd[1]) / 2 + const sideInX = bounds.centerX - sideMidX + const sideInZ = bounds.centerZ - sideMidZ + const sideInLen = Math.sqrt(sideInX * sideInX + sideInZ * sideInZ) || 1 + const snX = sideInX / sideInLen + const snZ = sideInZ / sideInLen + const sideRot = (Math.atan2(snX, snZ) * 180) / Math.PI + const sideAlongX = sideEnd[0] - sideStart[0] + const sideAlongZ = sideEnd[1] - sideStart[1] + const sideAlongLen = Math.sqrt(sideAlongX * sideAlongX + sideAlongZ * sideAlongZ) || 1 + const sax = sideAlongX / sideAlongLen + const saz = sideAlongZ / sideAlongLen + const sidePos = (inset: number, lateral = 0): [number, number] => [ + sideMidX + snX * inset + sax * lateral, + sideMidZ + snZ * inset + saz * lateral, + ] + + const placements: Placement[] = [] + const area = polygonArea(polygon) + + const addBack = (assetId: string, inset: number, lateral = 0, rotationDeg = facingRot) => { + const [x, z] = backPos(inset, lateral) + placements.push({ assetId, x, z, rotationDeg }) + } + const addSide = (assetId: string, inset: number, lateral = 0, rotationDeg = sideRot) => { + const [x, z] = sidePos(inset, lateral) + placements.push({ assetId, x, z, rotationDeg }) + } + + switch (roomType) { + case 'bedroom': { + const bedId = Math.max(bounds.width, bounds.depth) >= 3.2 ? 'double-bed' : 'single-bed' + const bed = findCatalogItem(bedId) + const [bedW = 2, , bedD = 2.5] = bed?.dimensions ?? [] + addBack(bedId, bedD / 2 + 0.1) + if (alongLen > bedW + 1.1) { + addBack('bedside-table', 0.35, -(bedW / 2 + 0.35)) + addBack('bedside-table', 0.35, bedW / 2 + 0.35) + } + if (area >= 10) addSide('dresser', 0.55, sideAlongLen * 0.22) + if (area >= 13) addSide('closet', 0.6, -sideAlongLen * 0.22) + break + } + case 'kitchen': + addBack(alongLen >= 2.6 ? 'kitchen' : 'kitchen-counter', 0.55) + if (alongLen >= 3.5) addBack('stove', 0.55, alongLen / 2 - 0.65) + addSide('fridge', 0.6, sideAlongLen * 0.25) + break + case 'bathroom': + addBack('toilet', 0.55, alongLen * 0.25) + addBack('bathroom-sink', 0.8, -alongLen * 0.2) + if (area >= 6.5) addSide('bathtub', 0.85) + else placements.push({ assetId: 'shower-square', x: bounds.centerX, z: bounds.centerZ }) + break + case 'living': { + addBack('sofa', 0.9) + addBack('coffee-table', 2.1) + addSide('livingroom-chair', 0.85, -sideAlongLen * 0.18) + const doorIdx = doorWallIndex % n + const doorStart = polygon[doorIdx]! + const doorEnd = polygon[(doorIdx + 1) % n]! + placements.push({ + assetId: 'tv-stand', + x: (doorStart[0] + doorEnd[0]) / 2 - inX * 0.35, + z: (doorStart[1] + doorEnd[1]) / 2 - inZ * 0.35, + rotationDeg: facingRot + 180, + }) + break + } + case 'dining': + placements.push({ assetId: 'dining-table', x: bounds.centerX, z: bounds.centerZ }) + placements.push({ assetId: 'dining-chair', x: bounds.centerX, z: bounds.centerZ - 0.85 }) + placements.push({ + assetId: 'dining-chair', + x: bounds.centerX, + z: bounds.centerZ + 0.85, + rotationDeg: 180, + }) + if (Math.min(bounds.width, bounds.depth) >= 3) { + placements.push({ + assetId: 'dining-chair', + x: bounds.centerX - 0.85, + z: bounds.centerZ, + rotationDeg: 270, + }) + placements.push({ + assetId: 'dining-chair', + x: bounds.centerX + 0.85, + z: bounds.centerZ, + rotationDeg: 90, + }) + } + break + case 'laundry': + addBack('washing-machine', 0.6, -0.55) + addBack('drying-rack', 0.65, 0.65) + break + case 'entry': + case 'hallway': + if (Math.min(bounds.width, bounds.depth) >= 1.4) addSide('coat-rack', 0.35) + break + case 'storage': + addBack('closet', 0.6) + break + } + + return { placements, bounds } +} + +export function registerSearchAssets(server: McpServer): void { + server.registerTool( + 'search_assets', + { + title: 'Search assets', + description: + 'Search the built-in MCP item catalog by keyword. Call before place_item when you need a valid catalogItemId.', + inputSchema: searchAssetsInput, + outputSchema: searchAssetsOutput, + }, + async ({ query, category }) => { + const results = searchCatalogItems({ query, category }).map((item) => ({ + id: item.id, + name: item.name, + category: item.category, + tags: item.tags ?? [], + dimensions: item.dimensions, + attachTo: item.attachTo ?? null, + })) + return textResult({ results, total: results.length }) + }, + ) +} + +export function registerCreateRoom( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + server.registerTool( + 'create_room', + { + title: 'Create room', + description: + 'Create a room on a level: zone, slab, ceiling, and one wall per polygon edge. Returns wallIds in polygon edge order.', + inputSchema: createRoomInput, + outputSchema: createRoomOutput, + }, + async ({ levelId, name, polygon, color, wallHeight, wallThickness }) => { + assertLevel(bridge, levelId) + const points = polygon as Vec2[] + const zone = ZoneNode.parse({ + name, + polygon: points, + color: color ?? '#60a5fa', + metadata: { mcpTool: 'create_room' }, + }) + const slab = SlabNode.parse({ polygon: points, metadata: { mcpTool: 'create_room' } }) + const ceiling = CeilingNode.parse({ polygon: points, metadata: { mcpTool: 'create_room' } }) + const walls = points.map((start, index) => + WallNode.parse({ + name: `${name} wall ${index + 1}`, + start, + end: points[(index + 1) % points.length], + ...(wallHeight !== undefined ? { height: wallHeight } : {}), + ...(wallThickness !== undefined ? { thickness: wallThickness } : {}), + metadata: { mcpTool: 'create_room', roomName: name, edgeIndex: index }, + }), + ) + + bridge.applyPatch([ + { op: 'create', node: zone, parentId: levelId as AnyNodeId }, + { op: 'create', node: slab, parentId: levelId as AnyNodeId }, + { op: 'create', node: ceiling, parentId: levelId as AnyNodeId }, + ...walls.map((wall) => ({ + op: 'create' as const, + node: wall, + parentId: levelId as AnyNodeId, + })), + ]) + await publishLiveSceneSnapshot(bridge, store, 'create_room') + + return textResult({ + zoneId: zone.id, + slabId: slab.id, + ceilingId: ceiling.id, + wallIds: walls.map((wall) => wall.id), + areaSqMeters: Math.round(polygonArea(points) * 100) / 100, + }) + }, + ) +} + +export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { + server.registerTool( + 'add_door', + { + title: 'Add door', + description: + 'Add a door to an existing wall. t/position is 0..1 along the wall: 0 = start, 0.5 = center, 1 = end.', + inputSchema: addDoorInput, + outputSchema: addDoorOutput, + }, + async ({ wallId, t, position, width = 0.9, height = 2.1, hingesSide, swingDirection }) => { + const wall = assertWall(bridge, wallId) + const length = wallLength(wall) + if (length < width) { + throwMcpError( + ErrorCode.InvalidParams, + `Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m door`, + ) + } + const wallT = resolveWallT('add_door', t, position) + const localX = wallLocalXFromT(wall, wallT, width) + const door = DoorNode.parse({ + wallId, + parentId: wallId, + position: [localX, height / 2, 0], + width, + height, + ...(hingesSide ? { hingesSide } : {}), + ...(swingDirection ? { swingDirection } : {}), + }) + const id = bridge.createNode(door, wallId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'add_door') + return textResult({ doorId: id, localX }) + }, + ) +} + +export function registerAddWindow( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + server.registerTool( + 'add_window', + { + title: 'Add window', + description: + 'Add a window to an existing wall. t/position is 0..1 along the wall; sillHeight is the height from floor to window bottom.', + inputSchema: addWindowInput, + outputSchema: addWindowOutput, + }, + async ({ wallId, t, position, width = 1.5, height = 1.5, sillHeight = 0.9 }) => { + const wall = assertWall(bridge, wallId) + const length = wallLength(wall) + if (length < width) { + throwMcpError( + ErrorCode.InvalidParams, + `Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m window`, + ) + } + const wallT = resolveWallT('add_window', t, position) + const localX = wallLocalXFromT(wall, wallT, width) + const windowNode = WindowNode.parse({ + wallId, + parentId: wallId, + position: [localX, sillHeight + height / 2, 0], + width, + height, + }) + const id = bridge.createNode(windowNode, wallId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'add_window') + return textResult({ windowId: id, localX, sillHeight }) + }, + ) +} + +export function registerFurnishRoom( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + server.registerTool( + 'furnish_room', + { + title: 'Furnish room', + description: + 'Place realistic furniture for a room type using levelId + polygon, or infer both from zoneId. Parent floor items to the level so they render and validate.', + inputSchema: furnishRoomInput, + outputSchema: furnishRoomOutput, + }, + async ({ levelId, zoneId, roomType, polygon, doorWallIndex }) => { + const room = inferRoomGeometry(bridge, levelId, polygon as Vec2[] | undefined, zoneId) + assertLevel(bridge, room.levelId) + const points = room.polygon + const { placements, bounds } = buildRoomPlacements(roomType, points, doorWallIndex ?? 0) + const footprints: Footprint[] = [] + const skipped: string[] = [] + const items: AnyNode[] = [] + + for (const placement of placements) { + const asset = findCatalogItem(placement.assetId) + if (!asset) { + skipped.push(`${placement.assetId}: asset not found`) + continue + } + const fp = itemFootprint(asset, placement.x, placement.z, placement.rotationDeg ?? 0) + const padding = 0.05 + if ( + !pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) || + !pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding) + ) { + skipped.push(`${asset.id}: outside room bounds`) + continue + } + if (footprints.some((existing) => footprintsOverlap(fp, existing))) { + skipped.push(`${asset.id}: overlaps another item`) + continue + } + footprints.push(fp) + items.push( + ItemNode.parse({ + name: asset.name, + position: [placement.x, 0, placement.z], + rotation: [0, ((placement.rotationDeg ?? 0) * Math.PI) / 180, 0], + asset: makeItemAsset(asset), + metadata: { mcpTool: 'furnish_room', roomType }, + }), + ) + } + + if (items.length > 0) { + bridge.applyPatch( + items.map((item) => ({ + op: 'create' as const, + node: item, + parentId: room.levelId as AnyNodeId, + })), + ) + await publishLiveSceneSnapshot(bridge, store, 'furnish_room') + } + + return textResult({ + placed: items.length, + itemIds: items.map((item) => item.id), + skipped, + }) + }, + ) +} + +export function registerRoomTools( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + registerSearchAssets(server) + registerCreateRoom(server, bridge, store) + registerAddDoor(server, bridge, store) + registerAddWindow(server, bridge, store) + registerFurnishRoom(server, bridge, store) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/load-scene.ts b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts index 0a14801b..0333bc91 100644 --- a/packages/mcp/src/tools/scene-lifecycle/load-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts @@ -38,6 +38,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: } try { bridge.loadJSON(result.graph) + bridge.setActiveScene(result) } catch (err) { const msg = err instanceof Error ? err.message : String(err) throwMcpError(ErrorCode.InvalidRequest, `load_failed: ${msg}`, { id }) diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts index 627bb541..345778ff 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import type { SceneBridge } from '../../bridge/scene-bridge' import { type SceneStore, SceneVersionConflictError } from '../../storage/types' import { ErrorCode, throwMcpError } from '../errors' +import { appendLiveSceneEvent } from '../live-sync' export const saveSceneInput = { id: z.string().min(1).max(64).optional(), @@ -103,6 +104,10 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: ...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}), ...(expectedVersion !== undefined ? { expectedVersion } : {}), }) + await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph) + if (includeCurrentScene) { + bridge.setActiveScene(meta) + } const payload = { id: meta.id, name: meta.name, diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts new file mode 100644 index 00000000..31a2660b --- /dev/null +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -0,0 +1,128 @@ +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 { + DoorNode, + LevelNode, + SlabNode, + StairNode, + StairSegmentNode, + WallNode, + ZoneNode, +} from '@pascal-app/core/schema' +import { SceneBridge } from '../bridge/scene-bridge' +import { registerSceneQueryTools } from './scene-query' + +describe('scene query tools', () => { + let client: Client + let bridge: SceneBridge + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerSceneQueryTools(server, bridge) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + }) + + test('list_levels returns level ids', async () => { + const result = await client.callTool({ name: 'list_levels', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.levels).toHaveLength(1) + expect(parsed.levels[0].id).toMatch(/^level_/) + }) + + test('get_level_summary includes walls, zones, and openings', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 0], end: [4, 0] }) + bridge.createNode(wall, level.id) + const door = DoorNode.parse({ wallId: wall.id, position: [2, 1.05, 0] }) + bridge.createNode(door, wall.id) + const zone = ZoneNode.parse({ + name: 'Room', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + bridge.createNode(zone, level.id) + + const result = await client.callTool({ + name: 'get_level_summary', + arguments: { levelId: level.id }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.counts.walls).toBe(1) + expect(parsed.counts.zones).toBe(1) + expect(parsed.counts.doors).toBe(1) + expect(parsed.walls[0].openings[0].id).toBe(door.id) + expect(parsed.zones[0].areaSqMeters).toBe(12) + }) + + test('verify_scene reports practical issues without replacing validate_scene', async () => { + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + bridge.createNode(WallNode.parse({ start: [0, 0], end: [4, 0] }), level.id) + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.ok).toBe(true) + expect(parsed.valid).toBe(true) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toContain('walls but no zones') + }) + + test('verify_scene reports stair wall obstructions and missing destination slab openings', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Upper Floor', level: 1 }) + bridge.createNode(upper, building.id) + const upperSlab = SlabNode.parse({ + name: 'Upper Floor Slab', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + bridge.createNode(upperSlab, upper.id) + bridge.createNode( + WallNode.parse({ name: 'Stair Blocker', start: [0, 2], end: [4, 2] }), + ground.id, + ) + + const segment = StairSegmentNode.parse({ + width: 1, + length: 2.6, + height: 2.5, + stepCount: 12, + }) + const stair = StairNode.parse({ + name: 'Main Stair', + position: [2, 0, 0.2], + stairType: 'straight', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + bridge.createNode(stair, ground.id) + bridge.createNode(segment, stair.id) + + const result = await client.callTool({ name: 'verify_scene', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + expect(parsed.hasIssues).toBe(true) + expect(parsed.issues.join('\n')).toContain('obstructs stair Main Stair') + expect(parsed.issues.join('\n')).toContain('no destination slab opening') + }) +}) diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts new file mode 100644 index 00000000..d74af63d --- /dev/null +++ b/packages/mcp/src/tools/scene-query.ts @@ -0,0 +1,655 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { z } from 'zod' +import type { SceneBridge } from '../bridge/scene-bridge' +import { + distance2D, + pointInPolygon, + polygonArea, + polygonContainsPolygon, + type Vec2, + wallLength, +} from './geometry' +import { NodeIdSchema } from './schemas' + +export const levelScopedInput = { + levelId: NodeIdSchema.optional(), +} + +const jsonObject = z.record(z.string(), z.unknown()) + +export const listLevelsOutput = { + activeSceneId: z.string().nullable(), + levels: z.array(jsonObject), +} + +export const getLevelSummaryOutput = { + levelId: z.string(), + levelName: z.string().optional(), + counts: jsonObject, + walls: z.array(jsonObject), + zones: z.array(jsonObject), + items: z.array(jsonObject), + slabs: z.array(jsonObject), + ceilings: z.array(jsonObject), +} + +export const getWallsOutput = { + levelId: z.string(), + walls: z.array(jsonObject), +} + +export const getZonesOutput = { + levelId: z.string(), + zones: z.array(jsonObject), +} + +export const verifySceneOutput = { + ok: z.boolean(), + valid: z.boolean(), + levelCount: z.number(), + activeSceneId: z.string().nullable(), + levels: z.array(jsonObject), + emptyLevelIds: z.array(z.string()), + issues: z.array(z.string()), + hasIssues: z.boolean(), +} + +function textResult>(payload: T) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } +} + +function getLevels(bridge: SceneBridge): AnyNode[] { + return bridge.findNodes({ type: 'level' }).sort((a, b) => { + const aa = a.type === 'level' ? a.level : 0 + const bb = b.type === 'level' ? b.level : 0 + return aa - bb + }) +} + +function getDefaultLevelId(bridge: SceneBridge, requested?: string | undefined): AnyNodeId | null { + if (requested) return requested as AnyNodeId + const level = getLevels(bridge)[0] + return (level?.id as AnyNodeId | undefined) ?? null +} + +function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] { + return Object.values(bridge.getNodes()).filter( + (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, + ) +} + +function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) { + return bridge + .getChildren(wallId) + .filter((child) => child.type === 'door' || child.type === 'window') + .map((child) => ({ + id: child.id, + type: child.type, + position: child.position, + width: child.width, + height: child.height, + })) +} + +function wallSummary(bridge: SceneBridge, wall: AnyNode) { + if (wall.type !== 'wall') return null + const length = distance2D(wall.start, wall.end) + return { + id: wall.id, + name: wall.name, + start: wall.start, + end: wall.end, + length: Math.round(length * 100) / 100, + height: wall.height, + thickness: wall.thickness, + openings: openingSummaries(bridge, wall.id as AnyNodeId), + } +} + +function zoneSummary(zone: AnyNode) { + if (zone.type !== 'zone') return null + const xs = zone.polygon.map((p) => p[0]) + const zs = zone.polygon.map((p) => p[1]) + return { + id: zone.id, + name: zone.name, + color: zone.color, + polygon: zone.polygon, + areaSqMeters: Math.round(polygonArea(zone.polygon) * 100) / 100, + bounds: { + width: Math.round((Math.max(...xs) - Math.min(...xs)) * 100) / 100, + depth: Math.round((Math.max(...zs) - Math.min(...zs)) * 100) / 100, + }, + } +} + +function itemSummary(item: AnyNode) { + if (item.type !== 'item') return null + return { + id: item.id, + name: item.name ?? item.asset.name, + parentId: item.parentId, + position: item.position, + rotation: item.rotation, + asset: { + id: item.asset.id, + name: item.asset.name, + category: item.asset.category, + dimensions: item.asset.dimensions, + attachTo: item.asset.attachTo ?? null, + }, + } +} + +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +type StairSegmentLike = { + width: number + length: number + height: number + stepCount: number + attachmentSide: 'front' | 'left' | 'right' +} + +function rotateXZ(x: number, z: number, angle: number): Vec2 { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function toWorldPlanPoint( + stair: AnyNode & { type: 'stair' }, + localX: number, + localZ: number, +): Vec2 { + const [worldX, worldZ] = rotateXZ(localX, localZ, stair.rotation ?? 0) + return [stair.position[0] + worldX, stair.position[2] + worldZ] +} + +function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransform[] { + const transforms: SegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRot = 0 + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] + if (!segment) continue + + if (index === 0) { + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + continue + } + + const previous = segments[index - 1] + if (!previous) continue + + let attachX = 0 + let attachZ = 0 + let rotationDelta = 0 + switch (segment.attachmentSide) { + case 'front': + attachZ = previous.length + break + case 'left': + attachX = previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = Math.PI / 2 + break + case 'right': + attachX = -previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = -Math.PI / 2 + break + } + + const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot) + currentX += deltaX + currentY += previous.height + currentZ += deltaZ + currentRot += rotationDelta + transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot }) + } + + return transforms +} + +function stairFootprintPolygons(bridge: SceneBridge, stair: AnyNode & { type: 'stair' }): Vec2[][] { + if (stair.stairType === 'curved' || stair.stairType === 'spiral') { + const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) + return [ + Array.from({ length: 24 }).map((_, index) => { + const angle = (index / 24) * Math.PI * 2 + return toWorldPlanPoint(stair, Math.cos(angle) * radius, Math.sin(angle) * radius) + }), + ] + } + + const nodes = bridge.getNodes() + const segments = (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is AnyNode & { type: 'stair-segment' } => node?.type === 'stair-segment') + const usableSegments: StairSegmentLike[] = + segments.length > 0 + ? segments + : [ + { + width: stair.width ?? 1, + length: 3, + height: stair.totalRise ?? 2.5, + stepCount: stair.stepCount ?? 10, + attachmentSide: 'front' as const, + }, + ] + const transforms = computeSegmentTransforms(usableSegments) + + return usableSegments.map((segment, index) => { + const transform = transforms[index] ?? { + position: [0, 0, 0] as [number, number, number], + rotation: 0, + } + const halfWidth = segment.width / 2 + const corners: Vec2[] = [ + [-halfWidth, 0], + [halfWidth, 0], + [halfWidth, segment.length], + [-halfWidth, segment.length], + ] + return corners.map(([localX, localZ]) => { + const [rx, rz] = rotateXZ(localX, localZ, transform.rotation) + return toWorldPlanPoint(stair, transform.position[0] + rx, transform.position[2] + rz) + }) + }) +} + +function wallSamplePoints(wall: AnyNode & { type: 'wall' }): Vec2[] { + return [0.25, 0.5, 0.75].map((t) => [ + wall.start[0] + (wall.end[0] - wall.start[0]) * t, + wall.start[1] + (wall.end[1] - wall.start[1]) * t, + ]) +} + +function getLevelNumber( + levelId: string | null | undefined, + nodes: Record, +): number | undefined { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function targetLevelIdsForStair( + bridge: SceneBridge, + stair: AnyNode & { type: 'stair' }, +): AnyNodeId[] { + const nodes = bridge.getNodes() + const parentLevelId = bridge.resolveLevelId(stair.id as AnyNodeId) + const fromLevelId = (stair.fromLevelId ?? parentLevelId) as string | null + const toLevelId = (stair.toLevelId ?? fromLevelId) as string | null + const fromLevel = getLevelNumber(fromLevelId, nodes) + const toLevel = getLevelNumber(toLevelId, nodes) + + if (fromLevel === undefined || toLevel === undefined) { + return toLevelId ? [toLevelId as AnyNodeId] : [] + } + + const minLevel = Math.min(fromLevel, toLevel) + const maxLevel = Math.max(fromLevel, toLevel) + return getLevels(bridge) + .filter((level) => level.type === 'level' && level.level > minLevel && level.level <= maxLevel) + .map((level) => level.id as AnyNodeId) +} + +function holeBelongsToStair( + surface: AnyNode & { type: 'slab' | 'ceiling' }, + holeIndex: number, + stairId: string, +) { + const metadata = surface.holeMetadata?.[holeIndex] + return metadata?.source === 'stair' && metadata.stairId === stairId +} + +function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) { + const level = bridge.getNode(levelId) + if (!level || level.type !== 'level') { + throw new Error(`Level not found: ${levelId}`) + } + const nodes = nodesOnLevel(bridge, levelId) + const walls = nodes + .map((n) => wallSummary(bridge, n)) + .filter((n): n is NonNullable => !!n) + const zones = nodes.map(zoneSummary).filter((n): n is NonNullable => !!n) + const items = nodes.map(itemSummary).filter((n): n is NonNullable => !!n) + const slabs = nodes + .filter((node) => node.type === 'slab') + .map((node) => ({ + id: node.id, + polygon: node.polygon, + holes: node.holes ?? [], + holeMetadata: node.holeMetadata ?? [], + elevation: node.elevation, + })) + const ceilings = nodes + .filter((node) => node.type === 'ceiling') + .map((node) => ({ + id: node.id, + polygon: node.polygon, + holes: node.holes ?? [], + holeMetadata: node.holeMetadata ?? [], + height: node.height, + })) + const doors = nodes.filter((node) => node.type === 'door') + const windows = nodes.filter((node) => node.type === 'window') + const roofs = nodes.filter((node) => node.type === 'roof') + const stairs = nodes.filter((node) => node.type === 'stair') + + return { + levelId, + levelName: level.name, + floorIndex: level.level, + counts: { + walls: walls.length, + zones: zones.length, + doors: doors.length, + windows: windows.length, + items: items.length, + slabs: slabs.length, + ceilings: ceilings.length, + roofs: roofs.length, + stairs: stairs.length, + }, + walls, + zones, + items, + slabs, + ceilings, + } +} + +export function registerListLevels(server: McpServer, bridge: SceneBridge): void { + server.registerTool( + 'list_levels', + { + title: 'List levels', + description: + 'List all levels in the current scene with ids, names, floor indices, and child counts.', + inputSchema: {}, + outputSchema: listLevelsOutput, + }, + async () => { + const activeScene = bridge.getActiveScene() + const levels = getLevels(bridge).map((level) => ({ + id: level.id, + name: level.name, + floorIndex: level.type === 'level' ? level.level : 0, + parentId: level.parentId, + childCount: bridge.getChildren(level.id as AnyNodeId).length, + })) + return textResult({ activeSceneId: activeScene?.id ?? null, levels }) + }, + ) +} + +export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): void { + server.registerTool( + 'get_level_summary', + { + title: 'Get level summary', + description: + 'Get a compact model-friendly summary of one level: counts plus walls, zones, slabs, ceilings, and items. Omit levelId to use the first level.', + inputSchema: levelScopedInput, + outputSchema: getLevelSummaryOutput, + }, + async ({ levelId }) => { + const resolved = getDefaultLevelId(bridge, levelId) + if (!resolved) throw new Error('No level exists in the scene') + return textResult(levelSummary(bridge, resolved)) + }, + ) +} + +export function registerGetWalls(server: McpServer, bridge: SceneBridge): void { + server.registerTool( + 'get_walls', + { + title: 'Get walls', + description: + 'Get walls on a level with start/end coordinates, length, height, thickness, and child doors/windows. Omit levelId to use the first level.', + inputSchema: levelScopedInput, + outputSchema: getWallsOutput, + }, + async ({ levelId }) => { + const resolved = getDefaultLevelId(bridge, levelId) + if (!resolved) throw new Error('No level exists in the scene') + return textResult({ + levelId: resolved, + walls: levelSummary(bridge, resolved).walls, + }) + }, + ) +} + +export function registerGetZones(server: McpServer, bridge: SceneBridge): void { + server.registerTool( + 'get_zones', + { + title: 'Get zones', + description: + 'Get room/zone polygons on a level with names, colors, bounds, and approximate areas. Omit levelId to use the first level.', + inputSchema: levelScopedInput, + outputSchema: getZonesOutput, + }, + async ({ levelId }) => { + const resolved = getDefaultLevelId(bridge, levelId) + if (!resolved) throw new Error('No level exists in the scene') + return textResult({ + levelId: resolved, + zones: levelSummary(bridge, resolved).zones, + }) + }, + ) +} + +export function registerVerifyScene(server: McpServer, bridge: SceneBridge): void { + server.registerTool( + 'verify_scene', + { + title: 'Verify scene', + description: + 'High-level self-check after complex edits. Returns validation status, per-level room/content counts, empty levels, and practical layout issues.', + inputSchema: {}, + outputSchema: verifySceneOutput, + }, + async () => { + const validation = bridge.validateScene() + const levels = getLevels(bridge).map((level) => { + const summary = levelSummary(bridge, level.id as AnyNodeId) + const totalContent = Object.values(summary.counts).reduce( + (sum, count) => sum + (typeof count === 'number' ? count : 0), + 0, + ) + return { + levelId: level.id, + levelName: level.name ?? `Level ${summary.floorIndex}`, + floorIndex: summary.floorIndex, + isEmpty: totalContent === 0, + content: summary.counts, + } + }) + + const issues: string[] = [] + const emptyLevelIds = levels.filter((level) => level.isEmpty).map((level) => level.levelId) + if (emptyLevelIds.length > 0) { + issues.push(`Empty level(s): ${emptyLevelIds.join(', ')}`) + } + + for (const level of levels) { + if (level.content.walls > 0 && level.content.zones === 0) { + issues.push(`${level.levelName} has walls but no zones/rooms`) + } + if (level.content.zones > 0 && level.content.slabs === 0) { + issues.push(`${level.levelName} has zones but no slabs/floors`) + } + if (level.content.zones > 0 && level.content.ceilings === 0) { + issues.push(`${level.levelName} has zones but no ceilings`) + } + if (level.content.walls > 0 && level.content.doors === 0) { + issues.push(`${level.levelName} has walls but no doors`) + } + if ( + level.content.roofs > 0 && + (level.content.walls > 0 || level.content.zones > 0 || level.content.stairs > 0) + ) { + issues.push( + `${level.levelName} mixes roof geometry with occupied-level content; place roofs on a dedicated roof level for solo/exploded level views`, + ) + } + } + + const hasMultipleLevels = levels.length > 1 + if (hasMultipleLevels) { + for (const level of getLevels(bridge)) { + if (level.type !== 'level') continue + const expectedHeight = + typeof level.metadata === 'object' && + level.metadata !== null && + 'height' in level.metadata && + typeof level.metadata.height === 'number' + ? level.metadata.height + : 3.2 + for (const wall of nodesOnLevel(bridge, level.id as AnyNodeId).filter( + (node): node is AnyNode & { type: 'wall' } => node.type === 'wall', + )) { + const wallHeight = wall.height ?? 2.5 + if (wallHeight > expectedHeight + 0.25) { + issues.push( + `Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`, + ) + } + } + } + } + + for (const node of Object.values(bridge.getNodes())) { + if (node.type === 'door' || node.type === 'window') { + const parent = node.parentId ? bridge.getNode(node.parentId as AnyNodeId) : null + if (!parent || parent.type !== 'wall') { + issues.push(`${node.type} ${node.id} is not parented to a wall`) + continue + } + const length = wallLength(parent) + const width = node.width ?? (node.type === 'door' ? 0.9 : 1.5) + const localX = node.position[0] + if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) { + issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`) + } + } + } + + for (const stair of Object.values(bridge.getNodes()).filter( + (node): node is AnyNode & { type: 'stair' } => node.type === 'stair', + )) { + const sourceLevelId = bridge.resolveLevelId(stair.id as AnyNodeId) + if (sourceLevelId) { + const sourceLevel = bridge.getNode(sourceLevelId) + const footprints = stairFootprintPolygons(bridge, stair) + const obstructingWalls = nodesOnLevel(bridge, sourceLevelId) + .filter((node): node is AnyNode & { type: 'wall' } => node.type === 'wall') + .filter((wall) => + footprints.some((footprint) => + wallSamplePoints(wall).some((point) => pointInPolygon(point, footprint, false)), + ), + ) + for (const wall of obstructingWalls) { + issues.push( + `Wall ${wall.name ?? wall.id} obstructs stair ${stair.name ?? stair.id} on ${ + sourceLevel?.name ?? sourceLevelId + }`, + ) + } + } + + if ((stair.slabOpeningMode ?? 'none') === 'destination') { + const targetLevelIds = targetLevelIdsForStair(bridge, stair) + if (targetLevelIds.length === 0) { + issues.push( + `Stair ${stair.name ?? stair.id} requests a slab opening but has no target level`, + ) + } + + for (const targetLevelId of targetLevelIds) { + const targetLevel = bridge.getNode(targetLevelId) + const targetSlabs = nodesOnLevel(bridge, targetLevelId).filter( + (node): node is AnyNode & { type: 'slab' } => node.type === 'slab', + ) + if (targetSlabs.length === 0) { + issues.push( + `Stair ${stair.name ?? stair.id} targets ${targetLevel?.name ?? targetLevelId} but it has no slab`, + ) + continue + } + + const matchingHoles = targetSlabs.flatMap((slab) => + (slab.holes ?? []) + .map((hole, index) => ({ slab, hole, index })) + .filter((entry) => holeBelongsToStair(entry.slab, entry.index, stair.id)), + ) + if (matchingHoles.length === 0) { + issues.push( + `Stair ${stair.name ?? stair.id} has no destination slab opening on ${ + targetLevel?.name ?? targetLevelId + }`, + ) + continue + } + + for (const { slab, hole } of matchingHoles) { + if (!polygonContainsPolygon(slab.polygon as Vec2[], hole as Vec2[])) { + issues.push( + `Stair ${stair.name ?? stair.id} opening extends outside slab ${slab.name ?? slab.id}`, + ) + } + } + } + } + } + + if (!validation.valid) { + for (const error of validation.errors.slice(0, 5)) { + issues.push(`Schema: ${error.nodeId}.${error.path} ${error.message}`) + } + if (validation.errors.length > 5) { + issues.push(`Schema: ${validation.errors.length - 5} additional validation errors`) + } + } + + const payload = { + ok: true, + valid: validation.valid, + levelCount: levels.length, + activeSceneId: bridge.getActiveScene()?.id ?? null, + levels, + emptyLevelIds, + issues, + hasIssues: issues.length > 0, + } + return textResult(payload) + }, + ) +} + +export function registerSceneQueryTools(server: McpServer, bridge: SceneBridge): void { + registerListLevels(server, bridge) + registerGetLevelSummary(server, bridge) + registerGetWalls(server, bridge) + registerGetZones(server, bridge) + registerVerifyScene(server, bridge) +} diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts index 64355713..06ca9c4b 100644 --- a/packages/mcp/src/tools/set-zone.ts +++ b/packages/mcp/src/tools/set-zone.ts @@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema' import { ZoneNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' +import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema } from './schemas' export const setZoneInput = { @@ -17,7 +19,7 @@ export const setZoneOutput = { zoneId: z.string(), } -export function registerSetZone(server: McpServer, bridge: SceneBridge): void { +export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { server.registerTool( 'set_zone', { @@ -45,6 +47,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge): void { metadata: properties ?? {}, }) const id = bridge.createNode(zone, levelId as AnyNodeId) + await publishLiveSceneSnapshot(bridge, store, 'set_zone') const payload = { zoneId: id as string } return { diff --git a/packages/mcp/src/tools/templates/create-from-template.ts b/packages/mcp/src/tools/templates/create-from-template.ts index 22f2941e..f844e666 100644 --- a/packages/mcp/src/tools/templates/create-from-template.ts +++ b/packages/mcp/src/tools/templates/create-from-template.ts @@ -7,6 +7,7 @@ import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children' import type { SceneStore } from '../../storage/types' import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates' import { ErrorCode, throwMcpError } from '../errors' +import { appendLiveSceneEvent } from '../live-sync' export const createFromTemplateInput = { id: z @@ -104,6 +105,7 @@ export function registerCreateFromTemplate( } if (!save) { + bridge.clearActiveScene() return { content: [{ type: 'text' as const, text: JSON.stringify(basePayload) }], structuredContent: basePayload, @@ -114,10 +116,11 @@ export function registerCreateFromTemplate( // Graceful no-store mode: report that save was skipped rather than // erroring — this makes the tool usable in headless bridge-only // deployments (tests, smoke scripts) without crashing. + bridge.clearActiveScene() const payload = { ...basePayload, saveSkipped: true } as const return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], - structuredContent: basePayload, + structuredContent: payload, } } @@ -127,6 +130,11 @@ export function registerCreateFromTemplate( ...(projectId !== undefined ? { projectId } : {}), graph: { nodes, rootNodeIds }, }) + bridge.setActiveScene(meta) + await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', { + nodes, + rootNodeIds, + }) const scene = { id: meta.id, name: meta.name, diff --git a/packages/mcp/src/tools/undo.ts b/packages/mcp/src/tools/undo.ts index bb195886..37005021 100644 --- a/packages/mcp/src/tools/undo.ts +++ b/packages/mcp/src/tools/undo.ts @@ -1,6 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' +import { publishLiveSceneSnapshot } from './live-sync' export const undoInput = { steps: z.number().int().positive().optional(), @@ -10,7 +12,7 @@ export const undoOutput = { undone: z.number(), } -export function registerUndo(server: McpServer, bridge: SceneBridge): void { +export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { server.registerTool( 'undo', { @@ -22,6 +24,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge): void { }, async ({ steps }) => { const undone = bridge.undo(steps ?? 1) + if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo') const payload = { undone } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }],