Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.
Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
@types/react, @types/react-dom, @types/three to prevent JSX namespace
fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
match the new versions on every bump (so viewer/editor/mcp tarballs always
reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts
Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
what private-editor currently reports
Open PRs against editor-v2 will need rebasing/conflict resolution.
833 lines
27 KiB
TypeScript
833 lines
27 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
|
|
import { z } from 'zod'
|
|
import type { SceneOperations } from '../operations'
|
|
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(),
|
|
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),
|
|
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(),
|
|
occupiedStoryCount: z.number(),
|
|
supportLevelCount: z.number(),
|
|
roofLevelIds: z.array(z.string()),
|
|
activeSceneId: z.string().nullable(),
|
|
levels: z.array(jsonObject),
|
|
emptyLevelIds: z.array(z.string()),
|
|
issues: z.array(z.string()),
|
|
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) {
|
|
return {
|
|
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
|
structuredContent: payload,
|
|
}
|
|
}
|
|
|
|
function getLevels(bridge: SceneOperations): 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: SceneOperations,
|
|
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: SceneOperations, levelId: AnyNodeId): AnyNode[] {
|
|
return Object.values(bridge.getNodes()).filter(
|
|
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
|
|
)
|
|
}
|
|
|
|
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: SceneOperations, 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: SceneOperations, 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: SceneOperations,
|
|
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: SceneOperations,
|
|
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 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: SceneOperations, 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')
|
|
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,
|
|
role,
|
|
metadataRole,
|
|
isOccupiedStory: role === 'occupied',
|
|
isSupportLevel: role !== 'occupied',
|
|
referenceLevelId,
|
|
counts,
|
|
walls,
|
|
zones,
|
|
items,
|
|
slabs,
|
|
ceilings,
|
|
}
|
|
}
|
|
|
|
export function registerListLevels(server: McpServer, bridge: SceneOperations): 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) => {
|
|
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,
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
export function registerGetLevelSummary(server: McpServer, bridge: SceneOperations): 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: SceneOperations): 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: SceneOperations): 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: SceneOperations): 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,
|
|
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`)
|
|
}
|
|
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 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 &&
|
|
'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
|
|
}
|
|
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`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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) =>
|
|
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,
|
|
occupiedStoryCount,
|
|
supportLevelCount: levels.length - occupiedStoryCount,
|
|
roofLevelIds,
|
|
activeSceneId: bridge.getActiveScene()?.id ?? null,
|
|
levels,
|
|
emptyLevelIds,
|
|
issues,
|
|
hasIssues: issues.length > 0,
|
|
}
|
|
return textResult(payload)
|
|
},
|
|
)
|
|
}
|
|
|
|
export function registerSceneQueryTools(server: McpServer, bridge: SceneOperations): void {
|
|
registerListLevels(server, bridge)
|
|
registerGetLevelSummary(server, bridge)
|
|
registerGetWalls(server, bridge)
|
|
registerGetZones(server, bridge)
|
|
registerVerifyScene(server, bridge)
|
|
}
|