fix(mcp): distinguish roof support levels from stories
This commit is contained in:
@@ -7,6 +7,7 @@ export const SCENE_DESIGN_GUIDANCE = [
|
|||||||
' - For clear concrete requests, act with reasonable defaults instead of asking for clarification.',
|
' - For clear concrete requests, act with reasonable defaults instead of asking for clarification.',
|
||||||
' - For full homes/apartments, include realistic support spaces: kitchen, living/dining, bathrooms, hallway/entry, storage/laundry where appropriate.',
|
' - For full homes/apartments, include realistic support spaces: kitchen, living/dining, bathrooms, hallway/entry, storage/laundry where appropriate.',
|
||||||
' - For multi-story buildings, create separate level-owned story shells. Do not stretch first-floor exterior walls to cover upper floors.',
|
' - For multi-story buildings, create separate level-owned story shells. Do not stretch first-floor exterior walls to cover upper floors.',
|
||||||
|
' - Treat requested story count as occupied stories, not raw level count; dedicated roof/support levels are allowed and must not be deleted just to match a story count.',
|
||||||
'',
|
'',
|
||||||
'Preferred phased tool workflow:',
|
'Preferred phased tool workflow:',
|
||||||
' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.',
|
' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.',
|
||||||
@@ -14,6 +15,7 @@ export const SCENE_DESIGN_GUIDANCE = [
|
|||||||
' - For rooms, prefer create_room, then add_door/add_window, then furnish_room.',
|
' - For rooms, prefer create_room, then add_door/add_window, then furnish_room.',
|
||||||
' - For stairs between floors, prefer create_stair_between_levels so slab/ceiling openings stay rectangular and do not duplicate auto-generated holes.',
|
' - For stairs between floors, prefer create_stair_between_levels so slab/ceiling openings stay rectangular and do not duplicate auto-generated holes.',
|
||||||
' - For roofs, prefer create_roof and let it create/use a dedicated roof level above the top occupied story so solo/exploded level views can isolate the roof.',
|
' - For roofs, prefer create_roof and let it create/use a dedicated roof level above the top occupied story so solo/exploded level views can isolate the roof.',
|
||||||
|
' - verify_scene reports both levelCount and occupiedStoryCount. Use occupiedStoryCount when checking whether a one-story/two-story brief was satisfied.',
|
||||||
' - add_door/add_window use t = 0..1 along a wall: 0 is start, 0.5 is center, 1 is end.',
|
' - add_door/add_window use t = 0..1 along a wall: 0 is start, 0.5 is center, 1 is end.',
|
||||||
' - Use search_assets before place_item when placing a specific catalog item.',
|
' - Use search_assets before place_item when placing a specific catalog item.',
|
||||||
' - Use apply_patch for precise bulk edits that the semantic tools cannot express.',
|
' - Use apply_patch for precise bulk edits that the semantic tools cannot express.',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const AGENT_GUIDE = [
|
|||||||
'- Use `create_story_shell` once per floor/story to avoid cross-level wall ownership mistakes.',
|
'- Use `create_story_shell` once per floor/story to avoid cross-level wall ownership mistakes.',
|
||||||
'- Use `create_stair_between_levels` for stairs. It creates a straight stair and one rectangular manual slab/ceiling opening while disabling automatic stair-opening mode, avoiding duplicate or irregular holes.',
|
'- Use `create_stair_between_levels` for stairs. It creates a straight stair and one rectangular manual slab/ceiling opening while disabling automatic stair-opening mode, avoiding duplicate or irregular holes.',
|
||||||
'- Roofs are containers with roof segments and should be isolated on a dedicated roof level for solo/exploded level views. Use `create_roof`; by default it creates a roof level above the reference occupied level. Do not attach roofs directly to the top occupied floor unless explicitly requested.',
|
'- Roofs are containers with roof segments and should be isolated on a dedicated roof level for solo/exploded level views. Use `create_roof`; by default it creates a roof level above the reference occupied level. Do not attach roofs directly to the top occupied floor unless explicitly requested.',
|
||||||
|
'- Story count means occupied stories, not raw level count. A two-story house may correctly have three levels when the third level has metadata role `roof`; do not delete roof/support levels to satisfy a requested story count.',
|
||||||
'- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.',
|
'- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.',
|
||||||
'',
|
'',
|
||||||
'## Scene model facts exposed here so agents do not need repo inspection',
|
'## Scene model facts exposed here so agents do not need repo inspection',
|
||||||
@@ -35,6 +36,7 @@ export const AGENT_GUIDE = [
|
|||||||
'- A story wall height is normally 2.4-3.0m; wall thickness is normally 0.1-0.3m.',
|
'- A story wall height is normally 2.4-3.0m; wall thickness is normally 0.1-0.3m.',
|
||||||
'- Slab and ceiling holes are polygon arrays. Manual stair openings should have `holeMetadata` with source `manual` and a single rectangular polygon.',
|
'- Slab and ceiling holes are polygon arrays. Manual stair openings should have `holeMetadata` with source `manual` and a single rectangular polygon.',
|
||||||
'- Dedicated roof levels use metadata role `roof` and normally contain the roof only; the top occupied level keeps its own walls, rooms, slabs, and ceiling.',
|
'- Dedicated roof levels use metadata role `roof` and normally contain the roof only; the top occupied level keeps its own walls, rooms, slabs, and ceiling.',
|
||||||
|
'- `verify_scene` reports `occupiedStoryCount`, `supportLevelCount`, and `roofLevelIds`; use those fields instead of `levelCount` when checking story-count requirements.',
|
||||||
'- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.',
|
'- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.',
|
||||||
'- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.',
|
'- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -174,6 +174,66 @@ describe('construction tools', () => {
|
|||||||
expect(bridge.validateScene().valid).toBe(true)
|
expect(bridge.validateScene().valid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('story construction tools reject dedicated roof support levels', async () => {
|
||||||
|
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||||
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
const roofLevel = LevelNode.parse({
|
||||||
|
name: 'Roof',
|
||||||
|
level: 1,
|
||||||
|
children: [],
|
||||||
|
metadata: { role: 'roof', referenceLevelId: level.id },
|
||||||
|
})
|
||||||
|
bridge.createNode(roofLevel, building.id)
|
||||||
|
|
||||||
|
const shell = await client.callTool({
|
||||||
|
name: 'create_story_shell',
|
||||||
|
arguments: {
|
||||||
|
levelId: roofLevel.id,
|
||||||
|
footprint: [
|
||||||
|
[-4, -3],
|
||||||
|
[4, -3],
|
||||||
|
[4, 3],
|
||||||
|
[-4, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(shell.isError).toBe(true)
|
||||||
|
|
||||||
|
const stair = await client.callTool({
|
||||||
|
name: 'create_stair_between_levels',
|
||||||
|
arguments: {
|
||||||
|
fromLevelId: level.id,
|
||||||
|
toLevelId: roofLevel.id,
|
||||||
|
position: [0, 0, 0],
|
||||||
|
runLength: 3,
|
||||||
|
totalRise: 2.8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(stair.isError).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create_roof requires an explicit roof support level when roofLevelId is provided', async () => {
|
||||||
|
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||||
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
const occupiedUpper = LevelNode.parse({
|
||||||
|
name: 'Second Floor',
|
||||||
|
level: 1,
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
bridge.createNode(occupiedUpper, building.id)
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'create_roof',
|
||||||
|
arguments: {
|
||||||
|
levelId: level.id,
|
||||||
|
roofLevelId: occupiedUpper.id,
|
||||||
|
width: 8,
|
||||||
|
depth: 6,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.isError).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('verify_scene flags roofs mixed into occupied levels', async () => {
|
test('verify_scene flags roofs mixed into occupied levels', async () => {
|
||||||
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
|
|||||||
@@ -232,7 +232,12 @@ export function registerConstructionTools(
|
|||||||
slabMaterialPreset,
|
slabMaterialPreset,
|
||||||
ceilingMaterialPreset,
|
ceilingMaterialPreset,
|
||||||
}) => {
|
}) => {
|
||||||
assertNode(bridge, levelId, 'level')
|
const level = assertNode(bridge, levelId, 'level')
|
||||||
|
if (isRoofLevel(level)) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot create a story shell on roof support level ${levelId}; create or choose an occupied story level instead`,
|
||||||
|
)
|
||||||
|
}
|
||||||
const points = footprint as [number, number][]
|
const points = footprint as [number, number][]
|
||||||
const wallIds: string[] = []
|
const wallIds: string[] = []
|
||||||
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
|
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
|
||||||
@@ -324,7 +329,12 @@ export function registerConstructionTools(
|
|||||||
let createdRoofLevelId: string | null = null
|
let createdRoofLevelId: string | null = null
|
||||||
|
|
||||||
if (roofLevelId !== undefined) {
|
if (roofLevelId !== undefined) {
|
||||||
assertNode(bridge, roofLevelId, 'level')
|
const roofLevel = assertNode(bridge, roofLevelId, 'level')
|
||||||
|
if (!isRoofLevel(roofLevel)) {
|
||||||
|
throw new Error(
|
||||||
|
`roofLevelId ${roofLevelId} must reference a dedicated roof level with metadata.role = "roof"; omit roofLevelId to create one automatically`,
|
||||||
|
)
|
||||||
|
}
|
||||||
targetRoofLevelId = roofLevelId as AnyNodeId
|
targetRoofLevelId = roofLevelId as AnyNodeId
|
||||||
} else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) {
|
} else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) {
|
||||||
const buildingId = getBuildingIdForLevel(bridge, levelId)
|
const buildingId = getBuildingIdForLevel(bridge, levelId)
|
||||||
@@ -411,8 +421,13 @@ export function registerConstructionTools(
|
|||||||
materialPreset,
|
materialPreset,
|
||||||
name,
|
name,
|
||||||
}) => {
|
}) => {
|
||||||
assertNode(bridge, fromLevelId, 'level')
|
const fromLevel = assertNode(bridge, fromLevelId, 'level')
|
||||||
assertNode(bridge, toLevelId, 'level')
|
const toLevel = assertNode(bridge, toLevelId, 'level')
|
||||||
|
if (isRoofLevel(fromLevel) || isRoofLevel(toLevel)) {
|
||||||
|
throw new Error(
|
||||||
|
'Roof support levels are not occupied stories; create a separate occupied attic/story level if a stair-accessible attic is required',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const segment = StairSegmentNode.parse({
|
const segment = StairSegmentNode.parse({
|
||||||
segmentType: 'stair',
|
segmentType: 'stair',
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ export function registerCreateWall(
|
|||||||
`Node ${levelId} is a ${parent.type}, expected level`,
|
`Node ${levelId} is a ${parent.type}, expected level`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
typeof parent.metadata === 'object' &&
|
||||||
|
parent.metadata !== null &&
|
||||||
|
'role' in parent.metadata &&
|
||||||
|
parent.metadata.role === 'roof'
|
||||||
|
) {
|
||||||
|
throwMcpError(
|
||||||
|
ErrorCode.InvalidParams,
|
||||||
|
`Roof support level ${levelId} is not an occupied story; create walls on an occupied level instead`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const wall = WallNode.parse({
|
const wall = WallNode.parse({
|
||||||
start: start as [number, number],
|
start: start as [number, number],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
|
import { LevelNode } from '@pascal-app/core/schema'
|
||||||
import { SceneBridge } from '../bridge/scene-bridge'
|
import { SceneBridge } from '../bridge/scene-bridge'
|
||||||
import { registerRoomTools } from './room-tools'
|
import { registerRoomTools } from './room-tools'
|
||||||
|
|
||||||
@@ -56,6 +57,32 @@ describe('room tools', () => {
|
|||||||
expect(bridge.validateScene().valid).toBe(true)
|
expect(bridge.validateScene().valid).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('create_room rejects dedicated roof support levels', async () => {
|
||||||
|
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||||
|
const roofLevel = LevelNode.parse({
|
||||||
|
name: 'Roof',
|
||||||
|
level: 1,
|
||||||
|
metadata: { role: 'roof' },
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
bridge.createNode(roofLevel, building.id)
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: 'create_room',
|
||||||
|
arguments: {
|
||||||
|
levelId: roofLevel.id,
|
||||||
|
name: 'Accidental attic room',
|
||||||
|
polygon: [
|
||||||
|
[0, 0],
|
||||||
|
[4, 0],
|
||||||
|
[4, 3],
|
||||||
|
[0, 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.isError).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('add_door and add_window convert t to wall-local meters', async () => {
|
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 level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
const roomResult = await client.callTool({
|
const roomResult = await client.callTool({
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
|
|||||||
if (level.type !== 'level') {
|
if (level.type !== 'level') {
|
||||||
throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${level.type}, expected level`)
|
throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${level.type}, expected level`)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
typeof level.metadata === 'object' &&
|
||||||
|
level.metadata !== null &&
|
||||||
|
'role' in level.metadata &&
|
||||||
|
level.metadata.role === 'roof'
|
||||||
|
) {
|
||||||
|
throwMcpError(
|
||||||
|
ErrorCode.InvalidParams,
|
||||||
|
`Roof support level ${levelId} is not an occupied story; create rooms or furnishings on an occupied level instead`,
|
||||||
|
)
|
||||||
|
}
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|||||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import {
|
import {
|
||||||
|
CeilingNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
|
RoofNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
|
WindowNode,
|
||||||
ZoneNode,
|
ZoneNode,
|
||||||
} from '@pascal-app/core/schema'
|
} from '@pascal-app/core/schema'
|
||||||
import { SceneBridge } from '../bridge/scene-bridge'
|
import { SceneBridge } from '../bridge/scene-bridge'
|
||||||
@@ -80,6 +83,149 @@ describe('scene query tools', () => {
|
|||||||
expect(parsed.issues.join('\n')).toContain('walls but no zones')
|
expect(parsed.issues.join('\n')).toContain('walls but no zones')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('verify_scene separates occupied stories from dedicated roof levels', 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 },
|
||||||
|
})
|
||||||
|
const roofLevel = LevelNode.parse({
|
||||||
|
name: 'Roof',
|
||||||
|
level: 2,
|
||||||
|
metadata: { role: 'roof', referenceLevelId: upper.id, height: 2.5 },
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
bridge.createNode(upper, building.id)
|
||||||
|
bridge.createNode(roofLevel, building.id)
|
||||||
|
|
||||||
|
for (const levelId of [ground.id, upper.id]) {
|
||||||
|
bridge.createNode(
|
||||||
|
ZoneNode.parse({
|
||||||
|
name: 'Room',
|
||||||
|
polygon: [
|
||||||
|
[0, 0],
|
||||||
|
[4, 0],
|
||||||
|
[4, 3],
|
||||||
|
[0, 3],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
levelId,
|
||||||
|
)
|
||||||
|
bridge.createNode(
|
||||||
|
SlabNode.parse({
|
||||||
|
polygon: [
|
||||||
|
[0, 0],
|
||||||
|
[4, 0],
|
||||||
|
[4, 3],
|
||||||
|
[0, 3],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
levelId,
|
||||||
|
)
|
||||||
|
bridge.createNode(
|
||||||
|
CeilingNode.parse({
|
||||||
|
polygon: [
|
||||||
|
[0, 0],
|
||||||
|
[4, 0],
|
||||||
|
[4, 3],
|
||||||
|
[0, 3],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
levelId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roof = RoofNode.parse({
|
||||||
|
name: 'Main roof',
|
||||||
|
metadata: { referenceLevelId: upper.id, roofLevelId: roofLevel.id },
|
||||||
|
})
|
||||||
|
bridge.createNode(roof, roofLevel.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.levelCount).toBe(3)
|
||||||
|
expect(parsed.occupiedStoryCount).toBe(2)
|
||||||
|
expect(parsed.supportLevelCount).toBe(1)
|
||||||
|
expect(parsed.roofLevelIds).toEqual([roofLevel.id])
|
||||||
|
expect(parsed.hasIssues).toBe(false)
|
||||||
|
|
||||||
|
const listed = await client.callTool({ name: 'list_levels', arguments: {} })
|
||||||
|
expect(listed.isError).toBeFalsy()
|
||||||
|
const listPayload = JSON.parse(
|
||||||
|
(listed.content as Array<{ type: string; text: string }>)[0]!.text,
|
||||||
|
)
|
||||||
|
expect(listPayload.occupiedStoryCount).toBe(2)
|
||||||
|
expect(listPayload.roofLevelIds).toEqual([roofLevel.id])
|
||||||
|
expect(listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id)).toMatchObject({
|
||||||
|
role: 'roof',
|
||||||
|
isSupportLevel: true,
|
||||||
|
referenceLevelId: upper.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verify_scene reports bad opening linkage and wall-local bounds', async () => {
|
||||||
|
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
const hostWall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5 })
|
||||||
|
const otherWall = WallNode.parse({ start: [0, 2], end: [4, 2], height: 2.5 })
|
||||||
|
bridge.createNode(hostWall, level.id)
|
||||||
|
bridge.createNode(otherWall, level.id)
|
||||||
|
|
||||||
|
const window = WindowNode.parse({
|
||||||
|
wallId: otherWall.id,
|
||||||
|
position: [4.8, 2.4, 0],
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
})
|
||||||
|
bridge.createNode(window, hostWall.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)
|
||||||
|
const issues = parsed.issues.join('\n')
|
||||||
|
expect(issues).toContain(`window ${window.id} has wallId ${otherWall.id}`)
|
||||||
|
expect(issues).toContain(`window ${window.id} extends outside wall ${hostWall.id}`)
|
||||||
|
expect(issues).toContain(`window ${window.id} vertical bounds`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verify_scene reports stairs outside their source floor slab', 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 segment = StairSegmentNode.parse({
|
||||||
|
width: 1,
|
||||||
|
length: 3,
|
||||||
|
height: 2.5,
|
||||||
|
stepCount: 10,
|
||||||
|
})
|
||||||
|
const stair = StairNode.parse({
|
||||||
|
name: 'Escaping Stair',
|
||||||
|
position: [3.6, 0, 1],
|
||||||
|
rotation: Math.PI / 2,
|
||||||
|
stairType: 'straight',
|
||||||
|
children: [segment.id],
|
||||||
|
})
|
||||||
|
bridge.createNode(stair, level.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.issues.join('\n')).toContain(
|
||||||
|
'Stair Escaping Stair footprint extends outside source floor slab',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('verify_scene reports stair wall obstructions and missing destination slab openings', async () => {
|
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 building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||||
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||||
|
|||||||
@@ -20,12 +20,21 @@ const jsonObject = z.record(z.string(), z.unknown())
|
|||||||
|
|
||||||
export const listLevelsOutput = {
|
export const listLevelsOutput = {
|
||||||
activeSceneId: z.string().nullable(),
|
activeSceneId: z.string().nullable(),
|
||||||
|
levelCount: z.number(),
|
||||||
|
occupiedStoryCount: z.number(),
|
||||||
|
supportLevelCount: z.number(),
|
||||||
|
roofLevelIds: z.array(z.string()),
|
||||||
levels: z.array(jsonObject),
|
levels: z.array(jsonObject),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLevelSummaryOutput = {
|
export const getLevelSummaryOutput = {
|
||||||
levelId: z.string(),
|
levelId: z.string(),
|
||||||
levelName: z.string().optional(),
|
levelName: z.string().optional(),
|
||||||
|
role: z.string(),
|
||||||
|
metadataRole: z.string().nullable(),
|
||||||
|
isOccupiedStory: z.boolean(),
|
||||||
|
isSupportLevel: z.boolean(),
|
||||||
|
referenceLevelId: z.string().nullable(),
|
||||||
counts: jsonObject,
|
counts: jsonObject,
|
||||||
walls: z.array(jsonObject),
|
walls: z.array(jsonObject),
|
||||||
zones: z.array(jsonObject),
|
zones: z.array(jsonObject),
|
||||||
@@ -48,6 +57,9 @@ export const verifySceneOutput = {
|
|||||||
ok: z.boolean(),
|
ok: z.boolean(),
|
||||||
valid: z.boolean(),
|
valid: z.boolean(),
|
||||||
levelCount: z.number(),
|
levelCount: z.number(),
|
||||||
|
occupiedStoryCount: z.number(),
|
||||||
|
supportLevelCount: z.number(),
|
||||||
|
roofLevelIds: z.array(z.string()),
|
||||||
activeSceneId: z.string().nullable(),
|
activeSceneId: z.string().nullable(),
|
||||||
levels: z.array(jsonObject),
|
levels: z.array(jsonObject),
|
||||||
emptyLevelIds: z.array(z.string()),
|
emptyLevelIds: z.array(z.string()),
|
||||||
@@ -55,6 +67,20 @@ export const verifySceneOutput = {
|
|||||||
hasIssues: z.boolean(),
|
hasIssues: z.boolean(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContentCounts = {
|
||||||
|
walls: number
|
||||||
|
zones: number
|
||||||
|
doors: number
|
||||||
|
windows: number
|
||||||
|
items: number
|
||||||
|
slabs: number
|
||||||
|
ceilings: number
|
||||||
|
roofs: number
|
||||||
|
stairs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LevelRole = 'occupied' | 'roof' | 'support'
|
||||||
|
|
||||||
function textResult<T extends Record<string, unknown>>(payload: T) {
|
function textResult<T extends Record<string, unknown>>(payload: T) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
||||||
@@ -82,6 +108,38 @@ function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metadataRecord(node: AnyNode): Record<string, unknown> | null {
|
||||||
|
return typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataString(node: AnyNode, key: string): string | undefined {
|
||||||
|
const value = metadataRecord(node)?.[key]
|
||||||
|
return typeof value === 'string' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function occupiedContentCount(counts: ContentCounts): number {
|
||||||
|
return (
|
||||||
|
counts.walls +
|
||||||
|
counts.zones +
|
||||||
|
counts.doors +
|
||||||
|
counts.windows +
|
||||||
|
counts.items +
|
||||||
|
counts.slabs +
|
||||||
|
counts.ceilings +
|
||||||
|
counts.stairs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole {
|
||||||
|
const metadataRole = metadataString(level, 'role')
|
||||||
|
if (metadataRole === 'roof') return 'roof'
|
||||||
|
if (metadataRole === 'support') return 'support'
|
||||||
|
if (counts.roofs > 0 && occupiedContentCount(counts) === 0) return 'roof'
|
||||||
|
return 'occupied'
|
||||||
|
}
|
||||||
|
|
||||||
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
|
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
|
||||||
return bridge
|
return bridge
|
||||||
.getChildren(wallId)
|
.getChildren(wallId)
|
||||||
@@ -317,6 +375,19 @@ function holeBelongsToStair(
|
|||||||
return metadata?.source === 'stair' && metadata.stairId === stairId
|
return metadata?.source === 'stair' && metadata.stairId === stairId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parentListsChild(parent: AnyNode, childId: string): boolean {
|
||||||
|
if (!('children' in parent) || !Array.isArray(parent.children)) return false
|
||||||
|
return parent.children.some((child) => {
|
||||||
|
if (typeof child === 'string') return child === childId
|
||||||
|
return (
|
||||||
|
child !== null &&
|
||||||
|
typeof child === 'object' &&
|
||||||
|
'id' in child &&
|
||||||
|
(child as { id?: unknown }).id === childId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
|
function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
|
||||||
const level = bridge.getNode(levelId)
|
const level = bridge.getNode(levelId)
|
||||||
if (!level || level.type !== 'level') {
|
if (!level || level.type !== 'level') {
|
||||||
@@ -350,12 +421,7 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
|
|||||||
const windows = nodes.filter((node) => node.type === 'window')
|
const windows = nodes.filter((node) => node.type === 'window')
|
||||||
const roofs = nodes.filter((node) => node.type === 'roof')
|
const roofs = nodes.filter((node) => node.type === 'roof')
|
||||||
const stairs = nodes.filter((node) => node.type === 'stair')
|
const stairs = nodes.filter((node) => node.type === 'stair')
|
||||||
|
const counts: ContentCounts = {
|
||||||
return {
|
|
||||||
levelId,
|
|
||||||
levelName: level.name,
|
|
||||||
floorIndex: level.level,
|
|
||||||
counts: {
|
|
||||||
walls: walls.length,
|
walls: walls.length,
|
||||||
zones: zones.length,
|
zones: zones.length,
|
||||||
doors: doors.length,
|
doors: doors.length,
|
||||||
@@ -365,7 +431,21 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
|
|||||||
ceilings: ceilings.length,
|
ceilings: ceilings.length,
|
||||||
roofs: roofs.length,
|
roofs: roofs.length,
|
||||||
stairs: stairs.length,
|
stairs: stairs.length,
|
||||||
},
|
}
|
||||||
|
const role = classifyLevel(level, counts)
|
||||||
|
const metadataRole = metadataString(level, 'role') ?? null
|
||||||
|
const referenceLevelId = metadataString(level, 'referenceLevelId') ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelId,
|
||||||
|
levelName: level.name,
|
||||||
|
floorIndex: level.level,
|
||||||
|
role,
|
||||||
|
metadataRole,
|
||||||
|
isOccupiedStory: role === 'occupied',
|
||||||
|
isSupportLevel: role !== 'occupied',
|
||||||
|
referenceLevelId,
|
||||||
|
counts,
|
||||||
walls,
|
walls,
|
||||||
zones,
|
zones,
|
||||||
items,
|
items,
|
||||||
@@ -386,14 +466,33 @@ export function registerListLevels(server: McpServer, bridge: SceneBridge): void
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const activeScene = bridge.getActiveScene()
|
const activeScene = bridge.getActiveScene()
|
||||||
const levels = getLevels(bridge).map((level) => ({
|
const levels = getLevels(bridge).map((level) => {
|
||||||
|
const summary = levelSummary(bridge, level.id as AnyNodeId)
|
||||||
|
return {
|
||||||
id: level.id,
|
id: level.id,
|
||||||
name: level.name,
|
name: level.name,
|
||||||
floorIndex: level.type === 'level' ? level.level : 0,
|
floorIndex: level.type === 'level' ? level.level : 0,
|
||||||
parentId: level.parentId,
|
parentId: level.parentId,
|
||||||
|
role: summary.role,
|
||||||
|
metadataRole: summary.metadataRole,
|
||||||
|
isOccupiedStory: summary.isOccupiedStory,
|
||||||
|
isSupportLevel: summary.isSupportLevel,
|
||||||
|
referenceLevelId: summary.referenceLevelId,
|
||||||
childCount: bridge.getChildren(level.id as AnyNodeId).length,
|
childCount: bridge.getChildren(level.id as AnyNodeId).length,
|
||||||
}))
|
}
|
||||||
return textResult({ activeSceneId: activeScene?.id ?? null, levels })
|
})
|
||||||
|
const occupiedStoryCount = levels.filter((level) => level.isOccupiedStory).length
|
||||||
|
const roofLevelIds = levels
|
||||||
|
.filter((level) => level.role === 'roof')
|
||||||
|
.map((level) => level.id as string)
|
||||||
|
return textResult({
|
||||||
|
activeSceneId: activeScene?.id ?? null,
|
||||||
|
levelCount: levels.length,
|
||||||
|
occupiedStoryCount,
|
||||||
|
supportLevelCount: levels.length - occupiedStoryCount,
|
||||||
|
roofLevelIds,
|
||||||
|
levels,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -480,18 +579,50 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
|
|||||||
levelId: level.id,
|
levelId: level.id,
|
||||||
levelName: level.name ?? `Level ${summary.floorIndex}`,
|
levelName: level.name ?? `Level ${summary.floorIndex}`,
|
||||||
floorIndex: summary.floorIndex,
|
floorIndex: summary.floorIndex,
|
||||||
|
role: summary.role,
|
||||||
|
metadataRole: summary.metadataRole,
|
||||||
|
isOccupiedStory: summary.isOccupiedStory,
|
||||||
|
isSupportLevel: summary.isSupportLevel,
|
||||||
|
referenceLevelId: summary.referenceLevelId,
|
||||||
isEmpty: totalContent === 0,
|
isEmpty: totalContent === 0,
|
||||||
content: summary.counts,
|
content: summary.counts,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const issues: string[] = []
|
const issues: string[] = []
|
||||||
|
const occupiedStoryCount = levels.filter((level) => level.isOccupiedStory).length
|
||||||
|
const roofLevelIds = levels
|
||||||
|
.filter((level) => level.role === 'roof')
|
||||||
|
.map((level) => level.levelId as string)
|
||||||
const emptyLevelIds = levels.filter((level) => level.isEmpty).map((level) => level.levelId)
|
const emptyLevelIds = levels.filter((level) => level.isEmpty).map((level) => level.levelId)
|
||||||
if (emptyLevelIds.length > 0) {
|
if (emptyLevelIds.length > 0) {
|
||||||
issues.push(`Empty level(s): ${emptyLevelIds.join(', ')}`)
|
issues.push(`Empty level(s): ${emptyLevelIds.join(', ')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const level of levels) {
|
for (const level of levels) {
|
||||||
|
const occupiedContent = occupiedContentCount(level.content)
|
||||||
|
if (level.role === 'roof') {
|
||||||
|
if (level.content.roofs === 0) {
|
||||||
|
issues.push(
|
||||||
|
`${level.levelName} is a roof support level but has no roof geometry; add or move roof geometry there rather than deleting the support level to satisfy story count`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (occupiedContent > 0) {
|
||||||
|
issues.push(
|
||||||
|
`${level.levelName} is a roof support level but contains occupied-story content; move rooms, walls, stairs, slabs, ceilings, and items to an occupied story and keep the roof level for roof geometry only`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level.referenceLevelId) {
|
||||||
|
const referenceLevel = bridge.getNode(level.referenceLevelId as AnyNodeId)
|
||||||
|
if (referenceLevel?.type === 'level' && level.floorIndex <= referenceLevel.level) {
|
||||||
|
issues.push(
|
||||||
|
`${level.levelName} roof support level should be above its reference occupied level ${referenceLevel.name ?? referenceLevel.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (level.content.walls > 0 && level.content.zones === 0) {
|
if (level.content.walls > 0 && level.content.zones === 0) {
|
||||||
issues.push(`${level.levelName} has walls but no zones/rooms`)
|
issues.push(`${level.levelName} has walls but no zones/rooms`)
|
||||||
}
|
}
|
||||||
@@ -514,10 +645,12 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMultipleLevels = levels.length > 1
|
const hasMultipleOccupiedStories = occupiedStoryCount > 1
|
||||||
if (hasMultipleLevels) {
|
if (hasMultipleOccupiedStories) {
|
||||||
for (const level of getLevels(bridge)) {
|
for (const level of getLevels(bridge)) {
|
||||||
if (level.type !== 'level') continue
|
if (level.type !== 'level') continue
|
||||||
|
const summary = levels.find((entry) => entry.levelId === level.id)
|
||||||
|
if (!summary?.isOccupiedStory) continue
|
||||||
const expectedHeight =
|
const expectedHeight =
|
||||||
typeof level.metadata === 'object' &&
|
typeof level.metadata === 'object' &&
|
||||||
level.metadata !== null &&
|
level.metadata !== null &&
|
||||||
@@ -545,12 +678,29 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
|
|||||||
issues.push(`${node.type} ${node.id} is not parented to a wall`)
|
issues.push(`${node.type} ${node.id} is not parented to a wall`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (node.wallId !== parent.id) {
|
||||||
|
issues.push(
|
||||||
|
`${node.type} ${node.id} has wallId ${node.wallId ?? 'unset'} but is parented to wall ${parent.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!parentListsChild(parent, node.id)) {
|
||||||
|
issues.push(`${node.type} ${node.id} is not listed in wall ${parent.id} children`)
|
||||||
|
}
|
||||||
const length = wallLength(parent)
|
const length = wallLength(parent)
|
||||||
const width = node.width ?? (node.type === 'door' ? 0.9 : 1.5)
|
const width = node.width ?? (node.type === 'door' ? 0.9 : 1.5)
|
||||||
|
const height = node.height ?? (node.type === 'door' ? 2.1 : 1.5)
|
||||||
const localX = node.position[0]
|
const localX = node.position[0]
|
||||||
if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) {
|
if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) {
|
||||||
issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`)
|
issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`)
|
||||||
}
|
}
|
||||||
|
const wallHeight = parent.height ?? 2.5
|
||||||
|
const bottom = node.position[1] - height / 2
|
||||||
|
const top = node.position[1] + height / 2
|
||||||
|
if (bottom < -0.01 || top > wallHeight + 0.01) {
|
||||||
|
issues.push(
|
||||||
|
`${node.type} ${node.id} vertical bounds [${bottom.toFixed(2)}, ${top.toFixed(2)}] exceed wall ${parent.id} height ${wallHeight.toFixed(2)}m`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +711,24 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
|
|||||||
if (sourceLevelId) {
|
if (sourceLevelId) {
|
||||||
const sourceLevel = bridge.getNode(sourceLevelId)
|
const sourceLevel = bridge.getNode(sourceLevelId)
|
||||||
const footprints = stairFootprintPolygons(bridge, stair)
|
const footprints = stairFootprintPolygons(bridge, stair)
|
||||||
|
const sourceSlabs = nodesOnLevel(bridge, sourceLevelId).filter(
|
||||||
|
(node): node is AnyNode & { type: 'slab' } => node.type === 'slab',
|
||||||
|
)
|
||||||
|
if (sourceSlabs.length > 0) {
|
||||||
|
const outsideFootprints = footprints.filter(
|
||||||
|
(footprint) =>
|
||||||
|
!sourceSlabs.some((slab) =>
|
||||||
|
polygonContainsPolygon(slab.polygon as Vec2[], footprint),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (outsideFootprints.length > 0) {
|
||||||
|
issues.push(
|
||||||
|
`Stair ${stair.name ?? stair.id} footprint extends outside source floor slab on ${
|
||||||
|
sourceLevel?.name ?? sourceLevelId
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
const obstructingWalls = nodesOnLevel(bridge, sourceLevelId)
|
const obstructingWalls = nodesOnLevel(bridge, sourceLevelId)
|
||||||
.filter((node): node is AnyNode & { type: 'wall' } => node.type === 'wall')
|
.filter((node): node is AnyNode & { type: 'wall' } => node.type === 'wall')
|
||||||
.filter((wall) =>
|
.filter((wall) =>
|
||||||
@@ -635,6 +803,9 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
|
|||||||
ok: true,
|
ok: true,
|
||||||
valid: validation.valid,
|
valid: validation.valid,
|
||||||
levelCount: levels.length,
|
levelCount: levels.length,
|
||||||
|
occupiedStoryCount,
|
||||||
|
supportLevelCount: levels.length - occupiedStoryCount,
|
||||||
|
roofLevelIds,
|
||||||
activeSceneId: bridge.getActiveScene()?.id ?? null,
|
activeSceneId: bridge.getActiveScene()?.id ?? null,
|
||||||
levels,
|
levels,
|
||||||
emptyLevelIds,
|
emptyLevelIds,
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?:
|
|||||||
`Node ${levelId} is a ${parent.type}, expected level`,
|
`Node ${levelId} is a ${parent.type}, expected level`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
typeof parent.metadata === 'object' &&
|
||||||
|
parent.metadata !== null &&
|
||||||
|
'role' in parent.metadata &&
|
||||||
|
parent.metadata.role === 'roof'
|
||||||
|
) {
|
||||||
|
throwMcpError(
|
||||||
|
ErrorCode.InvalidParams,
|
||||||
|
`Roof support level ${levelId} is not an occupied story; create zones on an occupied level instead`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const zone = ZoneNode.parse({
|
const zone = ZoneNode.parse({
|
||||||
name: label,
|
name: label,
|
||||||
|
|||||||
Reference in New Issue
Block a user