diff --git a/packages/mcp/src/prompts/scene-guidance.ts b/packages/mcp/src/prompts/scene-guidance.ts index a9dade36..35010459 100644 --- a/packages/mcp/src/prompts/scene-guidance.ts +++ b/packages/mcp/src/prompts/scene-guidance.ts @@ -7,6 +7,7 @@ export const SCENE_DESIGN_GUIDANCE = [ ' - For clear concrete requests, act with reasonable defaults instead of asking for clarification.', ' - For full homes/apartments, include realistic support spaces: kitchen, living/dining, bathrooms, hallway/entry, storage/laundry where appropriate.', ' - For multi-story buildings, create separate level-owned story shells. Do not stretch first-floor exterior walls to cover upper floors.', + ' - 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:', ' - 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 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.', + ' - 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.', ' - 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.', diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index e3c9e0b7..5705b9ab 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -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_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.', + '- 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.', '', '## 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.', '- 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.', + '- `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.', '- `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.', '', diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index 05c3410f..48ad04a9 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -174,6 +174,66 @@ describe('construction tools', () => { 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 () => { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! await client.callTool({ diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 8150c29c..fbb30a36 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -232,7 +232,12 @@ export function registerConstructionTools( slabMaterialPreset, 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 wallIds: string[] = [] const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = [] @@ -324,7 +329,12 @@ export function registerConstructionTools( let createdRoofLevelId: string | null = null 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 } else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) { const buildingId = getBuildingIdForLevel(bridge, levelId) @@ -411,8 +421,13 @@ export function registerConstructionTools( materialPreset, name, }) => { - assertNode(bridge, fromLevelId, 'level') - assertNode(bridge, toLevelId, 'level') + const fromLevel = assertNode(bridge, fromLevelId, '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({ segmentType: 'stair', diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index add33965..dbea1240 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -45,6 +45,17 @@ export function registerCreateWall( `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({ start: start as [number, number], diff --git a/packages/mcp/src/tools/room-tools.test.ts b/packages/mcp/src/tools/room-tools.test.ts index be82b964..0de36a43 100644 --- a/packages/mcp/src/tools/room-tools.test.ts +++ b/packages/mcp/src/tools/room-tools.test.ts @@ -2,6 +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 { LevelNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' import { registerRoomTools } from './room-tools' @@ -56,6 +57,32 @@ describe('room tools', () => { 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 () => { const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! const roomResult = await client.callTool({ diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 65dd53c3..5309c23a 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -124,6 +124,17 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode { if (level.type !== '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 } diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index 31a2660b..6b6bd380 100644 --- a/packages/mcp/src/tools/scene-query.test.ts +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -3,12 +3,15 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { + CeilingNode, DoorNode, LevelNode, + RoofNode, SlabNode, StairNode, StairSegmentNode, WallNode, + WindowNode, ZoneNode, } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' @@ -80,6 +83,149 @@ describe('scene query tools', () => { 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 () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index d74af63d..e20592a7 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -20,12 +20,21 @@ const jsonObject = z.record(z.string(), z.unknown()) export const listLevelsOutput = { activeSceneId: z.string().nullable(), + levelCount: z.number(), + occupiedStoryCount: z.number(), + supportLevelCount: z.number(), + roofLevelIds: z.array(z.string()), levels: z.array(jsonObject), } export const getLevelSummaryOutput = { levelId: z.string(), levelName: z.string().optional(), + role: z.string(), + metadataRole: z.string().nullable(), + isOccupiedStory: z.boolean(), + isSupportLevel: z.boolean(), + referenceLevelId: z.string().nullable(), counts: jsonObject, walls: z.array(jsonObject), zones: z.array(jsonObject), @@ -48,6 +57,9 @@ export const verifySceneOutput = { ok: z.boolean(), valid: z.boolean(), levelCount: z.number(), + occupiedStoryCount: z.number(), + supportLevelCount: z.number(), + roofLevelIds: z.array(z.string()), activeSceneId: z.string().nullable(), levels: z.array(jsonObject), emptyLevelIds: z.array(z.string()), @@ -55,6 +67,20 @@ export const verifySceneOutput = { 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>(payload: T) { return { 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 | null { + return typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : 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) { return bridge .getChildren(wallId) @@ -317,6 +375,19 @@ function holeBelongsToStair( 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) { const level = bridge.getNode(levelId) if (!level || level.type !== 'level') { @@ -350,22 +421,31 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) { const windows = nodes.filter((node) => node.type === 'window') const roofs = nodes.filter((node) => node.type === 'roof') const stairs = nodes.filter((node) => node.type === 'stair') + const counts: ContentCounts = { + 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, + } + 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, - 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, - }, + role, + metadataRole, + isOccupiedStory: role === 'occupied', + isSupportLevel: role !== 'occupied', + referenceLevelId, + counts, walls, zones, items, @@ -386,14 +466,33 @@ export function registerListLevels(server: McpServer, bridge: SceneBridge): void }, 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 }) + const levels = getLevels(bridge).map((level) => { + const summary = levelSummary(bridge, level.id as AnyNodeId) + return { + id: level.id, + name: level.name, + floorIndex: level.type === 'level' ? level.level : 0, + 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, + } + }) + 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, levelName: level.name ?? `Level ${summary.floorIndex}`, floorIndex: summary.floorIndex, + role: summary.role, + metadataRole: summary.metadataRole, + isOccupiedStory: summary.isOccupiedStory, + isSupportLevel: summary.isSupportLevel, + referenceLevelId: summary.referenceLevelId, isEmpty: totalContent === 0, content: summary.counts, } }) 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) if (emptyLevelIds.length > 0) { issues.push(`Empty level(s): ${emptyLevelIds.join(', ')}`) } 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) { 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 - if (hasMultipleLevels) { + const hasMultipleOccupiedStories = occupiedStoryCount > 1 + if (hasMultipleOccupiedStories) { for (const level of getLevels(bridge)) { if (level.type !== 'level') continue + const summary = levels.find((entry) => entry.levelId === level.id) + if (!summary?.isOccupiedStory) continue const expectedHeight = typeof level.metadata === 'object' && 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`) 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 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] if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) { 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) { const sourceLevel = bridge.getNode(sourceLevelId) 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) .filter((node): node is AnyNode & { type: 'wall' } => node.type === 'wall') .filter((wall) => @@ -635,6 +803,9 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi ok: true, valid: validation.valid, levelCount: levels.length, + occupiedStoryCount, + supportLevelCount: levels.length - occupiedStoryCount, + roofLevelIds, activeSceneId: bridge.getActiveScene()?.id ?? null, levels, emptyLevelIds, diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts index 06ca9c4b..f0661c84 100644 --- a/packages/mcp/src/tools/set-zone.ts +++ b/packages/mcp/src/tools/set-zone.ts @@ -40,6 +40,17 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: `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({ name: label,