feat(mcp): add guided construction workflows

This commit is contained in:
Aymeric Rabot
2026-04-27 14:31:04 -04:00
parent b3d1f663f6
commit 3d5c87a651
48 changed files with 3877 additions and 115 deletions
+5
View File
@@ -40,6 +40,11 @@
"types": "./dist/systems/wall/wall-footprint.d.ts",
"import": "./dist/systems/wall/wall-footprint.js",
"default": "./dist/systems/wall/wall-footprint.js"
},
"./stair-openings": {
"types": "./dist/systems/stair/stair-opening-sync.d.ts",
"import": "./dist/systems/stair/stair-opening-sync.js",
"default": "./dist/systems/stair/stair-opening-sync.js"
}
},
"files": [
+2
View File
@@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { ItemNode } from './item'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { SlabNode } from './slab'
@@ -19,6 +20,7 @@ export const LevelNode = BaseNode.extend({
z.union([
WallNode.shape.id,
FenceNode.shape.id,
ItemNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
CeilingNode.shape.id,
@@ -0,0 +1,71 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import { syncAutoStairOpenings } from './stair-opening-sync'
describe('syncAutoStairOpenings', () => {
test('only applies stair holes to destination slabs that contain the opening', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const bedroomSlab = SlabNode.parse({
name: 'Bedroom Slab',
parentId: upper.id,
polygon: [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
})
const segment = StairSegmentNode.parse({
parentId: 'stair_main',
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
id: 'stair_main',
name: 'Main Stair',
parentId: ground.id,
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
const nodes = Object.fromEntries(
[
building,
ground,
upper,
landingSlab,
bedroomSlab,
stair,
{ ...segment, parentId: stair.id },
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const bedroomUpdate = updates.find((update) => update.id === bedroomSlab.id)
expect(landingUpdate?.data.holes).toHaveLength(1)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
expect(bedroomUpdate).toBeUndefined()
})
})
@@ -1,5 +1,12 @@
import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type {
AnyNode,
AnyNodeId,
CeilingNode,
SlabNode,
StairNode,
StairSegmentNode,
} from '../../schema'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number]
@@ -58,7 +65,8 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]
if (left.length !== right.length) return false
return left.every(
(entry, index) =>
entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null),
entry.source === right[index]?.source &&
(entry.stairId ?? null) === (right[index]?.stairId ?? null),
)
}
@@ -178,7 +186,10 @@ function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNod
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
return (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false)
.filter(
(segment): segment is StairSegmentNode =>
segment?.type === 'stair-segment' && segment.visible !== false,
)
}
function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D {
@@ -186,7 +197,10 @@ function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Poi
return [stair.position[0] + worldX, stair.position[2] + worldZ]
}
function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode>): StraightStairLayout[] {
function getStraightStairLayouts(
stair: StairNode,
nodes: Record<string, AnyNode>,
): StraightStairLayout[] {
const segments = resolveStraightSegments(stair, nodes)
const transforms = computeSegmentTransforms(segments)
@@ -204,7 +218,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode
})
}
function getStraightSegmentFootprintPolygon(stair: StairNode, layout: StraightStairLayout): Point2D[] {
function getStraightSegmentFootprintPolygon(
stair: StairNode,
layout: StraightStairLayout,
): Point2D[] {
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
}
@@ -242,11 +259,16 @@ function getStraightSegmentSlicePolygon(
startAlong: number,
endAlong: number,
): Point2D[] {
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => toWorldPlanPoint(stair, x, z))
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) =>
toWorldPlanPoint(stair, x, z),
)
}
function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) {
const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1))
const treadDepth = Math.max(
0.2,
segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1),
)
return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8))
}
@@ -261,6 +283,36 @@ function polygonArea(points: Point2D[]) {
return area / 2
}
function pointOnSegment(point: Point2D, a: Point2D, b: Point2D, tolerance = 1e-6) {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > tolerance) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -tolerance) return false
const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lenSq + tolerance
}
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
if (polygon.length < 3) return false
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i]!
const b = polygon[j]!
if (pointOnSegment(point, a, b)) return true
const intersects =
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
if (intersects) inside = !inside
}
return inside
}
function polygonContainsPolygon(outer: Point2D[], inner: Point2D[]) {
return inner.every((point) => pointInPolygon(point, outer))
}
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
if (polygon.length < 4) return null
const xs = polygon.map(([x]) => x)
@@ -289,12 +341,16 @@ function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect {
function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
if (rects.length === 0) return []
const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort(
(a, b) => a - b,
)
const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort(
(a, b) => a - b,
)
const xs = Array.from(
new Set(
rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))),
),
).sort((a, b) => a - b)
const zs = Array.from(
new Set(
rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))),
),
).sort((a, b) => a - b)
if (xs.length < 2 || zs.length < 2) return []
const occupied = new Set<string>()
@@ -393,13 +449,17 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius))
outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
)
}
for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius))
innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
)
}
return [...outerPoints, ...innerPoints]
@@ -440,7 +500,11 @@ function getStraightOpeningPolygonsForSurface(
if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) {
const openingDepth = getStraightFlightOpeningDepth(stair, segment)
const flightRect = getAxisAlignedRectFromPolygon(
getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length),
getStraightSegmentLocalSlicePolygon(
layout,
Math.max(0, segment.length - openingDepth),
segment.length,
),
)
if (flightRect) openingRects.push(expandRect(flightRect, openingOffset))
}
@@ -452,7 +516,9 @@ function getStraightOpeningPolygonsForSurface(
}
const landingRects: AxisAlignedRect[] = []
const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length))
const landingRect = getAxisAlignedRectFromPolygon(
getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length),
)
if (landingRect) landingRects.push(expandRect(landingRect, openingOffset))
const previous = layouts[index - 1]
if (previous?.segment.segmentType === 'stair') {
@@ -556,10 +622,18 @@ function getTargetCeilingElevationForStair(
return ceiling.height ?? DEFAULT_WALL_HEIGHT
}
return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0)
return (
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
(stair.position[1] ?? 0)
)
}
function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record<string, AnyNode>) {
function shouldApplyStairToSlab(
stair: StairNode,
slabLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const toLevel = getLevelNumber(toLevelId, nodes)
@@ -578,7 +652,11 @@ function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Re
return slabLevel > minLevel && slabLevel <= maxLevel
}
function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record<string, AnyNode>) {
function shouldApplyStairToCeiling(
stair: StairNode,
ceilingLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const toLevel = getLevelNumber(toLevelId, nodes)
@@ -598,16 +676,22 @@ function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nod
}
export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false)
const stairs = Object.values(nodes).filter(
(node): node is StairNode => node.type === 'stair' && node.visible !== false,
)
const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab')
const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling')
const ceilings = Object.values(nodes).filter(
(node): node is CeilingNode => node.type === 'ceiling',
)
const updates: Array<{ id: AnyNodeId; data: Partial<SlabNode | CeilingNode> }> = []
for (const slab of slabs) {
const slabLevelId = resolveLevelId(slab, nodes)
const existingHoles = slab.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
const manualHoles = existingHoles.filter(
(_hole, index) => existingMetadata[index]?.source !== 'stair',
)
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
@@ -633,11 +717,15 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
},
})),
)
.filter((hole) => polygonContainsPolygon(slab.polygon, hole.polygon))
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
) {
updates.push({
id: slab.id,
data: {
@@ -652,7 +740,9 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const ceilingLevelId = resolveLevelId(ceiling, nodes)
const existingHoles = ceiling.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
const manualHoles = existingHoles.filter(
(_hole, index) => existingMetadata[index]?.source !== 'stair',
)
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
@@ -678,11 +768,15 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
},
})),
)
.filter((hole) => polygonContainsPolygon(ceiling.polygon, hole.polygon))
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
if (
!polygonsEqual(existingHoles, nextHoles) ||
!metadataEqual(existingMetadata, nextMetadata)
) {
updates.push({
id: ceiling.id,
data: {
+40 -3
View File
@@ -62,6 +62,26 @@ PASCAL_DATA_DIR="$HOME/.pascal/data" bun run dev
PASCAL_DATA_DIR="$HOME/.pascal/data" bun packages/mcp/dist/bin/pascal-mcp.js
```
## Live editor updates
When the editor and MCP server share the same `PASCAL_DATA_DIR`, MCP mutations
against a loaded saved scene are persisted to SQLite and recorded in a local
`scene_events` stream. The editor page subscribes to that stream at
`/api/scenes/:id/events` with server-sent events, so an open browser tab can
apply scene graph snapshots as the agent edits the scene.
The flow is intentionally local and lightweight:
1. Open or create a scene in the editor so it is saved in the local database.
2. Load that scene through MCP with `load_scene`.
3. Run MCP mutation tools such as `create_room`, `add_door`, `furnish_room`,
`create_wall`, `place_item`, or `set_zone`.
Each mutation version-checks the saved scene before writing. If the browser or
another MCP process saved a newer version first, the MCP tool returns
`live_sync_version_conflict`; reload the scene with `load_scene` before
continuing.
## Claude Desktop config
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -211,12 +231,24 @@ captured by Zundo's temporal middleware as a single undoable step.
| `get_node` | Fetch a node by id. | `{ id }` | the node, or `InvalidParams` if not found |
| `describe_node` | Node summary with ancestry, children count and properties. | `{ id }` | `{ id, type, parentId, ancestry[], childrenCount, properties, description }` |
| `find_nodes` | Filter nodes by type / parent / zone / level. | `{ type?, parentId?, zoneId?, levelId? }` | `{ nodes: AnyNode[] }` |
| `list_levels` | List levels with ids, floor indices, parent ids and child counts. | — | `{ activeSceneId, levels[] }` |
| `get_level_summary` | Compact summary of one level with counts, wall/opening lists, zones, slabs, ceilings and items. | `{ levelId? }` | `{ levelId, counts, walls, zones, items, slabs, ceilings }` |
| `get_walls` | Walls on a level with length and child doors/windows. | `{ levelId? }` | `{ levelId, walls[] }` |
| `get_zones` | Room/zone polygons with approximate areas and bounds. | `{ levelId? }` | `{ levelId, zones[] }` |
| `measure` | Distance between two nodes; area when applicable. | `{ fromId, toId }` | `{ distanceMeters, areaSqMeters?, units: 'meters' }` |
| `search_assets` | Search the built-in MCP item catalog. | `{ query, category? }` | `{ results, total }` |
| `create_story_shell` | Create one level-owned story shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story. | `{ levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? }` | `{ wallIds, slabId, ceilingId, createdIds }` |
| `create_stair_between_levels` | Create a straight stair and one rectangular manual opening in the destination slab/source ceiling, with auto-opening disabled. | `{ fromLevelId, toLevelId, position, width?, runLength?, totalRise? }` | `{ stairId, stairSegmentId, openingPolygon }` |
| `create_roof` | Create a roof container and one roof segment. By default creates a dedicated roof level above the reference occupied level for solo/exploded views. | `{ levelId, width, depth, roofType?, roofHeight?, roofLevelId?, useDedicatedRoofLevel? }` | `{ roofLevelId, createdRoofLevelId, roofId, roofSegmentId }` |
| `create_room` | Create a zone, slab, ceiling, and walls from a polygon. | `{ levelId, name, polygon, color?, wallHeight?, wallThickness? }` | `{ zoneId, slabId, ceilingId, wallIds, areaSqMeters }` |
| `add_door` | Add a door to a wall using parametric placement. | `{ wallId, t, width?, height?, hingesSide?, swingDirection? }` | `{ doorId, localX }` |
| `add_window` | Add a window to a wall using parametric placement and sill height. | `{ wallId, t, width?, height?, sillHeight? }` | `{ windowId, localX, sillHeight }` |
| `furnish_room` | Place realistic furniture for a room type inside a polygon. | `{ levelId, roomType, polygon, doorWallIndex? }` | `{ placed, itemIds, skipped }` |
| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. | `{ patches: Patch[] }` | `{ applied: number }` |
| `create_level` | Add a new level to a building. | `{ buildingId, elevation, height, label? }` | `{ levelId }` |
| `create_wall` | Add a wall to a level. | `{ levelId, start, end, thickness?, height? }` | `{ wallId }` |
| `place_item` | Place a catalog item on a slab, ceiling, or wall with placement validation. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId }` or `{ error: 'invalid_placement', reason }` |
| `cut_opening` | Cut a door or window opening into a wall. | `{ wallId, type: 'door' \| 'window', position, width, height }` | `{ openingId }` |
| `place_item` | Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId, status }` |
| `cut_opening` | Cut a door or window opening into a wall. `position` is 0..1 along the wall and is stored as wall-local meters. | `{ wallId, type: 'door' \| 'window', position, width, height }` | `{ openingId }` |
| `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` |
| `duplicate_level` | Clone a level and all of its descendants. | `{ levelId }` | `{ newLevelId, newNodeIds[] }` |
| `delete_node` | Delete a node; cascades when `cascade: true`. | `{ id, cascade? }` | `{ deletedIds: [] }` |
@@ -225,6 +257,7 @@ captured by Zundo's temporal middleware as a single undoable step.
| `export_json` | Serialize the scene graph as JSON. | `{ pretty? }` | `{ json: string }` |
| `export_glb` | Stubbed: GLB export requires the browser renderer. | — | throws `not_implemented` |
| `validate_scene` | Zod-validate every node and parent-child integrity. | — | `{ valid, errors: { nodeId, path, message }[] }` |
| `verify_scene` | High-level layout check with validation status, per-level counts, empty levels and practical issues. | — | `{ valid, levels[], issues, hasIssues }` |
| `check_collisions` | Find overlapping items and out-of-bounds placements. | `{ levelId? }` | `{ collisions: { aId, bId, kind }[] }` |
| `analyze_floorplan_image` | Vision tool: extract walls, rooms, and approximate dimensions from a floorplan image. | `{ image, scaleHint? }` | `{ walls, rooms, approximateDimensions, confidence }` |
| `analyze_room_photo` | Vision tool: extract approximate dimensions and fixtures from a room photo. | `{ image }` | `{ approximateDimensions, identifiedFixtures, identifiedWindows }` |
@@ -239,7 +272,8 @@ The vision tools require the MCP host to support the sampling capability
| --- | --- | --- |
| `pascal://scene/current` | `application/json` | Full `{ nodes, rootNodeIds, collections }` snapshot. |
| `pascal://scene/current/summary` | `text/markdown` | Human-readable summary with node counts, bounding box, and level areas. |
| `pascal://catalog/items` | `application/json` | Item catalog; returns `{ status: 'catalog_unavailable', items: [] }` in headless mode if no catalog is provided. |
| `pascal://agent/guide` | `text/markdown` | MCP-first construction workflow, scene invariants, and tool preferences for agents. |
| `pascal://catalog/items` | `application/json` | Dependency-free built-in catalog subset for common residential furniture and fixtures. |
| `pascal://constraints/{levelId}` | `application/json` | Slab footprints and wall polygons for the given level — useful as planner context. |
## Prompts
@@ -256,6 +290,9 @@ The vision tools require the MCP host to support the sampling capability
renderer and isn't reachable headlessly without a large additional effort.
- Vision tools require MCP host sampling support. Claude Desktop supports
this; some MCP clients don't.
- The built-in MCP catalog is intentionally small. Host applications can expose
their own richer catalog through additional tools/resources without requiring
the MCP package to depend on the editor UI bundle.
- Systems (wall mitering, slab triangulation, CSG cutouts, roof / stair
generation) run inside React hooks in the editor. Headless mode doesn't
regenerate derived geometry — but all node data remains fully manipulable.
+30
View File
@@ -6,6 +6,7 @@ import type { AnyNode } from '@pascal-app/core/schema'
import { type AnyNodeId, AnyNode as AnyNodeSchema, type AnyNodeType } from '@pascal-app/core/schema'
// Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`.
import useScene from '@pascal-app/core/store'
import type { SceneMeta } from '../storage/types'
export type ValidationError = { nodeId: string; path: string; message: string }
export type ValidationResult = { valid: boolean; errors: ValidationError[] }
@@ -14,6 +15,10 @@ export type CreatePatch = { op: 'create'; node: AnyNode; parentId?: AnyNodeId }
export type UpdatePatch = { op: 'update'; id: AnyNodeId; data: Partial<AnyNode> }
export type DeletePatch = { op: 'delete'; id: AnyNodeId; cascade?: boolean }
export type Patch = CreatePatch | UpdatePatch | DeletePatch
export type ActiveSceneMeta = Pick<
SceneMeta,
'id' | 'name' | 'projectId' | 'ownerId' | 'thumbnailUrl' | 'version'
>
/**
* Headless bridge to the `@pascal-app/core` Zustand store.
@@ -23,6 +28,31 @@ export type Patch = CreatePatch | UpdatePatch | DeletePatch
* `flushDirty()` for observability.
*/
export class SceneBridge {
private activeScene: ActiveSceneMeta | null = null
/**
* Scene identity currently bound to this bridge. MCP tools use this to know
* which editor scene should receive live events after mutations.
*/
setActiveScene(meta: ActiveSceneMeta): void {
this.activeScene = {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
ownerId: meta.ownerId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
}
}
getActiveScene(): ActiveSceneMeta | null {
return this.activeScene
}
clearActiveScene(): void {
this.activeScene = null
}
/** Load initial state; if empty, creates default Site → Building → Level. */
loadDefault(): void {
useScene.getState().loadScene()
+7 -4
View File
@@ -1,17 +1,20 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are a Pascal 3D scene designer.',
'You have access to the `apply_patch` tool for all scene mutations. Prefer it over individual create_* tools so that your changes land as a single undoable step.',
'Build incrementally. Starting from an empty scene, first create a Site, then a Building, then one or more Levels; only after that do you create walls, zones, slabs, items, and openings.',
'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.',
'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.',
'Respect these invariants:',
' - Levels live under a Building.',
' - Walls, fences, zones, slabs, ceilings, roofs, stairs live under a Level.',
' - Multi-story exterior walls are per-level story walls; never make lower-level walls taller to stand in for upper-level walls.',
' - Doors and windows live under a Wall (parentId = wallId).',
' - Items live under a Wall, Ceiling, or Site.',
' - Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling; outdoor items can live under a Site.',
'Use realistic dimensions in meters. Keep wall thickness small (0.10.3 m) and ceiling height 2.43.0 m unless the brief dictates otherwise.',
SCENE_DESIGN_GUIDANCE,
'Respond ONLY with tool calls. Do not produce verbose narrative or prose; keep any explanations in short tool-call arguments.',
].join('\n')
@@ -29,7 +32,7 @@ export function buildFromBriefPrompt(args: {
parts.push(
'',
'## Task',
'Produce a plan of `apply_patch` calls that realises the brief within the stated constraints. Start from an empty site. Call the vision / query tools only if you need extra context.',
'Produce tool calls that realise the brief within the stated constraints. Start from an empty site. Prefer create_story_shell/create_room/add_door/add_window/create_stair_between_levels/create_roof/furnish_room for architectural layout, use apply_patch for exact bulk graph work, and call validate_scene plus verify_scene after complex layouts.',
)
return parts.join('\n')
}
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are iterating on an existing Pascal scene based on user feedback.',
@@ -9,7 +10,10 @@ const PREAMBLE = [
' - Prefer updates over create+delete pairs when a field change will do.',
' - Do not re-create nodes that already exist.',
' - Do not touch nodes that are unrelated to the feedback.',
' - Prefer semantic tools such as create_room, add_door, add_window, furnish_room, and place_item when they match the request.',
' - Bundle related mutations into a single `apply_patch` call so they share one undo step.',
' - For multi-room changes, call verify_scene after the mutation and fix reported issues.',
SCENE_DESIGN_GUIDANCE,
' - Respond ONLY with tool calls. No prose.',
].join('\n')
+3
View File
@@ -62,6 +62,9 @@ describe('from_brief', () => {
if (m.content.type === 'text') {
expect(m.content.text).toContain('60 sqm studio')
expect(m.content.text).toContain('apply_patch')
expect(m.content.text).toContain('create_story_shell')
expect(m.content.text).toContain('pascal://agent/guide')
expect(m.content.text).toContain('dedicated roof level')
}
} finally {
await pair.close()
@@ -0,0 +1,22 @@
export const SCENE_DESIGN_GUIDANCE = [
'Scene design workflow:',
' - Use meters. X/Z are horizontal floor-plan axes; Y is vertical.',
' - Read `pascal://agent/guide` when you need construction rules; do not inspect repository code for ordinary scene editing.',
' - Door default: 0.9m wide by 2.1m high, floor-mounted.',
' - Window default: 1.5m wide by 1.5m high with a 0.9m sill height.',
' - 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.',
'',
'Preferred phased tool workflow:',
' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.',
' - Create visible massing early: create_level as needed, then create_story_shell once per story.',
' - 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.',
' - 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.',
' - After each major phase, call get_level_summary or pascal://scene/current/summary so progress is visible and errors are easier to localize.',
' - After multi-room or full-floor work, call validate_scene and verify_scene, then fix reported issues before finishing.',
].join('\n')
+67
View File
@@ -0,0 +1,67 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
export const AGENT_GUIDE = [
'# Pascal MCP agent guide',
'',
'Use this guide before inspecting application source code. The MCP surface is intended to expose the construction contract an agent needs for normal scene editing.',
'',
'## Fast visible-progress workflow',
'',
'1. Query `pascal://scene/current/summary` or `list_levels` to orient yourself.',
'2. Create visible massing first: `create_level` as needed, then `create_story_shell` once per story.',
'3. Add room semantics next: zones/rooms, interior walls, slabs, and ceilings. Prefer `create_room` for simple rooms and `apply_patch` only for exact multi-room partitions.',
'4. Add circulation and envelope details: `create_stair_between_levels`, then `add_door` and `add_window`.',
'5. Add `create_roof`, furniture with `furnish_room`/`place_item`, and exterior features such as fences, patios, driveways, lawns, and garden zones.',
'6. Run `validate_scene` and `verify_scene`; fix issues before handing off.',
'',
'This sequence lets users see a recognizable building quickly instead of waiting for one large hidden planning pass.',
'',
'## Construction rules',
'',
'- Levels live under a Building.',
'- Walls, fences, zones, slabs, ceilings, roofs, and stairs live under a Level.',
'- Doors and windows live under their Wall. Use `add_door`/`add_window`; their `t` or `position` is 0..1 along the wall.',
'- Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling.',
'- For multi-story buildings, create separate level-owned exterior walls for each story. Do not make first-story walls taller to represent upper-story bearing walls.',
'- 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.',
'- 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',
'',
'- X/Z are floor-plan axes and Y is vertical; dimensions are meters.',
'- 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.',
'- 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.',
'',
'## Tool preference',
'',
'- Prefer semantic tools first: `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_stair_between_levels`, `create_roof`, `furnish_room`, `place_item`.',
'- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.',
].join('\n')
export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void {
server.registerResource(
'agent-guide',
'pascal://agent/guide',
{
title: 'Agent construction guide',
description:
'MCP-first construction workflow, scene invariants, and tool preferences so agents do not need to inspect the Pascal codebase.',
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: AGENT_GUIDE,
},
],
}),
)
}
+8 -9
View File
@@ -1,13 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog'
/**
* `pascal://catalog/items` — item catalog (if the host supplies one).
* `pascal://catalog/items` — small built-in item catalog for standalone MCP.
*
* `@pascal-app/core` does NOT expose a runtime item catalog — that is the host
* app's responsibility. In headless / standalone MCP mode we therefore return
* a stable, machine-readable "unavailable" payload so agents can detect this
* and fall back to free-form item creation.
* The editor UI owns the full catalog. MCP intentionally keeps a dependency-free
* subset so headless agents can still place realistic furniture and fixtures.
*/
export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void {
server.registerResource(
@@ -16,14 +15,14 @@ export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): v
{
title: 'Item catalog',
description:
'Catalog of placeable items. Not available in core; the host app is expected to override this resource when it has a catalog.',
'Dependency-free catalog subset of placeable items available in standalone MCP mode.',
mimeType: 'application/json',
},
async (uri) => {
const payload = {
status: 'catalog_unavailable' as const,
items: [] as never[],
note: '@pascal-app/core does not ship a runtime item catalog; the host app is expected to provide one by overriding this resource.',
status: 'ok' as const,
items: MCP_CATALOG_ITEMS,
note: 'Standalone MCP catalog subset; host applications can still expose a larger catalog separately.',
}
return {
contents: [
+3
View File
@@ -1,5 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
@@ -13,8 +14,10 @@ import { registerSceneSummary } from './scene-summary'
* - `pascal://scene/current/summary` — text/markdown, human summary
* - `pascal://catalog/items` — application/json, host-supplied catalog
* - `pascal://constraints/{levelId}` — application/json, per-level constraints
* - `pascal://agent/guide` — text/markdown, MCP-first construction guide
*/
export function registerResources(server: McpServer, bridge: SceneBridge): void {
registerAgentGuide(server, bridge)
registerSceneCurrent(server, bridge)
registerSceneSummary(server, bridge)
registerCatalogItems(server, bridge)
+26 -3
View File
@@ -8,6 +8,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { WallNode, ZoneNode } from '@pascal-app/core/schema'
import useScene from '@pascal-app/core/store'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
@@ -183,15 +184,16 @@ describe('pascal://scene/current/summary', () => {
describe('pascal://catalog/items', () => {
beforeEach(() => resetScene())
test('returns catalog_unavailable payload', async () => {
test('returns built-in catalog subset', async () => {
const pair = await spinUp(registerCatalogItems)
try {
const res = await pair.client.readResource({ uri: 'pascal://catalog/items' })
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('application/json')
const parsed = JSON.parse(content.text ?? '{}')
expect(parsed.status).toBe('catalog_unavailable')
expect(parsed.items).toEqual([])
expect(parsed.status).toBe('ok')
expect(parsed.items.length).toBeGreaterThan(0)
expect(parsed.items.map((item: { id: string }) => item.id)).toContain('sofa')
expect(typeof parsed.note).toBe('string')
} finally {
await pair.close()
@@ -199,6 +201,27 @@ describe('pascal://catalog/items', () => {
})
})
describe('pascal://agent/guide', () => {
beforeEach(() => resetScene())
test('returns MCP-first construction guidance', async () => {
const pair = await spinUp(registerAgentGuide)
try {
const res = await pair.client.readResource({ uri: 'pascal://agent/guide' })
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('text/markdown')
const text = content.text ?? ''
expect(text).toContain('create_story_shell')
expect(text).toContain('create_stair_between_levels')
expect(text).toContain('dedicated roof level')
expect(text).toContain('Do not make first-story walls taller')
expect(text).toContain('Run `validate_scene` and `verify_scene`')
} finally {
await pair.close()
}
})
})
describe('pascal://constraints/{levelId}', () => {
beforeEach(() => resetScene())
+17
View File
@@ -4,6 +4,9 @@ import { registerPrompts } from './prompts'
import { registerResources } from './resources'
import { createSceneStore } from './storage'
import type {
SceneEvent,
SceneEventAppendOptions,
SceneEventListOptions,
SceneListOptions,
SceneMeta,
SceneMutateOptions,
@@ -70,5 +73,19 @@ function createLazySceneStore(): SceneStore {
const real = await resolve()
return real.rename(id, newName, options)
},
async appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent> {
const real = await resolve()
if (!real.appendSceneEvent) {
throw new Error('scene_events_unavailable')
}
return real.appendSceneEvent(options)
},
async listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]> {
const real = await resolve()
if (!real.listSceneEvents) {
throw new Error('scene_events_unavailable')
}
return real.listSceneEvents(id, options)
},
}
}
@@ -226,6 +226,53 @@ describe('SqliteSceneStore', () => {
}
})
test('appends and lists scene events in order', async () => {
const graph = makeGraph()
const meta = await store.save({ id: 'live', name: 'Live', graph })
const first = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'save_scene',
graph,
})
const updatedGraph = makeGraph({
nodes: {
...graph.nodes,
wall_new: {
object: 'node',
id: 'wall_new',
type: 'wall',
parentId: 'building_def',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [1, 0],
thickness: 0.1,
height: 2.5,
frontSide: 'unknown',
backSide: 'unknown',
},
} as SceneGraph['nodes'],
})
const second = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'create_wall',
graph: updatedGraph,
})
expect(second.eventId).toBeGreaterThan(first.eventId)
expect((await store.listSceneEvents('live')).map((event) => event.kind)).toEqual([
'save_scene',
'create_wall',
])
const afterFirst = await store.listSceneEvents('live', { afterEventId: first.eventId })
expect(afterFirst).toHaveLength(1)
expect(afterFirst[0]!.eventId).toBe(second.eventId)
expect(afterFirst[0]!.graph.nodes.wall_new).toBeDefined()
})
test('validates name and scene size', async () => {
await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
await expect(store.save({ name: 'x'.repeat(201), graph: makeGraph() })).rejects.toThrow(
@@ -6,6 +6,9 @@ import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver'
import {
type SceneEvent,
type SceneEventAppendOptions,
type SceneEventListOptions,
SceneInvalidError,
type SceneListOptions,
type SceneMeta,
@@ -46,6 +49,15 @@ interface SceneRow {
graph_json: string
}
interface SceneEventRow {
event_id: number
scene_id: string
version: number
kind: string
created_at: string
graph_json: string
}
const GraphSchema = z.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
@@ -170,6 +182,17 @@ function asSceneRow(value: unknown): SceneRow | null {
return value as SceneRow
}
function rowToSceneEvent(row: SceneEventRow): SceneEvent {
return {
eventId: Number(row.event_id),
sceneId: row.scene_id,
version: Number(row.version),
kind: row.kind,
createdAt: row.created_at,
graph: parseGraph(row.graph_json, `${row.scene_id}@${row.version}`),
}
}
/**
* SQLite-backed implementation of `SceneStore`.
*
@@ -397,6 +420,54 @@ export class SqliteSceneStore implements SceneStore {
})
}
async appendSceneEvent(opts: SceneEventAppendOptions): Promise<SceneEvent> {
return this.withWriteTransaction((db) => {
const safeId = sanitizeSlug(opts.sceneId)
const existing = this.getRow(db, safeId)
if (!existing) {
throw new SceneNotFoundError(`Scene "${safeId}" not found`)
}
const graphJson = serializeGraph(opts.graph)
const now = new Date().toISOString()
const result = db
.query(
`INSERT INTO scene_events (
scene_id, version, kind, created_at, graph_json
) VALUES (?, ?, ?, ?, ?)`,
)
.run(safeId, opts.version, opts.kind, now, graphJson)
return {
eventId: Number(result.lastInsertRowid),
sceneId: safeId,
version: opts.version,
kind: opts.kind,
createdAt: now,
graph: opts.graph,
}
})
}
async listSceneEvents(sceneId: string, opts: SceneEventListOptions = {}): Promise<SceneEvent[]> {
const afterEventId = Math.max(0, opts.afterEventId ?? 0)
const requestedLimit = opts.limit ?? 100
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100
const db = await this.database()
const rows = db
.query(
`SELECT event_id, scene_id, version, kind, created_at, graph_json
FROM scene_events
WHERE scene_id = ?
AND event_id > ?
ORDER BY event_id ASC
LIMIT ?`,
)
.all(sanitizeSlug(sceneId), afterEventId, limit)
return rows.map((row) => rowToSceneEvent(row as SceneEventRow))
}
close(): void {
this.db?.close()
this.db = null
@@ -452,6 +523,19 @@ export class SqliteSceneStore implements SceneStore {
PRIMARY KEY (scene_id, version),
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS scene_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version >= 1),
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
graph_json TEXT NOT NULL,
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS scene_events_scene_event_idx
ON scene_events(scene_id, event_id);
`)
}
+23
View File
@@ -25,6 +25,15 @@ export interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}
export interface SceneEvent {
eventId: number
sceneId: SceneId
version: number
kind: string
createdAt: string
graph: SceneGraph
}
export interface SceneSaveOptions {
id?: SceneId
name: string
@@ -46,6 +55,18 @@ export interface SceneMutateOptions {
expectedVersion?: number
}
export interface SceneEventAppendOptions {
sceneId: SceneId
version: number
kind: string
graph: SceneGraph
}
export interface SceneEventListOptions {
afterEventId?: number
limit?: number
}
export interface SceneStore {
readonly backend: 'sqlite'
save(opts: SceneSaveOptions): Promise<SceneMeta>
@@ -53,6 +74,8 @@ export interface SceneStore {
list(opts?: SceneListOptions): Promise<SceneMeta[]>
delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean>
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
appendSceneEvent?(opts: SceneEventAppendOptions): Promise<SceneEvent>
listSceneEvents?(sceneId: SceneId, opts?: SceneEventListOptions): Promise<SceneEvent[]>
}
export class SceneNotFoundError extends Error {
+50 -1
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { WallNode } from '@pascal-app/core/schema'
import { LevelNode, SlabNode, StairNode, StairSegmentNode, WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerApplyPatch } from './apply-patch'
@@ -45,6 +45,55 @@ describe('apply_patch', () => {
expect((stored as { thickness?: number }).thickness).toBe(0.2)
})
test('syncs derived stair openings after stair patches', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const upper = LevelNode.parse({ name: 'Upper Floor', level: 1 })
const upperSlab = SlabNode.parse({
name: 'Upper Floor Slab',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const segment = StairSegmentNode.parse({
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
name: 'Main Stair',
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
openingOffset: 0.1,
children: [segment.id],
})
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [
{ op: 'create', node: upper, parentId: building.id },
{ op: 'create', node: upperSlab, parentId: upper.id },
{ op: 'create', node: stair, parentId: ground.id },
{ op: 'create', node: segment, parentId: stair.id },
],
},
})
expect(result.isError).toBeFalsy()
const slab = bridge.getNode(upperSlab.id)
expect(slab?.type).toBe('slab')
if (slab?.type !== 'slab') return
expect(slab.holes).toHaveLength(1)
expect(slab.holeMetadata[0]).toEqual({ source: 'stair', stairId: stair.id })
})
test('rejects update to a non-existent node', async () => {
const result = await client.callTool({
name: 'apply_patch',
+8 -1
View File
@@ -2,7 +2,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { PatchSchema } from './schemas'
export const applyPatchInput = {
@@ -15,7 +17,11 @@ export const applyPatchOutput = {
createdIds: z.array(z.string()),
}
export function registerApplyPatch(server: McpServer, bridge: SceneBridge): void {
export function registerApplyPatch(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'apply_patch',
{
@@ -50,6 +56,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneBridge): void
try {
const result = bridge.applyPatch(bridgePatches)
await publishLiveSceneSnapshot(bridge, store, 'apply_patch')
const payload = {
appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[],
+315
View File
@@ -0,0 +1,315 @@
import type { AssetInput } from '@pascal-app/core/schema'
/**
* Small built-in catalog for standalone/headless MCP use.
*
* The editor has a much larger UI catalog, but depending on `@pascal-app/editor`
* from the MCP package would pull browser/React code into the headless server.
* These entries mirror the stable IDs and asset paths used by the editor for
* common AI-generated residential layouts.
*/
export const MCP_CATALOG_ITEMS: AssetInput[] = [
{
id: 'double-bed',
category: 'furniture',
tags: ['floor', 'bedroom'],
name: 'Double Bed',
thumbnail: '/items/double-bed/thumbnail.webp',
src: '/items/double-bed/model.glb',
dimensions: [2, 0.8, 2.5],
offset: [0, 0, -0.03],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'single-bed',
category: 'furniture',
tags: ['floor', 'bedroom'],
name: 'Single Bed',
thumbnail: '/items/single-bed/thumbnail.webp',
src: '/items/single-bed/model.glb',
dimensions: [1.5, 0.7, 2.5],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'bedside-table',
category: 'furniture',
tags: ['floor', 'bedroom'],
name: 'Bedside Table',
thumbnail: '/items/bedside-table/thumbnail.webp',
src: '/items/bedside-table/model.glb',
dimensions: [0.5, 0.5, 0.5],
offset: [0, 0, -0.01],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.5 },
},
{
id: 'dresser',
category: 'furniture',
tags: ['floor', 'storage', 'bedroom'],
name: 'Dresser',
thumbnail: '/items/dresser/thumbnail.webp',
src: '/items/dresser/model.glb',
dimensions: [1.5, 0.8, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.8 },
},
{
id: 'closet',
category: 'furniture',
tags: ['floor', 'storage', 'bedroom'],
name: 'Closet',
thumbnail: '/items/closet/thumbnail.webp',
src: '/items/closet/model.glb',
dimensions: [2, 2.5, 1],
offset: [0, 0, -0.01],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'sofa',
category: 'furniture',
tags: ['floor', 'seating', 'living'],
name: 'Sofa',
thumbnail: '/items/sofa/thumbnail.webp',
src: '/items/sofa/model.glb',
dimensions: [2.5, 0.8, 1.5],
offset: [0, 0, 0.04],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'livingroom-chair',
category: 'furniture',
tags: ['floor', 'seating', 'living'],
name: 'Livingroom Chair',
thumbnail: '/items/livingroom-chair/thumbnail.webp',
src: '/items/livingroom-chair/model.glb',
dimensions: [1.5, 0.8, 1.5],
offset: [0.01, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'coffee-table',
category: 'furniture',
tags: ['floor', 'table', 'living'],
name: 'Coffee Table',
thumbnail: '/items/coffee-table/thumbnail.webp',
src: '/items/coffee-table/model.glb',
dimensions: [2, 0.4, 1.5],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.3 },
},
{
id: 'tv-stand',
category: 'furniture',
tags: ['floor', 'storage', 'living'],
name: 'TV Stand',
thumbnail: '/items/tv-stand/thumbnail.webp',
src: '/items/tv-stand/model.glb',
dimensions: [2, 0.4, 0.5],
offset: [0, 0.21, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.36 },
},
{
id: 'shelf',
category: 'furniture',
tags: ['wall', 'storage'],
name: 'Shelf',
thumbnail: '/items/shelf/thumbnail.webp',
src: '/items/shelf/model.glb',
dimensions: [1, 0.5, 0.7],
offset: [0, 0.1, 0.01],
rotation: [0, 0, 0],
scale: [1, 1, 1],
attachTo: 'wall-side',
surface: { height: 0.12 },
},
{
id: 'dining-table',
category: 'furniture',
tags: ['floor', 'table', 'dining'],
name: 'Dining Table',
thumbnail: '/items/dining-table/thumbnail.webp',
src: '/items/dining-table/model.glb',
dimensions: [2.5, 0.8, 1],
offset: [0, 0, -0.01],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.8 },
},
{
id: 'dining-chair',
category: 'furniture',
tags: ['floor', 'seating', 'dining'],
name: 'Dining Chair',
thumbnail: '/items/dining-chair/thumbnail.webp',
src: '/items/dining-chair/model.glb',
dimensions: [0.5, 1, 0.5],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'kitchen',
category: 'kitchen',
tags: ['floor', 'large', 'kitchen'],
name: 'Kitchen',
thumbnail: '/items/kitchen/thumbnail.webp',
src: '/items/kitchen/model.glb',
dimensions: [2.5, 1.1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'kitchen-counter',
category: 'kitchen',
tags: ['floor', 'large', 'storage', 'kitchen'],
name: 'Kitchen Counter',
thumbnail: '/items/kitchen-counter/thumbnail.webp',
src: '/items/kitchen-counter/model.glb',
dimensions: [2, 0.8, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 0.75 },
},
{
id: 'stove',
category: 'kitchen',
tags: ['floor', 'large', 'kitchen'],
name: 'Stove',
thumbnail: '/items/stove/thumbnail.webp',
src: '/items/stove/model.glb',
dimensions: [1, 1, 1],
offset: [0, 0, -0.05],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'fridge',
category: 'kitchen',
tags: ['floor', 'large', 'kitchen'],
name: 'Fridge',
thumbnail: '/items/fridge/thumbnail.webp',
src: '/items/fridge/model.glb',
dimensions: [1, 2, 1],
offset: [0.01, 0, -0.05],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'toilet',
category: 'bathroom',
tags: ['floor', 'large', 'bathroom'],
name: 'Toilet',
thumbnail: '/items/toilet/thumbnail.webp',
src: '/items/toilet/model.glb',
dimensions: [1, 0.9, 1],
offset: [0, 0, -0.23],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'bathroom-sink',
category: 'bathroom',
tags: ['floor', 'large', 'bathroom'],
name: 'Bathroom Sink',
thumbnail: '/items/bathroom-sink/thumbnail.webp',
src: '/items/bathroom-sink/model.glb',
dimensions: [2, 1, 1.5],
offset: [0.11, 0, 0.02],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'shower-square',
category: 'bathroom',
tags: ['floor', 'large', 'bathroom'],
name: 'Squared Shower',
thumbnail: '/items/shower-square/thumbnail.webp',
src: '/items/shower-square/model.glb',
dimensions: [1, 2, 1],
offset: [0.41, 0, -0.42],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'bathtub',
category: 'bathroom',
tags: ['floor', 'large', 'bathroom'],
name: 'Bathtub',
thumbnail: '/items/bathtub/thumbnail.webp',
src: '/items/bathtub/model.glb',
dimensions: [2.5, 0.8, 1.5],
offset: [0, 0, 0.01],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'washing-machine',
category: 'bathroom',
tags: ['floor', 'large', 'electronics', 'laundry'],
name: 'Washing Machine',
thumbnail: '/items/washing-machine/thumbnail.webp',
src: '/items/washing-machine/model.glb',
dimensions: [1, 1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'drying-rack',
category: 'bathroom',
tags: ['floor', 'laundry'],
name: 'Drying Rack',
thumbnail: '/items/drying-rack/thumbnail.webp',
src: '/items/drying-rack/model.glb',
dimensions: [2, 1.1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
{
id: 'coat-rack',
category: 'furniture',
tags: ['floor', 'storage', 'entry'],
name: 'Coat Rack',
thumbnail: '/items/coat-rack/thumbnail.webp',
src: '/items/coat-rack/model.glb',
dimensions: [0.5, 1.8, 0.5],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
]
export function findCatalogItem(id: string): AssetInput | undefined {
return MCP_CATALOG_ITEMS.find((item) => item.id === id)
}
export function searchCatalogItems(args: {
query: string
category?: string | undefined
}): AssetInput[] {
const terms = args.query.trim().toLowerCase().split(/\s+/).filter(Boolean)
return MCP_CATALOG_ITEMS.filter((item) => {
if (args.category && item.category !== args.category) return false
const haystack = [item.id, item.name, item.category, ...(item.tags ?? [])]
.join(' ')
.toLowerCase()
return terms.every((term) => haystack.includes(term))
})
}
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LevelNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerSceneQueryTools } from './scene-query'
import { registerConstructionTools } from './construction-tools'
describe('construction tools', () => {
let client: Client
let server: McpServer
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
server = new McpServer({ name: 'test', version: '0.0.0' })
registerConstructionTools(server, bridge)
registerSceneQueryTools(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
})
afterEach(async () => {
await client.close()
await server.close()
})
test('create_story_shell creates level-owned walls plus slab and ceiling', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_story_shell',
arguments: {
levelId: level.id,
footprint: [
[-4, -3],
[4, -3],
[4, 3],
[-4, 3],
],
wallHeight: 2.8,
namePrefix: 'Ground',
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.wallIds).toHaveLength(4)
expect(parsed.slabId).toMatch(/^slab_/)
expect(parsed.ceilingId).toMatch(/^ceiling_/)
for (const wallId of parsed.wallIds) {
const wall = bridge.getNode(wallId)
expect(wall?.parentId).toBe(level.id)
expect(wall?.type).toBe('wall')
if (wall?.type === 'wall') expect(wall.height).toBe(2.8)
}
expect(bridge.validateScene().valid).toBe(true)
})
test('create_stair_between_levels creates one rectangular manual opening', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } })
bridge.createNode(upper, building.id)
for (const level of [ground, upper]) {
const result = await client.callTool({
name: 'create_story_shell',
arguments: {
levelId: level.id,
footprint: [
[-4, -3],
[4, -3],
[4, 3],
[-4, 3],
],
wallHeight: 2.8,
},
})
expect(result.isError).toBeFalsy()
}
const result = await client.callTool({
name: 'create_stair_between_levels',
arguments: {
fromLevelId: ground.id,
toLevelId: upper.id,
position: [0, 0, -1],
width: 1,
runLength: 3,
totalRise: 2.8,
openingOffset: 0.2,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.openingPolygon).toHaveLength(4)
const stair = bridge.getNode(parsed.stairId)
expect(stair?.type).toBe('stair')
if (stair?.type === 'stair') expect(stair.slabOpeningMode).toBe('none')
const destinationSlab = bridge.getNode(parsed.destinationSlabId)
expect(destinationSlab?.type).toBe('slab')
if (destinationSlab?.type === 'slab') {
expect(destinationSlab.holes).toHaveLength(1)
expect(destinationSlab.holes[0]).toHaveLength(4)
expect(destinationSlab.holeMetadata).toEqual([{ source: 'manual' }])
}
const sourceCeiling = bridge.getNode(parsed.sourceCeilingId)
expect(sourceCeiling?.type).toBe('ceiling')
if (sourceCeiling?.type === 'ceiling') {
expect(sourceCeiling.holes).toHaveLength(1)
expect(sourceCeiling.holes[0]).toHaveLength(4)
expect(sourceCeiling.holeMetadata).toEqual([{ source: 'manual' }])
}
expect(bridge.validateScene().valid).toBe(true)
})
test('verify_scene flags suspicious multi-story wall heights', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } })
bridge.createNode(upper, building.id)
const shell = await client.callTool({
name: 'create_story_shell',
arguments: {
levelId: ground.id,
footprint: [
[-4, -3],
[4, -3],
[4, 3],
[-4, 3],
],
wallHeight: 5.6,
},
})
expect(shell.isError).toBeFalsy()
const result = await client.callTool({ name: 'verify_scene', arguments: {} })
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.hasIssues).toBe(true)
expect(parsed.issues.join('\n')).toContain('multi-story exterior walls should be split')
})
test('create_roof creates a dedicated roof level by default', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_roof',
arguments: { levelId: level.id, width: 8, depth: 6, roofType: 'gable' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
const roofLevel = bridge.getNode(parsed.roofLevelId)
const roof = bridge.getNode(parsed.roofId)
const segment = bridge.getNode(parsed.roofSegmentId)
expect(parsed.createdRoofLevelId).toBe(parsed.roofLevelId)
expect(roofLevel?.parentId).toBe(building.id)
expect(roofLevel?.type).toBe('level')
if (roofLevel?.type === 'level') {
expect(roofLevel.level).toBe(level.type === 'level' ? level.level + 1 : 1)
expect(roofLevel.metadata).toMatchObject({ role: 'roof', referenceLevelId: level.id })
}
expect(roof?.parentId).toBe(parsed.roofLevelId)
expect(roof?.type).toBe('roof')
expect(segment?.parentId).toBe(parsed.roofId)
expect(segment?.type).toBe('roof-segment')
expect(bridge.validateScene().valid).toBe(true)
})
test('verify_scene flags roofs mixed into occupied levels', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
await client.callTool({
name: 'create_story_shell',
arguments: {
levelId: level.id,
footprint: [
[-4, -3],
[4, -3],
[4, 3],
[-4, 3],
],
},
})
const roof = await client.callTool({
name: 'create_roof',
arguments: {
levelId: level.id,
width: 8,
depth: 6,
useDedicatedRoofLevel: false,
},
})
expect(roof.isError).toBeFalsy()
const result = await client.callTool({ name: 'verify_scene', arguments: {} })
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.hasIssues).toBe(true)
expect(parsed.issues.join('\n')).toContain('dedicated roof level')
})
})
@@ -0,0 +1,498 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import {
CeilingNode,
LevelNode,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const
const RAILING_MODES = ['none', 'left', 'right', 'both'] as const
export const createStoryShellInput = {
levelId: NodeIdSchema,
footprint: z.array(Vec2Schema).min(3),
wallHeight: z.number().positive().default(2.8),
wallThickness: z.number().positive().default(0.16),
createSlab: z.boolean().default(true),
createCeiling: z.boolean().default(true),
slabElevation: z.number().default(0.1),
ceilingHeight: z.number().positive().optional(),
namePrefix: z.string().optional(),
wallMaterialPreset: z.string().optional(),
slabMaterialPreset: z.string().optional(),
ceilingMaterialPreset: z.string().optional(),
}
export const createStoryShellOutput = {
levelId: z.string(),
wallIds: z.array(z.string()),
slabId: z.string().nullable(),
ceilingId: z.string().nullable(),
createdIds: z.array(z.string()),
}
export const createRoofInput = {
levelId: NodeIdSchema,
roofLevelId: NodeIdSchema.optional(),
useDedicatedRoofLevel: z.boolean().default(true),
roofLevelLabel: z.string().default('Roof'),
roofLevelElevation: z.number().optional(),
roofLevelHeight: z.number().positive().optional(),
center: Vec3Schema.optional(),
width: z.number().positive(),
depth: z.number().positive(),
roofType: z.enum(ROOF_TYPES).default('hip'),
roofHeight: z.number().positive().default(1.8),
wallHeight: z.number().min(0).default(0.35),
wallThickness: z.number().positive().default(0.16),
overhang: z.number().min(0).default(0.45),
materialPreset: z.string().optional(),
name: z.string().optional(),
}
export const createRoofOutput = {
referenceLevelId: z.string(),
roofLevelId: z.string(),
createdRoofLevelId: z.string().nullable(),
roofId: z.string(),
roofSegmentId: z.string(),
}
export const createStairBetweenLevelsInput = {
fromLevelId: NodeIdSchema,
toLevelId: NodeIdSchema,
position: Vec3Schema,
rotation: z.number().default(0),
width: z.number().positive().default(1),
runLength: z.number().positive().default(3),
totalRise: z.number().positive().default(2.8),
stepCount: z.number().int().positive().default(14),
railingMode: z.enum(RAILING_MODES).default('both'),
destinationSlabId: NodeIdSchema.optional(),
sourceCeilingId: NodeIdSchema.optional(),
createDestinationSlabOpening: z.boolean().default(true),
createSourceCeilingOpening: z.boolean().default(true),
openingWidth: z.number().positive().optional(),
openingLength: z.number().positive().optional(),
openingOffset: z.number().min(0).default(0.15),
openingCenter: Vec2Schema.optional(),
openingRotation: z.number().optional(),
materialPreset: z.string().optional(),
name: z.string().optional(),
}
export const createStairBetweenLevelsOutput = {
stairId: z.string(),
stairSegmentId: z.string(),
destinationSlabId: z.string().nullable(),
sourceCeilingId: z.string().nullable(),
openingPolygon: z.array(Vec2Schema),
}
function textResult<T extends Record<string, unknown>>(payload: T) {
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
function assertNode(bridge: SceneBridge, id: string, type: AnyNode['type']): AnyNode {
const node = bridge.getNode(id as AnyNodeId)
if (!node) throw new Error(`${type} not found: ${id}`)
if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`)
return node
}
function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId {
const building = bridge
.getAncestry(levelId as AnyNodeId)
.find((node) => node.type === 'building')
if (!building) {
throw new Error(`Building ancestor not found for level: ${levelId}`)
}
return building.id as AnyNodeId
}
function isRoofLevel(level: AnyNode): boolean {
return (
level.type === 'level' &&
typeof level.metadata === 'object' &&
level.metadata !== null &&
'role' in level.metadata &&
level.metadata.role === 'roof'
)
}
function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLevel: AnyNode): number {
const existing = bridge
.getChildren(buildingId)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level')
.map((level) => level.level)
const referenceIndex = referenceLevel.type === 'level' ? referenceLevel.level : 0
const candidate = referenceIndex + 1
return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate
}
function nodesOnLevel(bridge: SceneBridge, levelId: string): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
}
function firstNodeOnLevel(
bridge: SceneBridge,
levelId: string,
type: 'slab' | 'ceiling',
): AnyNode | null {
return nodesOnLevel(bridge, levelId).find((node) => node.type === type) ?? null
}
function rotatePoint(x: number, z: number, rotation: number): [number, number] {
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
return [x * cos + z * sin, -x * sin + z * cos]
}
function rectangularOpening(args: {
position: [number, number, number]
rotation: number
width: number
length: number
offset: number
center?: [number, number] | undefined
openingRotation?: number | undefined
}): [number, number][] {
const width = args.width + args.offset * 2
const length = args.length + args.offset * 2
const center: [number, number] = args.center ?? [
args.position[0],
args.position[2] + args.length / 2,
]
const rotation = args.openingRotation ?? args.rotation
const halfW = width / 2
const halfL = length / 2
const local: [number, number][] = [
[-halfW, -halfL],
[halfW, -halfL],
[halfW, halfL],
[-halfW, halfL],
]
return local.map(([x, z]) => {
const [rx, rz] = rotatePoint(x, z, rotation)
return [center[0] + rx, center[1] + rz]
})
}
function withHole(
surface: AnyNode & { type: 'slab' | 'ceiling' },
hole: [number, number][],
): Partial<AnyNode> {
return {
holes: [...(surface.holes ?? []), hole],
holeMetadata: [...(surface.holeMetadata ?? []), { source: 'manual' }],
} as Partial<AnyNode>
}
export function registerConstructionTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'create_story_shell',
{
title: 'Create story shell',
description:
'Create one level-owned building shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story; do not make first-floor walls span multiple stories.',
inputSchema: createStoryShellInput,
outputSchema: createStoryShellOutput,
},
async ({
levelId,
footprint,
wallHeight,
wallThickness,
createSlab,
createCeiling,
slabElevation,
ceilingHeight,
namePrefix,
wallMaterialPreset,
slabMaterialPreset,
ceilingMaterialPreset,
}) => {
assertNode(bridge, levelId, 'level')
const points = footprint as [number, number][]
const wallIds: string[] = []
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
for (let i = 0; i < points.length; i++) {
const wall = WallNode.parse({
name: namePrefix ? `${namePrefix} Wall ${i + 1}` : undefined,
start: points[i],
end: points[(i + 1) % points.length],
thickness: wallThickness,
height: wallHeight,
frontSide: 'exterior',
backSide: 'interior',
...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}),
metadata: { role: 'exterior', storyShell: true },
})
wallIds.push(wall.id)
patches.push({ op: 'create', node: wall, parentId: levelId as AnyNodeId })
}
let slabId: string | null = null
if (createSlab) {
const slab = SlabNode.parse({
name: namePrefix ? `${namePrefix} Slab` : undefined,
polygon: points,
elevation: slabElevation,
...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}),
metadata: { role: 'story-slab' },
})
slabId = slab.id
patches.push({ op: 'create', node: slab, parentId: levelId as AnyNodeId })
}
let ceilingId: string | null = null
if (createCeiling) {
const ceiling = CeilingNode.parse({
name: namePrefix ? `${namePrefix} Ceiling` : undefined,
polygon: points,
height: ceilingHeight ?? wallHeight,
...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}),
metadata: { role: 'story-ceiling' },
})
ceilingId = ceiling.id
patches.push({ op: 'create', node: ceiling, parentId: levelId as AnyNodeId })
}
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_story_shell')
return textResult({
levelId,
wallIds,
slabId,
ceilingId,
createdIds: result.createdIds as string[],
})
},
)
server.registerTool(
'create_roof',
{
title: 'Create roof',
description:
'Create a roof container with one roof segment. By default creates a dedicated roof level above the reference level so exploded/solo level views can isolate the roof.',
inputSchema: createRoofInput,
outputSchema: createRoofOutput,
},
async ({
levelId,
roofLevelId,
useDedicatedRoofLevel,
roofLevelLabel,
roofLevelElevation,
roofLevelHeight,
center,
width,
depth,
roofType,
roofHeight,
wallHeight,
wallThickness,
overhang,
materialPreset,
name,
}) => {
const referenceLevel = assertNode(bridge, levelId, 'level')
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
let targetRoofLevelId = levelId as AnyNodeId
let createdRoofLevelId: string | null = null
if (roofLevelId !== undefined) {
assertNode(bridge, roofLevelId, 'level')
targetRoofLevelId = roofLevelId as AnyNodeId
} else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) {
const buildingId = getBuildingIdForLevel(bridge, levelId)
const roofLevel = LevelNode.parse({
name: roofLevelLabel,
level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel),
children: [],
metadata: {
role: 'roof',
label: roofLevelLabel,
referenceLevelId: levelId,
height: roofLevelHeight ?? Math.max(wallHeight + roofHeight, 0.2),
},
})
targetRoofLevelId = roofLevel.id as AnyNodeId
createdRoofLevelId = roofLevel.id
patches.push({ op: 'create', node: roofLevel, parentId: buildingId })
}
const segment = RoofSegmentNode.parse({
roofType,
width,
depth,
wallHeight,
roofHeight,
wallThickness,
overhang,
...(materialPreset ? { materialPreset } : {}),
})
const roof = RoofNode.parse({
name: name ?? 'Roof',
position: (center as [number, number, number] | undefined) ?? [0, 0, 0],
children: [segment.id],
...(materialPreset ? { materialPreset } : {}),
metadata: {
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
},
})
bridge.applyPatch([
...patches,
{ op: 'create', node: roof, parentId: targetRoofLevelId },
{ op: 'create', node: segment, parentId: roof.id as AnyNodeId },
])
await publishLiveSceneSnapshot(bridge, store, 'create_roof')
return textResult({
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
createdRoofLevelId,
roofId: roof.id,
roofSegmentId: segment.id,
})
},
)
server.registerTool(
'create_stair_between_levels',
{
title: 'Create stair between levels',
description:
'Create a straight stair and a single rectangular manual opening in the destination slab/source ceiling. This disables stair auto-opening mode to avoid duplicate or irregular holes.',
inputSchema: createStairBetweenLevelsInput,
outputSchema: createStairBetweenLevelsOutput,
},
async ({
fromLevelId,
toLevelId,
position,
rotation,
width,
runLength,
totalRise,
stepCount,
railingMode,
destinationSlabId,
sourceCeilingId,
createDestinationSlabOpening,
createSourceCeilingOpening,
openingWidth,
openingLength,
openingOffset,
openingCenter,
openingRotation,
materialPreset,
name,
}) => {
assertNode(bridge, fromLevelId, 'level')
assertNode(bridge, toLevelId, 'level')
const segment = StairSegmentNode.parse({
segmentType: 'stair',
width,
length: runLength,
height: totalRise,
stepCount,
...(materialPreset ? { materialPreset } : {}),
})
const stair = StairNode.parse({
name: name ?? 'Stair',
position: position as [number, number, number],
rotation,
stairType: 'straight',
fromLevelId,
toLevelId,
slabOpeningMode: 'none',
openingOffset,
width,
totalRise,
stepCount,
railingMode,
children: [segment.id],
...(materialPreset ? { materialPreset } : {}),
metadata: {
openingManaged: 'manual-rectangular',
},
})
const openingPolygon = rectangularOpening({
position: position as [number, number, number],
rotation,
width: openingWidth ?? width,
length: openingLength ?? runLength,
offset: openingOffset,
center: openingCenter as [number, number] | undefined,
openingRotation,
})
const patches: Array<
| { op: 'create'; node: AnyNode; parentId: AnyNodeId }
| { op: 'update'; id: AnyNodeId; data: Partial<AnyNode> }
> = [
{ op: 'create', node: stair, parentId: fromLevelId as AnyNodeId },
{ op: 'create', node: segment, parentId: stair.id as AnyNodeId },
]
const destinationSlab =
destinationSlabId !== undefined
? assertNode(bridge, destinationSlabId, 'slab')
: firstNodeOnLevel(bridge, toLevelId, 'slab')
if (createDestinationSlabOpening && destinationSlab?.type === 'slab') {
patches.push({
op: 'update',
id: destinationSlab.id as AnyNodeId,
data: withHole(destinationSlab, openingPolygon),
})
}
const sourceCeiling =
sourceCeilingId !== undefined
? assertNode(bridge, sourceCeilingId, 'ceiling')
: firstNodeOnLevel(bridge, fromLevelId, 'ceiling')
if (createSourceCeilingOpening && sourceCeiling?.type === 'ceiling') {
patches.push({
op: 'update',
id: sourceCeiling.id as AnyNodeId,
data: withHole(sourceCeiling, openingPolygon),
})
}
bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels')
return textResult({
stairId: stair.id,
stairSegmentId: segment.id,
destinationSlabId: destinationSlab?.id ?? null,
sourceCeilingId: sourceCeiling?.id ?? null,
openingPolygon,
})
},
)
}
+8 -1
View File
@@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema'
import { LevelNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const createLevelInput = {
@@ -17,7 +19,11 @@ export const createLevelOutput = {
levelId: z.string(),
}
export function registerCreateLevel(server: McpServer, bridge: SceneBridge): void {
export function registerCreateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'create_level',
{
@@ -51,6 +57,7 @@ export function registerCreateLevel(server: McpServer, bridge: SceneBridge): voi
})
const id = bridge.createNode(levelNode, buildingId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_level')
const payload = { levelId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../bridge/scene-bridge'
import type { SceneMeta, SceneStore } from '../storage/types'
import { registerCreateWall } from './create-wall'
describe('create_wall', () => {
@@ -39,6 +41,85 @@ describe('create_wall', () => {
expect((created as { thickness?: number }).thickness).toBe(0.15)
})
test('publishes a live scene snapshot when bound to a saved scene', async () => {
const now = new Date().toISOString()
const savedMeta: SceneMeta = {
id: 'live-scene',
name: 'Live Scene',
projectId: null,
thumbnailUrl: null,
version: 1,
createdAt: now,
updatedAt: now,
ownerId: null,
sizeBytes: 0,
nodeCount: Object.keys(bridge.getNodes()).length,
}
const savedGraphs: SceneGraph[] = []
const eventKinds: string[] = []
const store: SceneStore = {
backend: 'sqlite',
async save(opts) {
expect(opts.id).toBe(savedMeta.id)
expect(opts.expectedVersion).toBe(1)
savedGraphs.push(opts.graph)
return {
...savedMeta,
version: 2,
updatedAt: new Date().toISOString(),
sizeBytes: JSON.stringify(opts.graph).length,
nodeCount: Object.keys(opts.graph.nodes).length,
}
},
async load() {
return null
},
async list() {
return []
},
async delete() {
return false
},
async rename() {
return savedMeta
},
async appendSceneEvent(opts) {
eventKinds.push(opts.kind)
return {
eventId: 1,
sceneId: opts.sceneId,
version: opts.version,
kind: opts.kind,
createdAt: new Date().toISOString(),
graph: opts.graph,
}
},
}
const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' })
const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' })
bridge.setActiveScene(savedMeta)
registerCreateWall(liveServer, bridge, store)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)])
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await liveClient.callTool({
name: 'create_wall',
arguments: {
levelId: level.id,
start: [0, 1],
end: [4, 1],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(savedGraphs).toHaveLength(1)
expect(savedGraphs[0]!.nodes[parsed.wallId]).toBeDefined()
expect(eventKinds).toEqual(['create_wall'])
expect(bridge.getActiveScene()?.version).toBe(2)
})
test('rejects unknown level id', async () => {
const result = await client.callTool({
name: 'create_wall',
+8 -1
View File
@@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema'
import { WallNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
export const createWallInput = {
@@ -18,7 +20,11 @@ export const createWallOutput = {
wallId: z.string(),
}
export function registerCreateWall(server: McpServer, bridge: SceneBridge): void {
export function registerCreateWall(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'create_wall',
{
@@ -47,6 +53,7 @@ export function registerCreateWall(server: McpServer, bridge: SceneBridge): void
...(height !== undefined ? { height } : {}),
})
const id = bridge.createNode(wall, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_wall')
const payload = { wallId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -42,6 +42,7 @@ describe('cut_opening', () => {
const created = bridge.getNode(parsed.openingId)
expect((created as { wallId?: string }).wallId).toBe(wall.id)
expect((created as { width: number }).width).toBe(0.9)
expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(2.5, 3)
})
test('creates a window opening on a wall', async () => {
@@ -61,6 +62,9 @@ describe('cut_opening', () => {
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.openingId).toMatch(/^window_/)
const created = bridge.getNode(parsed.openingId)
expect((created as { position: [number, number, number] }).position[0]).toBeCloseTo(1.25, 3)
expect((created as { position: [number, number, number] }).position[1]).toBeCloseTo(1.5, 3)
})
test('rejects unknown wall id', async () => {
+31 -7
View File
@@ -3,7 +3,10 @@ import type { AnyNodeId } from '@pascal-app/core/schema'
import { DoorNode, WindowNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { wallLength, wallLocalXFromT } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const cutOpeningInput = {
@@ -18,7 +21,11 @@ export const cutOpeningOutput = {
openingId: z.string(),
}
export function registerCutOpening(server: McpServer, bridge: SceneBridge): void {
export function registerCutOpening(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'cut_opening',
{
@@ -37,19 +44,36 @@ export function registerCutOpening(server: McpServer, bridge: SceneBridge): void
throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`)
}
// wallT is stored on door/window children via position in the schema;
// the core systems look up wallId and derive placement from `position[0]`
// being on the wall-local axis. We set wallId explicitly so the runtime
// can associate the opening with its parent wall.
const length = wallLength(wall)
if (length < width) {
throwMcpError(
ErrorCode.InvalidParams,
`Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m opening`,
)
}
// `position` is public MCP ergonomics: 0..1 along the wall. Door/window
// nodes store wall-local meters in position[0], so convert before writing.
const base = {
wallId,
width,
height,
position: [position, height / 2, 0] as [number, number, number],
position: [wallLocalXFromT(wall, position, width), height / 2, 0] as [
number,
number,
number,
],
}
const opening = type === 'door' ? DoorNode.parse(base) : WindowNode.parse(base)
const opening =
type === 'door'
? DoorNode.parse(base)
: WindowNode.parse({
...base,
position: [base.position[0], 0.9 + height / 2, 0],
})
const id = bridge.createNode(opening, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'cut_opening')
const payload = { openingId: id as string }
return {
+8 -1
View File
@@ -2,7 +2,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const deleteNodeInput = {
@@ -14,7 +16,11 @@ export const deleteNodeOutput = {
deletedIds: z.array(z.string()),
}
export function registerDeleteNode(server: McpServer, bridge: SceneBridge): void {
export function registerDeleteNode(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'delete_node',
{
@@ -31,6 +37,7 @@ export function registerDeleteNode(server: McpServer, bridge: SceneBridge): void
}
try {
const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false)
await publishLiveSceneSnapshot(bridge, store, 'delete_node')
const payload = { deletedIds: removed }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+8 -1
View File
@@ -3,7 +3,9 @@ import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const duplicateLevelInput = {
@@ -15,7 +17,11 @@ export const duplicateLevelOutput = {
newNodeIds: z.array(z.string()),
}
export function registerDuplicateLevel(server: McpServer, bridge: SceneBridge): void {
export function registerDuplicateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'duplicate_level',
{
@@ -57,6 +63,7 @@ export function registerDuplicateLevel(server: McpServer, bridge: SceneBridge):
})
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'duplicate_level')
const payload = {
newLevelId: newLevelId as string,
+128
View File
@@ -0,0 +1,128 @@
import type { WallNode } from '@pascal-app/core/schema'
export type Vec2 = [number, number]
export type Vec3 = [number, number, number]
export function distance2D(a: Vec2, b: Vec2): number {
const dx = b[0] - a[0]
const dz = b[1] - a[1]
return Math.sqrt(dx * dx + dz * dz)
}
export function wallLength(wall: Pick<WallNode, 'start' | 'end'>): number {
return distance2D(wall.start, wall.end)
}
export function clamp(value: number, min: number, max: number): number {
if (max < min) return (min + max) / 2
return Math.max(min, Math.min(max, value))
}
export function wallLocalXFromT(
wall: Pick<WallNode, 'start' | 'end'>,
t: number,
width: number,
): number {
const length = wallLength(wall)
return clamp(t * length, width / 2, length - width / 2)
}
export function projectWorldPointToWallLocalX(
wall: Pick<WallNode, 'start' | 'end'>,
position: Vec3,
): number {
const [sx, sz] = wall.start
const [ex, ez] = wall.end
const dx = ex - sx
const dz = ez - sz
const len = Math.sqrt(dx * dx + dz * dz)
if (len === 0) return 0
const px = position[0] - sx
const pz = position[2] - sz
const distance = px * (dx / len) + pz * (dz / len)
return clamp(distance, 0, len)
}
export function polygonArea(points: Vec2[]): number {
if (points.length < 3) return 0
let area = 0
for (let i = 0; i < points.length; i++) {
const current = points[i]!
const next = points[(i + 1) % points.length]!
area += current[0] * next[1] - next[0] * current[1]
}
return Math.abs(area) / 2
}
export function polygonBounds(points: Vec2[]): {
minX: number
maxX: number
minZ: number
maxZ: number
width: number
depth: number
centerX: number
centerZ: number
} {
const xs = points.map((p) => p[0])
const zs = points.map((p) => p[1])
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minZ = Math.min(...zs)
const maxZ = Math.max(...zs)
return {
minX,
maxX,
minZ,
maxZ,
width: maxX - minX,
depth: maxZ - minZ,
centerX: (minX + maxX) / 2,
centerZ: (minZ + maxZ) / 2,
}
}
export function pointInBoundsWithPadding(
x: number,
z: number,
bounds: ReturnType<typeof polygonBounds>,
padding: number,
): boolean {
return (
x >= bounds.minX + padding &&
x <= bounds.maxX - padding &&
z >= bounds.minZ + padding &&
z <= bounds.maxZ - padding
)
}
export function pointOnSegment(point: Vec2, a: Vec2, b: Vec2, tolerance = 1e-6): boolean {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > tolerance) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -tolerance) return false
const lenSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lenSq + tolerance
}
export function pointInPolygon(point: Vec2, polygon: Vec2[], includeBoundary = true): boolean {
if (polygon.length < 3) return false
let inside = false
const [x, z] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i]!
const b = polygon[j]!
if (pointOnSegment(point, a, b)) return includeBoundary
const intersects =
a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0]
if (intersects) inside = !inside
}
return inside
}
export function polygonContainsPolygon(outer: Vec2[], inner: Vec2[]): boolean {
return inner.every((point) => pointInPolygon(point, outer, true))
}
+16 -10
View File
@@ -3,6 +3,7 @@ import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { registerApplyPatch } from './apply-patch'
import { registerCheckCollisions } from './check-collisions'
import { registerConstructionTools } from './construction-tools'
import { registerCreateLevel } from './create-level'
import { registerCreateWall } from './create-wall'
import { registerCutOpening } from './cut-opening'
@@ -18,7 +19,9 @@ import { registerMeasure } from './measure'
import { registerPhotoToSceneTool } from './photo-to-scene'
import { registerPlaceItem } from './place-item'
import { registerRedo } from './redo'
import { registerRoomTools } from './room-tools'
import { registerSceneLifecycleTools } from './scene-lifecycle'
import { registerSceneQueryTools } from './scene-query'
import { registerSetZone } from './set-zone'
import { registerTemplateTools } from './templates'
import { registerUndo } from './undo'
@@ -38,17 +41,20 @@ export function registerTools(server: McpServer, bridge: SceneBridge, store?: Sc
registerGetNode(server, bridge)
registerDescribeNode(server, bridge)
registerFindNodes(server, bridge)
registerSceneQueryTools(server, bridge)
registerMeasure(server, bridge)
registerApplyPatch(server, bridge)
registerCreateLevel(server, bridge)
registerCreateWall(server, bridge)
registerPlaceItem(server, bridge)
registerCutOpening(server, bridge)
registerSetZone(server, bridge)
registerDuplicateLevel(server, bridge)
registerDeleteNode(server, bridge)
registerUndo(server, bridge)
registerRedo(server, bridge)
registerConstructionTools(server, bridge, store)
registerRoomTools(server, bridge, store)
registerApplyPatch(server, bridge, store)
registerCreateLevel(server, bridge, store)
registerCreateWall(server, bridge, store)
registerPlaceItem(server, bridge, store)
registerCutOpening(server, bridge, store)
registerSetZone(server, bridge, store)
registerDuplicateLevel(server, bridge, store)
registerDeleteNode(server, bridge, store)
registerUndo(server, bridge, store)
registerRedo(server, bridge, store)
registerExportJson(server, bridge)
registerExportGlb(server, bridge)
registerValidateScene(server, bridge)
+80
View File
@@ -0,0 +1,80 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings'
import type { SceneBridge } from '../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
export function syncDerivedStairOpenings(bridge: SceneBridge): number {
const updates = syncAutoStairOpenings(bridge.getNodes())
if (updates.length === 0) return 0
bridge.applyPatch(
updates.map((update) => ({
op: 'update' as const,
id: update.id,
data: update.data,
})),
)
return updates.length
}
/**
* Persist the bridge's current graph to the active scene and append a live
* event for browser subscribers. No-ops when the MCP session is not currently
* bound to a saved scene.
*/
export async function publishLiveSceneSnapshot(
bridge: SceneBridge,
store: SceneStore | undefined,
kind: string,
): Promise<void> {
syncDerivedStairOpenings(bridge)
const active = bridge.getActiveScene()
if (!active || !store?.appendSceneEvent) return
const exported = bridge.exportJSON()
const graph: SceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
try {
const meta = await store.save({
id: active.id,
name: active.name,
projectId: active.projectId,
ownerId: active.ownerId,
thumbnailUrl: active.thumbnailUrl,
graph,
expectedVersion: active.version,
})
bridge.setActiveScene(meta)
await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind,
graph,
})
} catch (error) {
if (error instanceof SceneVersionConflictError) {
throwMcpError(ErrorCode.InvalidRequest, 'live_sync_version_conflict', {
sceneId: active.id,
expectedVersion: active.version,
})
}
const message = error instanceof Error ? error.message : String(error)
throwMcpError(ErrorCode.InternalError, `live_sync_failed: ${message}`)
}
}
export async function appendLiveSceneEvent(
store: SceneStore,
sceneId: string,
version: number,
kind: string,
graph: SceneGraph,
): Promise<void> {
if (!store.appendSceneEvent) return
await store.appendSceneEvent({ sceneId, version, kind, graph })
}
@@ -13,6 +13,7 @@ import {
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import { appendLiveSceneEvent } from '../live-sync'
/**
* Input shape for the `photo_to_scene` orchestrator. `image` matches the
@@ -378,6 +379,8 @@ export function registerPhotoToScene(
name,
graph,
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'photo_to_scene', graph)
const payload: {
sceneId: string
url: string
@@ -411,6 +414,7 @@ export function registerPhotoToScene(
confidence: vision.confidence,
graph,
}
bridge.clearActiveScene()
if (notes) payload.notes = notes
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+33 -5
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { WallNode } from '@pascal-app/core/schema'
import { SlabNode, WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerPlaceItem } from './place-item'
@@ -29,7 +29,7 @@ describe('place_item', () => {
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'chair:basic',
catalogItemId: 'shelf',
targetNodeId: wall.id,
position: [5, 0, 0],
},
@@ -37,20 +37,48 @@ describe('place_item', () => {
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.itemId).toMatch(/^item_/)
expect(parsed.status).toBe('catalog_unavailable')
expect(parsed.status).toBe('ok')
const item = bridge.getNode(parsed.itemId)
expect(item).not.toBeNull()
// Midpoint of a [0..10] wall at x=5 → wallT = 0.5.
expect((item as { wallT?: number }).wallT).toBeCloseTo(0.5, 3)
expect((item as { position: [number, number, number] }).position[0]).toBeCloseTo(5, 3)
})
test('rejects placement on a level', async () => {
test('places a floor item through a slab target but parents it to the level for rendering', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const slab = SlabNode.parse({
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
})
bridge.createNode(slab, level.id)
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'sofa',
targetNodeId: slab.id,
position: [2, 0, 2],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
const item = bridge.getNode(parsed.itemId)
expect(item?.parentId).toBe(level.id)
expect(bridge.validateScene().valid).toBe(true)
})
test('rejects placement on an unsupported node', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'foo',
targetNodeId: level.id,
targetNodeId: building.id,
position: [0, 0, 0],
},
})
+50 -37
View File
@@ -3,7 +3,11 @@ import type { AnyNodeId } from '@pascal-app/core/schema'
import { ItemNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { findCatalogItem } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec3Schema } from './schemas'
export const placeItemInput = {
@@ -18,31 +22,17 @@ export const placeItemOutput = {
status: z.string().optional(),
}
/** Compute wallT (0..1) from a 3D position projected onto the wall centreline. */
function computeWallT(
start: [number, number],
end: [number, number],
position: [number, number, number],
): number {
const [sx, sz] = start
const [ex, ez] = end
const dx = ex - sx
const dz = ez - sz
const lenSq = dx * dx + dz * dz
if (lenSq === 0) return 0
const px = position[0] - sx
const pz = position[2] - sz
const t = (px * dx + pz * dz) / lenSq
return Math.max(0, Math.min(1, t))
}
export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void {
export function registerPlaceItem(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'place_item',
{
title: 'Place item',
description:
'Place a catalog item into the scene, attaching it to a wall, ceiling, or site. In headless mode the catalog is unavailable, so the asset payload is a placeholder — `status: "catalog_unavailable"` indicates this.',
'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, a ceiling for ceiling-attached items, or the site for outdoor items.',
inputSchema: placeItemInput,
outputSchema: placeItemOutput,
},
@@ -52,14 +42,22 @@ export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void
throwMcpError(ErrorCode.InvalidParams, `Target node not found: ${targetNodeId}`)
}
const targetType = target.type
if (targetType !== 'wall' && targetType !== 'ceiling' && targetType !== 'site') {
if (
targetType !== 'level' &&
targetType !== 'slab' &&
targetType !== 'zone' &&
targetType !== 'wall' &&
targetType !== 'ceiling' &&
targetType !== 'site'
) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot place item on ${targetType}; target must be a wall, ceiling, or site`,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling, or site`,
)
}
const baseAsset = {
const catalogAsset = findCatalogItem(catalogItemId)
const baseAsset = catalogAsset ?? {
id: catalogItemId,
name: catalogItemId,
category: 'unknown',
@@ -71,28 +69,43 @@ export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void
scale: [1, 1, 1] as [number, number, number],
}
const wallExtras: { wallId: string; wallT: number } | Record<string, never> =
targetType === 'wall'
? {
wallId: targetNodeId,
wallT: computeWallT(
(target as { start: [number, number] }).start,
(target as { end: [number, number] }).end,
position as [number, number, number],
),
}
: {}
const requestedPosition = position as [number, number, number]
const parentId =
targetType === 'slab' || targetType === 'zone'
? bridge.resolveLevelId(targetNodeId as AnyNodeId)
: targetNodeId
if (!parentId) {
throwMcpError(
ErrorCode.InvalidParams,
`Could not resolve a level parent for target ${targetNodeId}`,
)
}
const wallExtras: { wallId: string; wallT: number } | Record<string, never> = {}
let itemPosition = requestedPosition
if (targetType === 'wall') {
const localX = projectWorldPointToWallLocalX(target, requestedPosition)
const length = wallLength(target)
itemPosition = [localX, requestedPosition[1], 0]
Object.assign(wallExtras, {
wallId: targetNodeId,
wallT: length === 0 ? 0 : localX / length,
})
}
const item = ItemNode.parse({
position: position as [number, number, number],
position: itemPosition,
rotation: [0, rotation ?? 0, 0],
asset: baseAsset,
...wallExtras,
})
const id = bridge.createNode(item, targetNodeId as AnyNodeId)
const id = bridge.createNode(item, parentId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'place_item')
const payload = {
itemId: id as string,
status: 'catalog_unavailable',
status: catalogAsset ? 'ok' : 'catalog_unavailable',
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+4 -1
View File
@@ -1,6 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync'
export const redoInput = {
steps: z.number().int().positive().optional(),
@@ -10,7 +12,7 @@ export const redoOutput = {
redone: z.number(),
}
export function registerRedo(server: McpServer, bridge: SceneBridge): void {
export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
server.registerTool(
'redo',
{
@@ -22,6 +24,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge): void {
},
async ({ steps }) => {
const redone = bridge.redo(steps ?? 1)
if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo')
const payload = { redone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+189
View File
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerRoomTools } from './room-tools'
describe('room tools', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerRoomTools(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
})
test('search_assets returns built-in catalog matches', async () => {
const result = await client.callTool({
name: 'search_assets',
arguments: { query: 'sofa' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.total).toBeGreaterThan(0)
expect(parsed.results.map((item: { id: string }) => item.id)).toContain('sofa')
})
test('create_room creates a valid zone/slab/ceiling/wall bundle', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_room',
arguments: {
levelId: level.id,
name: 'Bedroom',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.zoneId).toMatch(/^zone_/)
expect(parsed.slabId).toMatch(/^slab_/)
expect(parsed.ceilingId).toMatch(/^ceiling_/)
expect(parsed.wallIds).toHaveLength(4)
expect(parsed.areaSqMeters).toBe(12)
expect(bridge.validateScene().valid).toBe(true)
})
test('add_door and add_window convert t to wall-local meters', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const roomResult = await client.callTool({
name: 'create_room',
arguments: {
levelId: level.id,
name: 'Living',
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
},
})
const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text)
const wallId = room.wallIds[0]
const doorResult = await client.callTool({
name: 'add_door',
arguments: { wallId, t: 0.5 },
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(2.5, 3)
expect(
(bridge.getNode(door.doorId) as { position: [number, number, number] }).position[0],
).toBeCloseTo(2.5, 3)
const windowResult = await client.callTool({
name: 'add_window',
arguments: { wallId, t: 0.25, width: 1, height: 1, sillHeight: 1 },
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(1.25, 3)
expect(
(bridge.getNode(win.windowId) as { position: [number, number, number] }).position[1],
).toBe(1.5)
})
test('add_door and add_window accept position as a t alias', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const roomResult = await client.callTool({
name: 'create_room',
arguments: {
levelId: level.id,
name: 'Entry',
polygon: [
[0, 0],
[6, 0],
[6, 3],
[0, 3],
],
},
})
const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text)
const wallId = room.wallIds[0]
const doorResult = await client.callTool({
name: 'add_door',
arguments: { wallId, position: 0.25 },
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(1.5, 3)
const windowResult = await client.callTool({
name: 'add_window',
arguments: { wallId, position: 0.75, width: 1 },
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(4.5, 3)
expect(bridge.validateScene().valid).toBe(true)
})
test('furnish_room parents floor items to the level and keeps the scene valid', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'furnish_room',
arguments: {
levelId: level.id,
roomType: 'bedroom',
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
doorWallIndex: 0,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.placed).toBeGreaterThan(0)
for (const itemId of parsed.itemIds) {
expect(bridge.getNode(itemId)?.parentId).toBe(level.id)
}
expect(bridge.validateScene().valid).toBe(true)
})
test('furnish_room can infer level and polygon from zoneId', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const roomResult = await client.callTool({
name: 'create_room',
arguments: {
levelId: level.id,
name: 'Bedroom',
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
},
})
const room = JSON.parse((roomResult.content as Array<{ type: string; text: string }>)[0]!.text)
const result = await client.callTool({
name: 'furnish_room',
arguments: {
zoneId: room.zoneId,
roomType: 'bedroom',
doorWallIndex: 0,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.placed).toBeGreaterThan(0)
for (const itemId of parsed.itemIds) {
expect(bridge.getNode(itemId)?.parentId).toBe(level.id)
}
expect(bridge.validateScene().valid).toBe(true)
})
})
+596
View File
@@ -0,0 +1,596 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId, AssetInput } from '@pascal-app/core/schema'
import {
CeilingNode,
DoorNode,
ItemNode,
SlabNode,
WallNode,
WindowNode,
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { findCatalogItem, searchCatalogItems } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import {
pointInBoundsWithPadding,
polygonArea,
polygonBounds,
type Vec2,
wallLength,
wallLocalXFromT,
} from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
const ROOM_TYPES = [
'bedroom',
'kitchen',
'bathroom',
'living',
'dining',
'hallway',
'entry',
'laundry',
'storage',
] as const
export const searchAssetsInput = {
query: z.string().min(1),
category: z.string().optional(),
}
export const searchAssetsOutput = {
results: z.array(z.record(z.string(), z.unknown())),
total: z.number(),
}
export const createRoomInput = {
levelId: NodeIdSchema,
name: z.string().min(1),
polygon: z.array(Vec2Schema).min(3),
color: z.string().optional(),
wallHeight: z.number().positive().optional(),
wallThickness: z.number().positive().optional(),
}
export const createRoomOutput = {
zoneId: z.string(),
slabId: z.string(),
ceilingId: z.string(),
wallIds: z.array(z.string()),
areaSqMeters: z.number(),
}
export const addDoorInput = {
wallId: NodeIdSchema,
t: z.number().min(0).max(1).optional(),
position: z.number().min(0).max(1).optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
hingesSide: z.enum(['left', 'right']).optional(),
swingDirection: z.enum(['inward', 'outward']).optional(),
}
export const addDoorOutput = {
doorId: z.string(),
localX: z.number(),
}
export const addWindowInput = {
wallId: NodeIdSchema,
t: z.number().min(0).max(1).optional(),
position: z.number().min(0).max(1).optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
sillHeight: z.number().min(0).optional(),
}
export const addWindowOutput = {
windowId: z.string(),
localX: z.number(),
sillHeight: z.number(),
}
export const furnishRoomInput = {
levelId: NodeIdSchema.optional(),
zoneId: NodeIdSchema.optional(),
roomType: z.enum(ROOM_TYPES),
polygon: z.array(Vec2Schema).min(3).optional(),
doorWallIndex: z.number().int().min(0).optional(),
}
export const furnishRoomOutput = {
placed: z.number(),
itemIds: z.array(z.string()),
skipped: z.array(z.string()),
}
type Footprint = { minX: number; maxX: number; minZ: number; maxZ: number }
type Placement = { assetId: string; x: number; z: number; rotationDeg?: number }
function textResult<T extends Record<string, unknown>>(payload: T) {
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
const level = bridge.getNode(levelId as AnyNodeId)
if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
if (level.type !== 'level') {
throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${level.type}, expected level`)
}
return level
}
function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wall' } {
const wall = bridge.getNode(wallId as AnyNodeId)
if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`)
if (wall.type !== 'wall') {
throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`)
}
return wall
}
function inferRoomGeometry(
bridge: SceneBridge,
levelId: string | undefined,
polygon: Vec2[] | undefined,
zoneId: string | undefined,
) {
if (levelId && polygon) return { levelId, polygon }
if (!zoneId) {
throwMcpError(
ErrorCode.InvalidParams,
'Provide either levelId + polygon or zoneId so the room can be furnished',
)
}
const zone = bridge.getNode(zoneId as AnyNodeId)
if (!zone) throwMcpError(ErrorCode.InvalidParams, `Zone not found: ${zoneId}`)
if (zone.type !== 'zone') {
throwMcpError(ErrorCode.InvalidParams, `Node ${zoneId} is a ${zone.type}, expected zone`)
}
const inferredLevelId = levelId ?? zone.parentId ?? undefined
if (!inferredLevelId) {
throwMcpError(ErrorCode.InvalidParams, `Zone ${zoneId} is missing a parent level`)
}
return {
levelId: inferredLevelId,
polygon: polygon ?? (zone.polygon as Vec2[]),
}
}
function resolveWallT(toolName: string, t?: number, position?: number): number {
const resolved = t ?? position
if (resolved === undefined) {
throwMcpError(ErrorCode.InvalidParams, `${toolName} requires t or position in the 0..1 range`)
}
return resolved
}
function makeItemAsset(asset: AssetInput) {
return {
id: asset.id,
name: asset.name,
category: asset.category,
thumbnail: asset.thumbnail ?? '',
src: asset.src,
dimensions: asset.dimensions ?? [1, 1, 1],
offset: asset.offset ?? [0, 0, 0],
rotation: asset.rotation ?? [0, 0, 0],
scale: asset.scale ?? [1, 1, 1],
...(asset.attachTo ? { attachTo: asset.attachTo } : {}),
...(asset.tags ? { tags: asset.tags } : {}),
...(asset.surface ? { surface: asset.surface } : {}),
...(asset.interactive ? { interactive: asset.interactive } : {}),
}
}
function itemFootprint(asset: AssetInput, x: number, z: number, rotationDeg = 0): Footprint {
const [w = 1, , d = 1] = asset.dimensions ?? [1, 1, 1]
const rot = (rotationDeg * Math.PI) / 180
const cos = Math.abs(Math.cos(rot))
const sin = Math.abs(Math.sin(rot))
const halfW = (w * cos + d * sin) / 2
const halfD = (w * sin + d * cos) / 2
return { minX: x - halfW, maxX: x + halfW, minZ: z - halfD, maxZ: z + halfD }
}
function footprintsOverlap(a: Footprint, b: Footprint): boolean {
const gap = 0.08
return (
a.maxX - gap > b.minX && a.minX + gap < b.maxX && a.maxZ - gap > b.minZ && a.minZ + gap < b.maxZ
)
}
function buildRoomPlacements(
roomType: (typeof ROOM_TYPES)[number],
polygon: Vec2[],
doorWallIndex = 0,
) {
const bounds = polygonBounds(polygon)
const n = polygon.length
const backIdx = (doorWallIndex + Math.floor(n / 2)) % n
const backStart = polygon[backIdx]!
const backEnd = polygon[(backIdx + 1) % n]!
const backMidX = (backStart[0] + backEnd[0]) / 2
const backMidZ = (backStart[1] + backEnd[1]) / 2
const inwardX = bounds.centerX - backMidX
const inwardZ = bounds.centerZ - backMidZ
const inwardLen = Math.sqrt(inwardX * inwardX + inwardZ * inwardZ) || 1
const inX = inwardX / inwardLen
const inZ = inwardZ / inwardLen
const facingRot = (Math.atan2(inX, inZ) * 180) / Math.PI
const alongX = backEnd[0] - backStart[0]
const alongZ = backEnd[1] - backStart[1]
const alongLen = Math.sqrt(alongX * alongX + alongZ * alongZ) || 1
const ax = alongX / alongLen
const az = alongZ / alongLen
const backPos = (inset: number, lateral = 0): [number, number] => [
backMidX + inX * inset + ax * lateral,
backMidZ + inZ * inset + az * lateral,
]
const sideIdx = (doorWallIndex + 1) % n
const sideStart = polygon[sideIdx]!
const sideEnd = polygon[(sideIdx + 1) % n]!
const sideMidX = (sideStart[0] + sideEnd[0]) / 2
const sideMidZ = (sideStart[1] + sideEnd[1]) / 2
const sideInX = bounds.centerX - sideMidX
const sideInZ = bounds.centerZ - sideMidZ
const sideInLen = Math.sqrt(sideInX * sideInX + sideInZ * sideInZ) || 1
const snX = sideInX / sideInLen
const snZ = sideInZ / sideInLen
const sideRot = (Math.atan2(snX, snZ) * 180) / Math.PI
const sideAlongX = sideEnd[0] - sideStart[0]
const sideAlongZ = sideEnd[1] - sideStart[1]
const sideAlongLen = Math.sqrt(sideAlongX * sideAlongX + sideAlongZ * sideAlongZ) || 1
const sax = sideAlongX / sideAlongLen
const saz = sideAlongZ / sideAlongLen
const sidePos = (inset: number, lateral = 0): [number, number] => [
sideMidX + snX * inset + sax * lateral,
sideMidZ + snZ * inset + saz * lateral,
]
const placements: Placement[] = []
const area = polygonArea(polygon)
const addBack = (assetId: string, inset: number, lateral = 0, rotationDeg = facingRot) => {
const [x, z] = backPos(inset, lateral)
placements.push({ assetId, x, z, rotationDeg })
}
const addSide = (assetId: string, inset: number, lateral = 0, rotationDeg = sideRot) => {
const [x, z] = sidePos(inset, lateral)
placements.push({ assetId, x, z, rotationDeg })
}
switch (roomType) {
case 'bedroom': {
const bedId = Math.max(bounds.width, bounds.depth) >= 3.2 ? 'double-bed' : 'single-bed'
const bed = findCatalogItem(bedId)
const [bedW = 2, , bedD = 2.5] = bed?.dimensions ?? []
addBack(bedId, bedD / 2 + 0.1)
if (alongLen > bedW + 1.1) {
addBack('bedside-table', 0.35, -(bedW / 2 + 0.35))
addBack('bedside-table', 0.35, bedW / 2 + 0.35)
}
if (area >= 10) addSide('dresser', 0.55, sideAlongLen * 0.22)
if (area >= 13) addSide('closet', 0.6, -sideAlongLen * 0.22)
break
}
case 'kitchen':
addBack(alongLen >= 2.6 ? 'kitchen' : 'kitchen-counter', 0.55)
if (alongLen >= 3.5) addBack('stove', 0.55, alongLen / 2 - 0.65)
addSide('fridge', 0.6, sideAlongLen * 0.25)
break
case 'bathroom':
addBack('toilet', 0.55, alongLen * 0.25)
addBack('bathroom-sink', 0.8, -alongLen * 0.2)
if (area >= 6.5) addSide('bathtub', 0.85)
else placements.push({ assetId: 'shower-square', x: bounds.centerX, z: bounds.centerZ })
break
case 'living': {
addBack('sofa', 0.9)
addBack('coffee-table', 2.1)
addSide('livingroom-chair', 0.85, -sideAlongLen * 0.18)
const doorIdx = doorWallIndex % n
const doorStart = polygon[doorIdx]!
const doorEnd = polygon[(doorIdx + 1) % n]!
placements.push({
assetId: 'tv-stand',
x: (doorStart[0] + doorEnd[0]) / 2 - inX * 0.35,
z: (doorStart[1] + doorEnd[1]) / 2 - inZ * 0.35,
rotationDeg: facingRot + 180,
})
break
}
case 'dining':
placements.push({ assetId: 'dining-table', x: bounds.centerX, z: bounds.centerZ })
placements.push({ assetId: 'dining-chair', x: bounds.centerX, z: bounds.centerZ - 0.85 })
placements.push({
assetId: 'dining-chair',
x: bounds.centerX,
z: bounds.centerZ + 0.85,
rotationDeg: 180,
})
if (Math.min(bounds.width, bounds.depth) >= 3) {
placements.push({
assetId: 'dining-chair',
x: bounds.centerX - 0.85,
z: bounds.centerZ,
rotationDeg: 270,
})
placements.push({
assetId: 'dining-chair',
x: bounds.centerX + 0.85,
z: bounds.centerZ,
rotationDeg: 90,
})
}
break
case 'laundry':
addBack('washing-machine', 0.6, -0.55)
addBack('drying-rack', 0.65, 0.65)
break
case 'entry':
case 'hallway':
if (Math.min(bounds.width, bounds.depth) >= 1.4) addSide('coat-rack', 0.35)
break
case 'storage':
addBack('closet', 0.6)
break
}
return { placements, bounds }
}
export function registerSearchAssets(server: McpServer): void {
server.registerTool(
'search_assets',
{
title: 'Search assets',
description:
'Search the built-in MCP item catalog by keyword. Call before place_item when you need a valid catalogItemId.',
inputSchema: searchAssetsInput,
outputSchema: searchAssetsOutput,
},
async ({ query, category }) => {
const results = searchCatalogItems({ query, category }).map((item) => ({
id: item.id,
name: item.name,
category: item.category,
tags: item.tags ?? [],
dimensions: item.dimensions,
attachTo: item.attachTo ?? null,
}))
return textResult({ results, total: results.length })
},
)
}
export function registerCreateRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'create_room',
{
title: 'Create room',
description:
'Create a room on a level: zone, slab, ceiling, and one wall per polygon edge. Returns wallIds in polygon edge order.',
inputSchema: createRoomInput,
outputSchema: createRoomOutput,
},
async ({ levelId, name, polygon, color, wallHeight, wallThickness }) => {
assertLevel(bridge, levelId)
const points = polygon as Vec2[]
const zone = ZoneNode.parse({
name,
polygon: points,
color: color ?? '#60a5fa',
metadata: { mcpTool: 'create_room' },
})
const slab = SlabNode.parse({ polygon: points, metadata: { mcpTool: 'create_room' } })
const ceiling = CeilingNode.parse({ polygon: points, metadata: { mcpTool: 'create_room' } })
const walls = points.map((start, index) =>
WallNode.parse({
name: `${name} wall ${index + 1}`,
start,
end: points[(index + 1) % points.length],
...(wallHeight !== undefined ? { height: wallHeight } : {}),
...(wallThickness !== undefined ? { thickness: wallThickness } : {}),
metadata: { mcpTool: 'create_room', roomName: name, edgeIndex: index },
}),
)
bridge.applyPatch([
{ op: 'create', node: zone, parentId: levelId as AnyNodeId },
{ op: 'create', node: slab, parentId: levelId as AnyNodeId },
{ op: 'create', node: ceiling, parentId: levelId as AnyNodeId },
...walls.map((wall) => ({
op: 'create' as const,
node: wall,
parentId: levelId as AnyNodeId,
})),
])
await publishLiveSceneSnapshot(bridge, store, 'create_room')
return textResult({
zoneId: zone.id,
slabId: slab.id,
ceilingId: ceiling.id,
wallIds: walls.map((wall) => wall.id),
areaSqMeters: Math.round(polygonArea(points) * 100) / 100,
})
},
)
}
export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
server.registerTool(
'add_door',
{
title: 'Add door',
description:
'Add a door to an existing wall. t/position is 0..1 along the wall: 0 = start, 0.5 = center, 1 = end.',
inputSchema: addDoorInput,
outputSchema: addDoorOutput,
},
async ({ wallId, t, position, width = 0.9, height = 2.1, hingesSide, swingDirection }) => {
const wall = assertWall(bridge, wallId)
const length = wallLength(wall)
if (length < width) {
throwMcpError(
ErrorCode.InvalidParams,
`Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m door`,
)
}
const wallT = resolveWallT('add_door', t, position)
const localX = wallLocalXFromT(wall, wallT, width)
const door = DoorNode.parse({
wallId,
parentId: wallId,
position: [localX, height / 2, 0],
width,
height,
...(hingesSide ? { hingesSide } : {}),
...(swingDirection ? { swingDirection } : {}),
})
const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_door')
return textResult({ doorId: id, localX })
},
)
}
export function registerAddWindow(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'add_window',
{
title: 'Add window',
description:
'Add a window to an existing wall. t/position is 0..1 along the wall; sillHeight is the height from floor to window bottom.',
inputSchema: addWindowInput,
outputSchema: addWindowOutput,
},
async ({ wallId, t, position, width = 1.5, height = 1.5, sillHeight = 0.9 }) => {
const wall = assertWall(bridge, wallId)
const length = wallLength(wall)
if (length < width) {
throwMcpError(
ErrorCode.InvalidParams,
`Wall ${wallId} is ${length.toFixed(2)}m long, too short for a ${width.toFixed(2)}m window`,
)
}
const wallT = resolveWallT('add_window', t, position)
const localX = wallLocalXFromT(wall, wallT, width)
const windowNode = WindowNode.parse({
wallId,
parentId: wallId,
position: [localX, sillHeight + height / 2, 0],
width,
height,
})
const id = bridge.createNode(windowNode, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_window')
return textResult({ windowId: id, localX, sillHeight })
},
)
}
export function registerFurnishRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool(
'furnish_room',
{
title: 'Furnish room',
description:
'Place realistic furniture for a room type using levelId + polygon, or infer both from zoneId. Parent floor items to the level so they render and validate.',
inputSchema: furnishRoomInput,
outputSchema: furnishRoomOutput,
},
async ({ levelId, zoneId, roomType, polygon, doorWallIndex }) => {
const room = inferRoomGeometry(bridge, levelId, polygon as Vec2[] | undefined, zoneId)
assertLevel(bridge, room.levelId)
const points = room.polygon
const { placements, bounds } = buildRoomPlacements(roomType, points, doorWallIndex ?? 0)
const footprints: Footprint[] = []
const skipped: string[] = []
const items: AnyNode[] = []
for (const placement of placements) {
const asset = findCatalogItem(placement.assetId)
if (!asset) {
skipped.push(`${placement.assetId}: asset not found`)
continue
}
const fp = itemFootprint(asset, placement.x, placement.z, placement.rotationDeg ?? 0)
const padding = 0.05
if (
!pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) ||
!pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding)
) {
skipped.push(`${asset.id}: outside room bounds`)
continue
}
if (footprints.some((existing) => footprintsOverlap(fp, existing))) {
skipped.push(`${asset.id}: overlaps another item`)
continue
}
footprints.push(fp)
items.push(
ItemNode.parse({
name: asset.name,
position: [placement.x, 0, placement.z],
rotation: [0, ((placement.rotationDeg ?? 0) * Math.PI) / 180, 0],
asset: makeItemAsset(asset),
metadata: { mcpTool: 'furnish_room', roomType },
}),
)
}
if (items.length > 0) {
bridge.applyPatch(
items.map((item) => ({
op: 'create' as const,
node: item,
parentId: room.levelId as AnyNodeId,
})),
)
await publishLiveSceneSnapshot(bridge, store, 'furnish_room')
}
return textResult({
placed: items.length,
itemIds: items.map((item) => item.id),
skipped,
})
},
)
}
export function registerRoomTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
registerSearchAssets(server)
registerCreateRoom(server, bridge, store)
registerAddDoor(server, bridge, store)
registerAddWindow(server, bridge, store)
registerFurnishRoom(server, bridge, store)
}
@@ -38,6 +38,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store:
}
try {
bridge.loadJSON(result.graph)
bridge.setActiveScene(result)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InvalidRequest, `load_failed: ${msg}`, { id })
@@ -5,6 +5,7 @@ import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
export const saveSceneInput = {
id: z.string().min(1).max(64).optional(),
@@ -103,6 +104,10 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph)
if (includeCurrentScene) {
bridge.setActiveScene(meta)
}
const payload = {
id: meta.id,
name: meta.name,
+128
View File
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import {
DoorNode,
LevelNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
ZoneNode,
} from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerSceneQueryTools } from './scene-query'
describe('scene query tools', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerSceneQueryTools(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
})
test('list_levels returns level ids', async () => {
const result = await client.callTool({ name: 'list_levels', arguments: {} })
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.levels).toHaveLength(1)
expect(parsed.levels[0].id).toMatch(/^level_/)
})
test('get_level_summary includes walls, zones, and openings', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [4, 0] })
bridge.createNode(wall, level.id)
const door = DoorNode.parse({ wallId: wall.id, position: [2, 1.05, 0] })
bridge.createNode(door, wall.id)
const zone = ZoneNode.parse({
name: 'Room',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
bridge.createNode(zone, level.id)
const result = await client.callTool({
name: 'get_level_summary',
arguments: { levelId: level.id },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.counts.walls).toBe(1)
expect(parsed.counts.zones).toBe(1)
expect(parsed.counts.doors).toBe(1)
expect(parsed.walls[0].openings[0].id).toBe(door.id)
expect(parsed.zones[0].areaSqMeters).toBe(12)
})
test('verify_scene reports practical issues without replacing validate_scene', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
bridge.createNode(WallNode.parse({ start: [0, 0], end: [4, 0] }), level.id)
const result = await client.callTool({ name: 'verify_scene', arguments: {} })
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.ok).toBe(true)
expect(parsed.valid).toBe(true)
expect(parsed.hasIssues).toBe(true)
expect(parsed.issues.join('\n')).toContain('walls but no zones')
})
test('verify_scene reports stair wall obstructions and missing destination slab openings', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const upper = LevelNode.parse({ name: 'Upper Floor', level: 1 })
bridge.createNode(upper, building.id)
const upperSlab = SlabNode.parse({
name: 'Upper Floor Slab',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
bridge.createNode(upperSlab, upper.id)
bridge.createNode(
WallNode.parse({ name: 'Stair Blocker', start: [0, 2], end: [4, 2] }),
ground.id,
)
const segment = StairSegmentNode.parse({
width: 1,
length: 2.6,
height: 2.5,
stepCount: 12,
})
const stair = StairNode.parse({
name: 'Main Stair',
position: [2, 0, 0.2],
stairType: 'straight',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
children: [segment.id],
})
bridge.createNode(stair, ground.id)
bridge.createNode(segment, stair.id)
const result = await client.callTool({ name: 'verify_scene', arguments: {} })
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.hasIssues).toBe(true)
expect(parsed.issues.join('\n')).toContain('obstructs stair Main Stair')
expect(parsed.issues.join('\n')).toContain('no destination slab opening')
})
})
+655
View File
@@ -0,0 +1,655 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import {
distance2D,
pointInPolygon,
polygonArea,
polygonContainsPolygon,
type Vec2,
wallLength,
} from './geometry'
import { NodeIdSchema } from './schemas'
export const levelScopedInput = {
levelId: NodeIdSchema.optional(),
}
const jsonObject = z.record(z.string(), z.unknown())
export const listLevelsOutput = {
activeSceneId: z.string().nullable(),
levels: z.array(jsonObject),
}
export const getLevelSummaryOutput = {
levelId: z.string(),
levelName: z.string().optional(),
counts: jsonObject,
walls: z.array(jsonObject),
zones: z.array(jsonObject),
items: z.array(jsonObject),
slabs: z.array(jsonObject),
ceilings: z.array(jsonObject),
}
export const getWallsOutput = {
levelId: z.string(),
walls: z.array(jsonObject),
}
export const getZonesOutput = {
levelId: z.string(),
zones: z.array(jsonObject),
}
export const verifySceneOutput = {
ok: z.boolean(),
valid: z.boolean(),
levelCount: z.number(),
activeSceneId: z.string().nullable(),
levels: z.array(jsonObject),
emptyLevelIds: z.array(z.string()),
issues: z.array(z.string()),
hasIssues: z.boolean(),
}
function textResult<T extends Record<string, unknown>>(payload: T) {
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
function getLevels(bridge: SceneBridge): AnyNode[] {
return bridge.findNodes({ type: 'level' }).sort((a, b) => {
const aa = a.type === 'level' ? a.level : 0
const bb = b.type === 'level' ? b.level : 0
return aa - bb
})
}
function getDefaultLevelId(bridge: SceneBridge, requested?: string | undefined): AnyNodeId | null {
if (requested) return requested as AnyNodeId
const level = getLevels(bridge)[0]
return (level?.id as AnyNodeId | undefined) ?? null
}
function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
}
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
return bridge
.getChildren(wallId)
.filter((child) => child.type === 'door' || child.type === 'window')
.map((child) => ({
id: child.id,
type: child.type,
position: child.position,
width: child.width,
height: child.height,
}))
}
function wallSummary(bridge: SceneBridge, wall: AnyNode) {
if (wall.type !== 'wall') return null
const length = distance2D(wall.start, wall.end)
return {
id: wall.id,
name: wall.name,
start: wall.start,
end: wall.end,
length: Math.round(length * 100) / 100,
height: wall.height,
thickness: wall.thickness,
openings: openingSummaries(bridge, wall.id as AnyNodeId),
}
}
function zoneSummary(zone: AnyNode) {
if (zone.type !== 'zone') return null
const xs = zone.polygon.map((p) => p[0])
const zs = zone.polygon.map((p) => p[1])
return {
id: zone.id,
name: zone.name,
color: zone.color,
polygon: zone.polygon,
areaSqMeters: Math.round(polygonArea(zone.polygon) * 100) / 100,
bounds: {
width: Math.round((Math.max(...xs) - Math.min(...xs)) * 100) / 100,
depth: Math.round((Math.max(...zs) - Math.min(...zs)) * 100) / 100,
},
}
}
function itemSummary(item: AnyNode) {
if (item.type !== 'item') return null
return {
id: item.id,
name: item.name ?? item.asset.name,
parentId: item.parentId,
position: item.position,
rotation: item.rotation,
asset: {
id: item.asset.id,
name: item.asset.name,
category: item.asset.category,
dimensions: item.asset.dimensions,
attachTo: item.asset.attachTo ?? null,
},
}
}
type SegmentTransform = {
position: [number, number, number]
rotation: number
}
type StairSegmentLike = {
width: number
length: number
height: number
stepCount: number
attachmentSide: 'front' | 'left' | 'right'
}
function rotateXZ(x: number, z: number, angle: number): Vec2 {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos + z * sin, -x * sin + z * cos]
}
function toWorldPlanPoint(
stair: AnyNode & { type: 'stair' },
localX: number,
localZ: number,
): Vec2 {
const [worldX, worldZ] = rotateXZ(localX, localZ, stair.rotation ?? 0)
return [stair.position[0] + worldX, stair.position[2] + worldZ]
}
function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentX = 0
let currentY = 0
let currentZ = 0
let currentRot = 0
for (let index = 0; index < segments.length; index++) {
const segment = segments[index]
if (!segment) continue
if (index === 0) {
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
continue
}
const previous = segments[index - 1]
if (!previous) continue
let attachX = 0
let attachZ = 0
let rotationDelta = 0
switch (segment.attachmentSide) {
case 'front':
attachZ = previous.length
break
case 'left':
attachX = previous.width / 2
attachZ = previous.length / 2
rotationDelta = Math.PI / 2
break
case 'right':
attachX = -previous.width / 2
attachZ = previous.length / 2
rotationDelta = -Math.PI / 2
break
}
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
currentX += deltaX
currentY += previous.height
currentZ += deltaZ
currentRot += rotationDelta
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
}
return transforms
}
function stairFootprintPolygons(bridge: SceneBridge, stair: AnyNode & { type: 'stair' }): Vec2[][] {
if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
return [
Array.from({ length: 24 }).map((_, index) => {
const angle = (index / 24) * Math.PI * 2
return toWorldPlanPoint(stair, Math.cos(angle) * radius, Math.sin(angle) * radius)
}),
]
}
const nodes = bridge.getNodes()
const segments = (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is AnyNode & { type: 'stair-segment' } => node?.type === 'stair-segment')
const usableSegments: StairSegmentLike[] =
segments.length > 0
? segments
: [
{
width: stair.width ?? 1,
length: 3,
height: stair.totalRise ?? 2.5,
stepCount: stair.stepCount ?? 10,
attachmentSide: 'front' as const,
},
]
const transforms = computeSegmentTransforms(usableSegments)
return usableSegments.map((segment, index) => {
const transform = transforms[index] ?? {
position: [0, 0, 0] as [number, number, number],
rotation: 0,
}
const halfWidth = segment.width / 2
const corners: Vec2[] = [
[-halfWidth, 0],
[halfWidth, 0],
[halfWidth, segment.length],
[-halfWidth, segment.length],
]
return corners.map(([localX, localZ]) => {
const [rx, rz] = rotateXZ(localX, localZ, transform.rotation)
return toWorldPlanPoint(stair, transform.position[0] + rx, transform.position[2] + rz)
})
})
}
function wallSamplePoints(wall: AnyNode & { type: 'wall' }): Vec2[] {
return [0.25, 0.5, 0.75].map((t) => [
wall.start[0] + (wall.end[0] - wall.start[0]) * t,
wall.start[1] + (wall.end[1] - wall.start[1]) * t,
])
}
function getLevelNumber(
levelId: string | null | undefined,
nodes: Record<string, AnyNode>,
): number | undefined {
if (!levelId) return undefined
const node = nodes[levelId as AnyNodeId]
return node?.type === 'level' ? node.level : undefined
}
function targetLevelIdsForStair(
bridge: SceneBridge,
stair: AnyNode & { type: 'stair' },
): AnyNodeId[] {
const nodes = bridge.getNodes()
const parentLevelId = bridge.resolveLevelId(stair.id as AnyNodeId)
const fromLevelId = (stair.fromLevelId ?? parentLevelId) as string | null
const toLevelId = (stair.toLevelId ?? fromLevelId) as string | null
const fromLevel = getLevelNumber(fromLevelId, nodes)
const toLevel = getLevelNumber(toLevelId, nodes)
if (fromLevel === undefined || toLevel === undefined) {
return toLevelId ? [toLevelId as AnyNodeId] : []
}
const minLevel = Math.min(fromLevel, toLevel)
const maxLevel = Math.max(fromLevel, toLevel)
return getLevels(bridge)
.filter((level) => level.type === 'level' && level.level > minLevel && level.level <= maxLevel)
.map((level) => level.id as AnyNodeId)
}
function holeBelongsToStair(
surface: AnyNode & { type: 'slab' | 'ceiling' },
holeIndex: number,
stairId: string,
) {
const metadata = surface.holeMetadata?.[holeIndex]
return metadata?.source === 'stair' && metadata.stairId === stairId
}
function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
const level = bridge.getNode(levelId)
if (!level || level.type !== 'level') {
throw new Error(`Level not found: ${levelId}`)
}
const nodes = nodesOnLevel(bridge, levelId)
const walls = nodes
.map((n) => wallSummary(bridge, n))
.filter((n): n is NonNullable<typeof n> => !!n)
const zones = nodes.map(zoneSummary).filter((n): n is NonNullable<typeof n> => !!n)
const items = nodes.map(itemSummary).filter((n): n is NonNullable<typeof n> => !!n)
const slabs = nodes
.filter((node) => node.type === 'slab')
.map((node) => ({
id: node.id,
polygon: node.polygon,
holes: node.holes ?? [],
holeMetadata: node.holeMetadata ?? [],
elevation: node.elevation,
}))
const ceilings = nodes
.filter((node) => node.type === 'ceiling')
.map((node) => ({
id: node.id,
polygon: node.polygon,
holes: node.holes ?? [],
holeMetadata: node.holeMetadata ?? [],
height: node.height,
}))
const doors = nodes.filter((node) => node.type === 'door')
const windows = nodes.filter((node) => node.type === 'window')
const roofs = nodes.filter((node) => node.type === 'roof')
const stairs = nodes.filter((node) => node.type === 'stair')
return {
levelId,
levelName: level.name,
floorIndex: level.level,
counts: {
walls: walls.length,
zones: zones.length,
doors: doors.length,
windows: windows.length,
items: items.length,
slabs: slabs.length,
ceilings: ceilings.length,
roofs: roofs.length,
stairs: stairs.length,
},
walls,
zones,
items,
slabs,
ceilings,
}
}
export function registerListLevels(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'list_levels',
{
title: 'List levels',
description:
'List all levels in the current scene with ids, names, floor indices, and child counts.',
inputSchema: {},
outputSchema: listLevelsOutput,
},
async () => {
const activeScene = bridge.getActiveScene()
const levels = getLevels(bridge).map((level) => ({
id: level.id,
name: level.name,
floorIndex: level.type === 'level' ? level.level : 0,
parentId: level.parentId,
childCount: bridge.getChildren(level.id as AnyNodeId).length,
}))
return textResult({ activeSceneId: activeScene?.id ?? null, levels })
},
)
}
export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'get_level_summary',
{
title: 'Get level summary',
description:
'Get a compact model-friendly summary of one level: counts plus walls, zones, slabs, ceilings, and items. Omit levelId to use the first level.',
inputSchema: levelScopedInput,
outputSchema: getLevelSummaryOutput,
},
async ({ levelId }) => {
const resolved = getDefaultLevelId(bridge, levelId)
if (!resolved) throw new Error('No level exists in the scene')
return textResult(levelSummary(bridge, resolved))
},
)
}
export function registerGetWalls(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'get_walls',
{
title: 'Get walls',
description:
'Get walls on a level with start/end coordinates, length, height, thickness, and child doors/windows. Omit levelId to use the first level.',
inputSchema: levelScopedInput,
outputSchema: getWallsOutput,
},
async ({ levelId }) => {
const resolved = getDefaultLevelId(bridge, levelId)
if (!resolved) throw new Error('No level exists in the scene')
return textResult({
levelId: resolved,
walls: levelSummary(bridge, resolved).walls,
})
},
)
}
export function registerGetZones(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'get_zones',
{
title: 'Get zones',
description:
'Get room/zone polygons on a level with names, colors, bounds, and approximate areas. Omit levelId to use the first level.',
inputSchema: levelScopedInput,
outputSchema: getZonesOutput,
},
async ({ levelId }) => {
const resolved = getDefaultLevelId(bridge, levelId)
if (!resolved) throw new Error('No level exists in the scene')
return textResult({
levelId: resolved,
zones: levelSummary(bridge, resolved).zones,
})
},
)
}
export function registerVerifyScene(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'verify_scene',
{
title: 'Verify scene',
description:
'High-level self-check after complex edits. Returns validation status, per-level room/content counts, empty levels, and practical layout issues.',
inputSchema: {},
outputSchema: verifySceneOutput,
},
async () => {
const validation = bridge.validateScene()
const levels = getLevels(bridge).map((level) => {
const summary = levelSummary(bridge, level.id as AnyNodeId)
const totalContent = Object.values(summary.counts).reduce(
(sum, count) => sum + (typeof count === 'number' ? count : 0),
0,
)
return {
levelId: level.id,
levelName: level.name ?? `Level ${summary.floorIndex}`,
floorIndex: summary.floorIndex,
isEmpty: totalContent === 0,
content: summary.counts,
}
})
const issues: string[] = []
const emptyLevelIds = levels.filter((level) => level.isEmpty).map((level) => level.levelId)
if (emptyLevelIds.length > 0) {
issues.push(`Empty level(s): ${emptyLevelIds.join(', ')}`)
}
for (const level of levels) {
if (level.content.walls > 0 && level.content.zones === 0) {
issues.push(`${level.levelName} has walls but no zones/rooms`)
}
if (level.content.zones > 0 && level.content.slabs === 0) {
issues.push(`${level.levelName} has zones but no slabs/floors`)
}
if (level.content.zones > 0 && level.content.ceilings === 0) {
issues.push(`${level.levelName} has zones but no ceilings`)
}
if (level.content.walls > 0 && level.content.doors === 0) {
issues.push(`${level.levelName} has walls but no doors`)
}
if (
level.content.roofs > 0 &&
(level.content.walls > 0 || level.content.zones > 0 || level.content.stairs > 0)
) {
issues.push(
`${level.levelName} mixes roof geometry with occupied-level content; place roofs on a dedicated roof level for solo/exploded level views`,
)
}
}
const hasMultipleLevels = levels.length > 1
if (hasMultipleLevels) {
for (const level of getLevels(bridge)) {
if (level.type !== 'level') continue
const expectedHeight =
typeof level.metadata === 'object' &&
level.metadata !== null &&
'height' in level.metadata &&
typeof level.metadata.height === 'number'
? level.metadata.height
: 3.2
for (const wall of nodesOnLevel(bridge, level.id as AnyNodeId).filter(
(node): node is AnyNode & { type: 'wall' } => node.type === 'wall',
)) {
const wallHeight = wall.height ?? 2.5
if (wallHeight > expectedHeight + 0.25) {
issues.push(
`Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`,
)
}
}
}
}
for (const node of Object.values(bridge.getNodes())) {
if (node.type === 'door' || node.type === 'window') {
const parent = node.parentId ? bridge.getNode(node.parentId as AnyNodeId) : null
if (!parent || parent.type !== 'wall') {
issues.push(`${node.type} ${node.id} is not parented to a wall`)
continue
}
const length = wallLength(parent)
const width = node.width ?? (node.type === 'door' ? 0.9 : 1.5)
const localX = node.position[0]
if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) {
issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`)
}
}
}
for (const stair of Object.values(bridge.getNodes()).filter(
(node): node is AnyNode & { type: 'stair' } => node.type === 'stair',
)) {
const sourceLevelId = bridge.resolveLevelId(stair.id as AnyNodeId)
if (sourceLevelId) {
const sourceLevel = bridge.getNode(sourceLevelId)
const footprints = stairFootprintPolygons(bridge, stair)
const obstructingWalls = nodesOnLevel(bridge, sourceLevelId)
.filter((node): node is AnyNode & { type: 'wall' } => node.type === 'wall')
.filter((wall) =>
footprints.some((footprint) =>
wallSamplePoints(wall).some((point) => pointInPolygon(point, footprint, false)),
),
)
for (const wall of obstructingWalls) {
issues.push(
`Wall ${wall.name ?? wall.id} obstructs stair ${stair.name ?? stair.id} on ${
sourceLevel?.name ?? sourceLevelId
}`,
)
}
}
if ((stair.slabOpeningMode ?? 'none') === 'destination') {
const targetLevelIds = targetLevelIdsForStair(bridge, stair)
if (targetLevelIds.length === 0) {
issues.push(
`Stair ${stair.name ?? stair.id} requests a slab opening but has no target level`,
)
}
for (const targetLevelId of targetLevelIds) {
const targetLevel = bridge.getNode(targetLevelId)
const targetSlabs = nodesOnLevel(bridge, targetLevelId).filter(
(node): node is AnyNode & { type: 'slab' } => node.type === 'slab',
)
if (targetSlabs.length === 0) {
issues.push(
`Stair ${stair.name ?? stair.id} targets ${targetLevel?.name ?? targetLevelId} but it has no slab`,
)
continue
}
const matchingHoles = targetSlabs.flatMap((slab) =>
(slab.holes ?? [])
.map((hole, index) => ({ slab, hole, index }))
.filter((entry) => holeBelongsToStair(entry.slab, entry.index, stair.id)),
)
if (matchingHoles.length === 0) {
issues.push(
`Stair ${stair.name ?? stair.id} has no destination slab opening on ${
targetLevel?.name ?? targetLevelId
}`,
)
continue
}
for (const { slab, hole } of matchingHoles) {
if (!polygonContainsPolygon(slab.polygon as Vec2[], hole as Vec2[])) {
issues.push(
`Stair ${stair.name ?? stair.id} opening extends outside slab ${slab.name ?? slab.id}`,
)
}
}
}
}
}
if (!validation.valid) {
for (const error of validation.errors.slice(0, 5)) {
issues.push(`Schema: ${error.nodeId}.${error.path} ${error.message}`)
}
if (validation.errors.length > 5) {
issues.push(`Schema: ${validation.errors.length - 5} additional validation errors`)
}
}
const payload = {
ok: true,
valid: validation.valid,
levelCount: levels.length,
activeSceneId: bridge.getActiveScene()?.id ?? null,
levels,
emptyLevelIds,
issues,
hasIssues: issues.length > 0,
}
return textResult(payload)
},
)
}
export function registerSceneQueryTools(server: McpServer, bridge: SceneBridge): void {
registerListLevels(server, bridge)
registerGetLevelSummary(server, bridge)
registerGetWalls(server, bridge)
registerGetZones(server, bridge)
registerVerifyScene(server, bridge)
}
+4 -1
View File
@@ -3,7 +3,9 @@ import type { AnyNodeId } from '@pascal-app/core/schema'
import { ZoneNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
export const setZoneInput = {
@@ -17,7 +19,7 @@ export const setZoneOutput = {
zoneId: z.string(),
}
export function registerSetZone(server: McpServer, bridge: SceneBridge): void {
export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
server.registerTool(
'set_zone',
{
@@ -45,6 +47,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge): void {
metadata: properties ?? {},
})
const id = bridge.createNode(zone, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'set_zone')
const payload = { zoneId: id as string }
return {
@@ -7,6 +7,7 @@ import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children'
import type { SceneStore } from '../../storage/types'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
export const createFromTemplateInput = {
id: z
@@ -104,6 +105,7 @@ export function registerCreateFromTemplate(
}
if (!save) {
bridge.clearActiveScene()
return {
content: [{ type: 'text' as const, text: JSON.stringify(basePayload) }],
structuredContent: basePayload,
@@ -114,10 +116,11 @@ export function registerCreateFromTemplate(
// Graceful no-store mode: report that save was skipped rather than
// erroring — this makes the tool usable in headless bridge-only
// deployments (tests, smoke scripts) without crashing.
bridge.clearActiveScene()
const payload = { ...basePayload, saveSkipped: true } as const
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: basePayload,
structuredContent: payload,
}
}
@@ -127,6 +130,11 @@ export function registerCreateFromTemplate(
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', {
nodes,
rootNodeIds,
})
const scene = {
id: meta.id,
name: meta.name,
+4 -1
View File
@@ -1,6 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync'
export const undoInput = {
steps: z.number().int().positive().optional(),
@@ -10,7 +12,7 @@ export const undoOutput = {
undone: z.number(),
}
export function registerUndo(server: McpServer, bridge: SceneBridge): void {
export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
server.registerTool(
'undo',
{
@@ -22,6 +24,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge): void {
},
async ({ steps }) => {
const undone = bridge.undo(steps ?? 1)
if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo')
const payload = { undone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],