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 { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), wallHeight: z.number().positive().default(2.8), wallThickness: z.number().positive().default(0.16), createSlab: z.boolean().default(true), createCeiling: z.boolean().default(true), slabElevation: z.number().default(0.1), ceilingHeight: z.number().positive().optional(), namePrefix: z.string().optional(), wallMaterialPreset: z.string().optional(), slabMaterialPreset: z.string().optional(), ceilingMaterialPreset: z.string().optional(), } export const createStoryShellOutput = { levelId: z.string(), wallIds: z.array(z.string()), slabId: z.string().nullable(), ceilingId: z.string().nullable(), createdIds: z.array(z.string()), } export const createRoofInput = { levelId: NodeIdSchema, roofLevelId: NodeIdSchema.optional(), useDedicatedRoofLevel: z.boolean().default(true), roofLevelLabel: z.string().default('Roof'), roofLevelElevation: z.number().optional(), roofLevelHeight: z.number().positive().optional(), center: Vec3Schema.optional(), width: z.number().positive(), depth: z.number().positive(), roofType: z.enum(ROOF_TYPES).default('hip'), roofHeight: z.number().positive().default(1.8), wallHeight: z.number().min(0).default(0.35), wallThickness: z.number().positive().default(0.16), overhang: z.number().min(0).default(0.45), materialPreset: z.string().optional(), name: z.string().optional(), } export const createRoofOutput = { referenceLevelId: z.string(), roofLevelId: z.string(), createdRoofLevelId: z.string().nullable(), roofId: z.string(), roofSegmentId: z.string(), } export const createStairBetweenLevelsInput = { fromLevelId: NodeIdSchema, toLevelId: NodeIdSchema, position: Vec3Schema, rotation: z.number().default(0), width: z.number().positive().default(1), runLength: z.number().positive().default(3), totalRise: z.number().positive().default(2.8), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), sourceCeilingId: NodeIdSchema.optional(), createDestinationSlabOpening: z.boolean().default(true), createSourceCeilingOpening: z.boolean().default(true), openingWidth: z.number().positive().optional(), openingLength: z.number().positive().optional(), openingOffset: z.number().min(0).default(0.15), openingCenter: Vec2Schema.optional(), openingRotation: z.number().optional(), materialPreset: z.string().optional(), name: z.string().optional(), } export const createStairBetweenLevelsOutput = { stairId: z.string(), stairSegmentId: z.string(), destinationSlabId: z.string().nullable(), sourceCeilingId: z.string().nullable(), openingPolygon: z.array(Vec2Schema), } function textResult>(payload: T) { return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], structuredContent: payload, } } function assertNode(bridge: SceneOperations, 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: SceneOperations, 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: SceneOperations, 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: SceneOperations, levelId: string): AnyNode[] { return Object.values(bridge.getNodes()).filter( (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, ) } function firstNodeOnLevel( bridge: SceneOperations, levelId: string, type: 'slab' | 'ceiling', ): AnyNode | null { return nodesOnLevel(bridge, levelId).find((node) => node.type === type) ?? null } function rotatePoint(x: number, z: number, rotation: number): [number, number] { const cos = Math.cos(rotation) const sin = Math.sin(rotation) return [x * cos + z * sin, -x * sin + z * cos] } function rectangularOpening(args: { position: [number, number, number] rotation: number width: number length: number offset: number center?: [number, number] | undefined openingRotation?: number | undefined }): [number, number][] { const width = args.width + args.offset * 2 const length = args.length + args.offset * 2 const center: [number, number] = args.center ?? [ args.position[0], args.position[2] + args.length / 2, ] const rotation = args.openingRotation ?? args.rotation const halfW = width / 2 const halfL = length / 2 const local: [number, number][] = [ [-halfW, -halfL], [halfW, -halfL], [halfW, halfL], [-halfW, halfL], ] return local.map(([x, z]) => { const [rx, rz] = rotatePoint(x, z, rotation) return [center[0] + rx, center[1] + rz] }) } function withHole( surface: AnyNode & { type: 'slab' | 'ceiling' }, hole: [number, number][], ): Partial { return { holes: [...(surface.holes ?? []), hole], holeMetadata: [...(surface.holeMetadata ?? []), { source: 'manual' }], } as Partial } export function registerConstructionTools(server: McpServer, bridge: SceneOperations): 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, }) => { 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 }> = [] 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, '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) { 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) 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, '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, }) => { 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', width, length: runLength, height: totalRise, stepCount, ...(materialPreset ? { materialPreset } : {}), }) const stair = StairNode.parse({ name: name ?? 'Stair', position: position as [number, number, number], rotation, stairType: 'straight', fromLevelId, toLevelId, slabOpeningMode: 'none', openingOffset, width, totalRise, stepCount, railingMode, children: [segment.id], ...(materialPreset ? { materialPreset } : {}), metadata: { openingManaged: 'manual-rectangular', }, }) const openingPolygon = rectangularOpening({ position: position as [number, number, number], rotation, width: openingWidth ?? width, length: openingLength ?? runLength, offset: openingOffset, center: openingCenter as [number, number] | undefined, openingRotation, }) const patches: Array< | { op: 'create'; node: AnyNode; parentId: AnyNodeId } | { op: 'update'; id: AnyNodeId; data: Partial } > = [ { op: 'create', node: stair, parentId: fromLevelId as AnyNodeId }, { op: 'create', node: segment, parentId: stair.id as AnyNodeId }, ] const destinationSlab = destinationSlabId !== undefined ? assertNode(bridge, destinationSlabId, 'slab') : firstNodeOnLevel(bridge, toLevelId, 'slab') if (createDestinationSlabOpening && destinationSlab?.type === 'slab') { patches.push({ op: 'update', id: destinationSlab.id as AnyNodeId, data: withHole(destinationSlab, openingPolygon), }) } const sourceCeiling = sourceCeilingId !== undefined ? assertNode(bridge, sourceCeilingId, 'ceiling') : firstNodeOnLevel(bridge, fromLevelId, 'ceiling') if (createSourceCeilingOpening && sourceCeiling?.type === 'ceiling') { patches.push({ op: 'update', id: sourceCeiling.id as AnyNodeId, data: withHole(sourceCeiling, openingPolygon), }) } bridge.applyPatch(patches) await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels') return textResult({ stairId: stair.id, stairSegmentId: segment.id, destinationSlabId: destinationSlab?.id ?? null, sourceCeilingId: sourceCeiling?.id ?? null, openingPolygon, }) }, ) }