feat(mcp): add guided construction workflows

This commit is contained in:
Aymeric Rabot
2026-04-27 14:31:04 -04:00
parent b3d1f663f6
commit 3d5c87a651
48 changed files with 3877 additions and 115 deletions
+50 -1
View File
@@ -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',
+8 -1
View File
@@ -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[],
+315
View File
@@ -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,
})
},
)
}
+8 -1
View File
@@ -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',
+8 -1
View File
@@ -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 () => {
+31 -7
View File
@@ -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 {
+8 -1
View File
@@ -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) }],
+8 -1
View File
@@ -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,
+128
View File
@@ -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))
}
+16 -10
View File
@@ -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)
+80
View File
@@ -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) }],
+33 -5
View File
@@ -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],
},
})
+50 -37
View File
@@ -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) }],
+4 -1
View File
@@ -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) }],
+189
View File
@@ -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)
})
})
+596
View File
@@ -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,
+128
View File
@@ -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')
})
})
+655
View File
@@ -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)
}
+4 -1
View File
@@ -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,
+4 -1
View File
@@ -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) }],