feat(mcp): add guided construction workflows
This commit is contained in:
@@ -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<typeof setTimeout> | undefined
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const suppressRemoteSaveUntilRef = useRef(0)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(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<string>).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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, AnyNode>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, AnyNod
|
||||
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
|
||||
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<string, AnyNode>): StraightStairLayout[] {
|
||||
function getStraightStairLayouts(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): StraightStairLayout[] {
|
||||
const segments = resolveStraightSegments(stair, nodes)
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
|
||||
@@ -204,7 +218,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode
|
||||
})
|
||||
}
|
||||
|
||||
function getStraightSegmentFootprintPolygon(stair: StairNode, layout: StraightStairLayout): Point2D[] {
|
||||
function getStraightSegmentFootprintPolygon(
|
||||
stair: StairNode,
|
||||
layout: StraightStairLayout,
|
||||
): Point2D[] {
|
||||
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
|
||||
}
|
||||
|
||||
@@ -242,11 +259,16 @@ function getStraightSegmentSlicePolygon(
|
||||
startAlong: number,
|
||||
endAlong: number,
|
||||
): Point2D[] {
|
||||
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => 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<string>()
|
||||
@@ -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<string, AnyNode>) {
|
||||
function shouldApplyStairToSlab(
|
||||
stair: StairNode,
|
||||
slabLevelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
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<string, AnyNode>) {
|
||||
function shouldApplyStairToCeiling(
|
||||
stair: StairNode,
|
||||
ceilingLevelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
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<string, AnyNode>) {
|
||||
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<SlabNode | CeilingNode> }> = []
|
||||
|
||||
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<string, AnyNode>) {
|
||||
},
|
||||
})),
|
||||
)
|
||||
.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<string, AnyNode>) {
|
||||
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<string, AnyNode>) {
|
||||
},
|
||||
})),
|
||||
)
|
||||
.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: {
|
||||
|
||||
+40
-3
@@ -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.
|
||||
|
||||
@@ -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<AnyNode> }
|
||||
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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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<SceneEvent> {
|
||||
const real = await resolve()
|
||||
if (!real.appendSceneEvent) {
|
||||
throw new Error('scene_events_unavailable')
|
||||
}
|
||||
return real.appendSceneEvent(options)
|
||||
},
|
||||
async listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]> {
|
||||
const real = await resolve()
|
||||
if (!real.listSceneEvents) {
|
||||
throw new Error('scene_events_unavailable')
|
||||
}
|
||||
return real.listSceneEvents(id, options)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<SceneEvent> {
|
||||
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<SceneEvent[]> {
|
||||
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);
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SceneMeta>
|
||||
@@ -53,6 +74,8 @@ export interface SceneStore {
|
||||
list(opts?: SceneListOptions): Promise<SceneMeta[]>
|
||||
delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean>
|
||||
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
|
||||
appendSceneEvent?(opts: SceneEventAppendOptions): Promise<SceneEvent>
|
||||
listSceneEvents?(sceneId: SceneId, opts?: SceneEventListOptions): Promise<SceneEvent[]>
|
||||
}
|
||||
|
||||
export class SceneNotFoundError extends Error {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<T extends Record<string, unknown>>(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<AnyNode> {
|
||||
return {
|
||||
holes: [...(surface.holes ?? []), hole],
|
||||
holeMetadata: [...(surface.holeMetadata ?? []), { source: 'manual' }],
|
||||
} as Partial<AnyNode>
|
||||
}
|
||||
|
||||
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<AnyNode> }
|
||||
> = [
|
||||
{ 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,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WallNode, 'start' | 'end'>): 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<WallNode, 'start' | 'end'>,
|
||||
t: number,
|
||||
width: number,
|
||||
): number {
|
||||
const length = wallLength(wall)
|
||||
return clamp(t * length, width / 2, length - width / 2)
|
||||
}
|
||||
|
||||
export function projectWorldPointToWallLocalX(
|
||||
wall: Pick<WallNode, 'start' | 'end'>,
|
||||
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<typeof polygonBounds>,
|
||||
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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (!store.appendSceneEvent) return
|
||||
await store.appendSceneEvent({ sceneId, version, kind, graph })
|
||||
}
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<string, never> =
|
||||
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<string, never> = {}
|
||||
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) }],
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<T extends Record<string, unknown>>(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)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<T extends Record<string, unknown>>(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<string, AnyNode>,
|
||||
): 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<typeof n> => !!n)
|
||||
const zones = nodes.map(zoneSummary).filter((n): n is NonNullable<typeof n> => !!n)
|
||||
const items = nodes.map(itemSummary).filter((n): n is NonNullable<typeof n> => !!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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
Reference in New Issue
Block a user