Reset core/viewer/editor/mcp packages and apps/editor from private-editor

Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
This commit is contained in:
Wawa
2026-05-09 20:52:26 +00:00
parent 146f0a846f
commit e618175bba
196 changed files with 5899 additions and 3966 deletions
+4 -1
View File
@@ -29,7 +29,7 @@ export async function publishLiveSceneSnapshot(
syncDerivedStairOpenings(operations)
const active = operations.getActiveScene()
if (!active || !operations.canAppendSceneEvents) return
if (!(active && operations.canAppendSceneEvents)) return
const graph = operations.exportSceneGraph()
@@ -42,6 +42,9 @@ export async function publishLiveSceneSnapshot(
thumbnailUrl: active.thumbnailUrl,
graph,
expectedVersion: active.version,
saveMode: 'draft',
publish: false,
operation: kind,
})
operations.setActiveScene(meta)
await operations.appendSceneEvent({
+1 -1
View File
@@ -129,7 +129,7 @@ export function registerMeasure(server: McpServer, bridge: SceneOperations): voi
const fromCentre = getCentre(from as AnyNode)
const toCentre = getCentre(to as AnyNode)
if (!fromCentre || !toCentre) {
if (!(fromCentre && toCentre)) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot derive centre for measurement between ${from.type} and ${to.type}`,
+3 -4
View File
@@ -27,7 +27,7 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v
{
title: 'Place item',
description:
'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.',
'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, or a ceiling for ceiling-attached items. Do not target the site node directly.',
inputSchema: placeItemInput,
outputSchema: placeItemOutput,
},
@@ -42,12 +42,11 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v
targetType !== 'slab' &&
targetType !== 'zone' &&
targetType !== 'wall' &&
targetType !== 'ceiling' &&
targetType !== 'site'
targetType !== 'ceiling'
) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling, or site`,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, or ceiling. Site-level placement is not supported yet because site.children is reserved for buildings.`,
)
}
+10
View File
@@ -107,6 +107,10 @@ describe('room tools', () => {
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(2.5, 3)
expect(door.t).toBe(0.5)
expect(door.position).toBe(0.5)
expect(door.wallLength).toBeCloseTo(5, 3)
expect(door.coordinateSystem).toBe('wall-local-meters')
expect(
(bridge.getNode(door.doorId) as { position: [number, number, number] }).position[0],
).toBeCloseTo(2.5, 3)
@@ -117,6 +121,10 @@ describe('room tools', () => {
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(1.25, 3)
expect(win.t).toBe(0.25)
expect(win.position).toBe(0.25)
expect(win.wallLength).toBeCloseTo(5, 3)
expect(win.coordinateSystem).toBe('wall-local-meters')
expect(
(bridge.getNode(win.windowId) as { position: [number, number, number] }).position[1],
).toBe(1.5)
@@ -146,6 +154,7 @@ describe('room tools', () => {
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(1.5, 3)
expect(door.t).toBe(0.25)
const windowResult = await client.callTool({
name: 'add_window',
@@ -153,6 +162,7 @@ describe('room tools', () => {
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(4.5, 3)
expect(win.t).toBe(0.75)
expect(bridge.validateScene().valid).toBe(true)
})
+33 -4
View File
@@ -76,6 +76,11 @@ export const addDoorInput = {
export const addDoorOutput = {
doorId: z.string(),
localX: z.number(),
t: z.number(),
position: z.number(),
wallLength: z.number(),
clamped: z.boolean(),
coordinateSystem: z.literal('wall-local-meters'),
}
export const addWindowInput = {
@@ -90,6 +95,11 @@ export const addWindowInput = {
export const addWindowOutput = {
windowId: z.string(),
localX: z.number(),
t: z.number(),
position: z.number(),
wallLength: z.number(),
clamped: z.boolean(),
coordinateSystem: z.literal('wall-local-meters'),
sillHeight: z.number(),
}
@@ -471,7 +481,15 @@ export function registerAddDoor(server: McpServer, bridge: SceneOperations): voi
})
const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'add_door')
return textResult({ doorId: id, localX })
return textResult({
doorId: id,
localX,
t: wallT,
position: wallT,
wallLength: length,
clamped: Math.abs(localX - wallT * length) > 1e-9,
coordinateSystem: 'wall-local-meters',
})
},
)
}
@@ -506,7 +524,16 @@ export function registerAddWindow(server: McpServer, bridge: SceneOperations): v
})
const id = bridge.createNode(windowNode, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'add_window')
return textResult({ windowId: id, localX, sillHeight })
return textResult({
windowId: id,
localX,
t: wallT,
position: wallT,
wallLength: length,
clamped: Math.abs(localX - wallT * length) > 1e-9,
coordinateSystem: 'wall-local-meters',
sillHeight,
})
},
)
}
@@ -539,8 +566,10 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations):
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)
!(
pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) &&
pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding)
)
) {
skipped.push(`${asset.id}: outside room bounds`)
continue
@@ -0,0 +1,85 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { currentLevelContext, projectStatusPayload } from './metadata'
export const createProjectInput = {
name: z.string().min(1).max(200),
id: z.string().min(1).max(64).optional(),
isPrivate: z.boolean().default(true),
}
export const createProjectOutput = {
id: z.string(),
projectId: z.string(),
name: z.string(),
editorUrl: z.string(),
url: z.string(),
ownerId: z.string().nullable(),
thumbnailUrl: z.string().nullable(),
publishedVersion: z.number().nullable(),
latestVersion: z.number().nullable(),
draftVersion: z.number().nullable(),
browserVisibleVersion: z.number().nullable(),
version: z.number(),
isEmpty: z.boolean(),
sizeBytes: z.number(),
nodeCount: z.number(),
graphHash: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
nextStep: z.string(),
}
export function registerCreateProject(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'create_project',
{
title: 'Create project',
description:
'Create a browser-visible Pascal project for the authenticated user. Use this before save_scene when the user asks for a new project.',
inputSchema: createProjectInput,
outputSchema: createProjectOutput,
},
async ({ name, id, isPrivate }) => {
if (!operations.canCreateProject) {
throwMcpError(
ErrorCode.InvalidRequest,
'create_project_unavailable: this MCP store cannot create hosted projects',
)
}
try {
const status = await operations.createProject({
name,
...(id !== undefined ? { id } : {}),
isPrivate,
})
operations.setActiveScene({
id: status.id,
name: status.name,
projectId: status.projectId,
ownerId: status.ownerId,
thumbnailUrl: status.thumbnailUrl,
version: status.version,
})
const payload = {
...projectStatusPayload(
status,
'The project is now bound to this MCP session. Open editorUrl now; semantic tools will update the browser-visible draft. Call save_scene with saveMode: "checkpoint" only when you want a meaningful version.',
),
...currentLevelContext(operations),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InvalidRequest, msg)
}
},
)
}
@@ -0,0 +1,78 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, McpError, throwMcpError } from '../errors'
import { currentLevelContext, projectStatusPayload } from './metadata'
export const getProjectStatusInput = {
id: z.string().min(1).max(64),
}
export const getProjectStatusOutput = {
id: z.string(),
projectId: z.string(),
name: z.string(),
editorUrl: z.string(),
url: z.string(),
ownerId: z.string().nullable(),
thumbnailUrl: z.string().nullable(),
publishedVersion: z.number().nullable(),
latestVersion: z.number().nullable(),
draftVersion: z.number().nullable(),
browserVisibleVersion: z.number().nullable(),
version: z.number(),
isEmpty: z.boolean(),
sizeBytes: z.number(),
nodeCount: z.number(),
graphHash: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
nextStep: z.string(),
}
export function registerGetProjectStatus(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'get_project_status',
{
title: 'Get project status',
description:
'Authoritative status/debug call for a Pascal project: editor URL, browser-visible version, latest saved version, published version, node count, and graph hash.',
inputSchema: getProjectStatusInput,
outputSchema: getProjectStatusOutput,
},
async ({ id }) => {
try {
const status = await operations.getProjectStatus(id)
if (!status) {
throwMcpError(ErrorCode.InvalidParams, 'project_not_found', { id })
}
const activeScene = operations.getActiveScene()
if (activeScene?.id !== status.id) {
const scene = await operations.loadStoredScene(status.id)
if (scene) {
operations.loadJSON(scene.graph)
operations.setActiveScene(scene)
}
}
const nextStep =
status.nodeCount > 0
? 'Open editorUrl or continue editing, then save_scene again.'
: 'Project is empty. Build a scene with semantic tools or create_from_template, then save_scene.'
const payload = {
...projectStatusPayload(status, nextStep),
...currentLevelContext(operations),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
if (err instanceof McpError) throw err
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InternalError, msg)
}
},
)
}
@@ -1,6 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../../operations'
import { registerCreateProject } from './create-project'
import { registerDeleteScene } from './delete-scene'
import { registerGetProjectStatus } from './get-project-status'
import { registerListScenes } from './list-scenes'
import { registerLoadScene } from './load-scene'
import { registerRenameScene } from './rename-scene'
@@ -13,6 +15,8 @@ import { registerSaveScene } from './save-scene'
* entry points share the same storage boundary.
*/
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
registerCreateProject(server, operations)
registerGetProjectStatus(server, operations)
registerSaveScene(server, operations)
registerLoadScene(server, operations)
registerListScenes(server, operations)
@@ -20,7 +24,13 @@ export function registerSceneLifecycleTools(server: McpServer, operations: Scene
registerRenameScene(server, operations)
}
export { createProjectInput, createProjectOutput, registerCreateProject } from './create-project'
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
export {
getProjectStatusInput,
getProjectStatusOutput,
registerGetProjectStatus,
} from './get-project-status'
export { listScenesInput, listScenesOutput, registerListScenes } from './list-scenes'
export { loadSceneInput, loadSceneOutput, registerLoadScene } from './load-scene'
export { registerRenameScene, renameSceneInput, renameSceneOutput } from './rename-scene'
@@ -23,6 +23,10 @@ export const listScenesOutput = {
ownerId: z.string().nullable(),
sizeBytes: z.number(),
nodeCount: z.number(),
editorUrl: z.string().optional(),
url: z.string().optional(),
published: z.boolean().optional(),
graphHash: z.string().optional(),
}),
),
}
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { currentLevelContext, sceneMetaPayload } from './metadata'
export const loadSceneInput = {
id: z.string().min(1).max(64),
@@ -18,6 +19,14 @@ export const loadSceneOutput = {
ownerId: z.string().nullable(),
sizeBytes: z.number(),
nodeCount: z.number(),
url: z.string(),
editorUrl: z.string(),
published: z.boolean(),
isDraft: z.boolean(),
saveMode: z.enum(['draft', 'checkpoint']),
graphHash: z.string().optional(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
}
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
@@ -43,16 +52,8 @@ export function registerLoadScene(server: McpServer, bridge: SceneOperations): v
throwMcpError(ErrorCode.InvalidRequest, `load_failed: ${msg}`, { id })
}
const payload = {
id: result.id,
name: result.name,
projectId: result.projectId,
thumbnailUrl: result.thumbnailUrl,
version: result.version,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
ownerId: result.ownerId,
sizeBytes: result.sizeBytes,
nodeCount: result.nodeCount,
...sceneMetaPayload(result, result.graph),
...currentLevelContext(bridge),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNodeId } from '@pascal-app/core/schema'
import type { SceneOperations } from '../../operations'
import type { ProjectStatus, SceneMeta } from '../../storage/types'
export function computeGraphHash(graph: SceneGraph): string {
const normalized = JSON.stringify({
nodes: graph.nodes ?? {},
rootNodeIds: graph.rootNodeIds ?? [],
collections: (graph as Record<string, unknown>).collections ?? {},
})
return createHash('sha256').update(normalized).digest('hex')
}
export function editorUrlFor(meta: Pick<SceneMeta, 'id' | 'editorUrl' | 'url'>): string {
return meta.editorUrl ?? meta.url ?? `/editor/${meta.id}`
}
export function sceneMetaPayload(meta: SceneMeta, graph?: SceneGraph) {
const editorUrl = editorUrlFor(meta)
return {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
editorUrl,
url: editorUrl,
published: meta.published ?? true,
isDraft: meta.isDraft ?? false,
saveMode: meta.saveMode ?? (meta.isDraft ? 'draft' : 'checkpoint'),
graphHash: meta.graphHash ?? (graph ? computeGraphHash(graph) : undefined),
}
}
export function projectStatusPayload(status: ProjectStatus, nextStep?: string) {
return {
id: status.id,
projectId: status.projectId,
name: status.name,
editorUrl: status.editorUrl,
url: status.url,
ownerId: status.ownerId,
thumbnailUrl: status.thumbnailUrl,
publishedVersion: status.publishedVersion,
latestVersion: status.latestVersion,
draftVersion: status.draftVersion,
browserVisibleVersion: status.browserVisibleVersion,
version: status.version,
isEmpty: status.isEmpty,
sizeBytes: status.sizeBytes,
nodeCount: status.nodeCount,
graphHash: status.graphHash,
createdAt: status.createdAt,
updatedAt: status.updatedAt,
...(nextStep ? { nextStep } : {}),
}
}
export function currentLevelContext(operations: SceneOperations) {
const levels = operations.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
})
const levelIds = levels.map((level) => level.id as AnyNodeId as string)
return {
levelIds,
defaultLevelId: levelIds[0] ?? null,
}
}
@@ -0,0 +1,55 @@
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 { registerCreateProject } from './create-project'
import { registerGetProjectStatus } from './get-project-status'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('project lifecycle tools', () => {
let client: Client
let store: InMemorySceneStore
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateProject(server, operations)
registerGetProjectStatus(server, operations)
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('creates a project and returns an editor URL', async () => {
const result = await client.callTool({
name: 'create_project',
arguments: { name: 'Dogfood house' },
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.name).toBe('Dogfood house')
expect(typeof parsed.projectId).toBe('string')
expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`)
expect(parsed.nodeCount).toBe(0)
expect(parsed.nextStep).toContain('save_scene')
})
test('reports status for an existing project', async () => {
const project = await store.createProject({ name: 'Status house' })
const result = await client.callTool({
name: 'get_project_status',
arguments: { id: project.projectId },
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.projectId).toBe(project.projectId)
expect(parsed.editorUrl).toBe(`/editor/${project.projectId}`)
expect(parsed.nodeCount).toBe(0)
})
})
@@ -29,7 +29,7 @@ describe('save_scene', () => {
await Promise.all([server.connect(srvT), client.connect(cliT)])
})
test('saves the current scene and returns SceneMeta with url', async () => {
test('saves the current scene and returns SceneMeta with editorUrl', async () => {
const result = await client.callTool({
name: 'save_scene',
arguments: { name: 'My Scene' },
@@ -39,7 +39,10 @@ describe('save_scene', () => {
expect(parsed.name).toBe('My Scene')
expect(typeof parsed.id).toBe('string')
expect(parsed.version).toBe(1)
expect(parsed.url).toBe(`/scene/${parsed.id}`)
expect(parsed.url).toBe(`/editor/${parsed.id}`)
expect(parsed.editorUrl).toBe(`/editor/${parsed.id}`)
expect(parsed.published).toBe(true)
expect(typeof parsed.graphHash).toBe('string')
expect(parsed.nodeCount).toBeGreaterThan(0)
})
@@ -6,12 +6,23 @@ import type { SceneOperations } from '../../operations'
import { SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
import { currentLevelContext, sceneMetaPayload } from './metadata'
export const saveSceneInput = {
id: z.string().min(1).max(64).optional(),
name: z.string().min(1).max(200),
projectId: z.string().optional(),
expectedVersion: z.number().int().positive().optional(),
saveMode: z
.enum(['draft', 'checkpoint'])
.default('draft')
.describe(
'`draft` updates the browser-visible working model without polluting version history. `checkpoint` creates a meaningful saved version.',
),
publish: z
.boolean()
.optional()
.describe('For checkpoint saves, publish the checkpoint as the browser-visible version.'),
thumbnail: z.string().url().optional(),
includeCurrentScene: z
.boolean()
@@ -37,6 +48,13 @@ export const saveSceneOutput = {
sizeBytes: z.number(),
nodeCount: z.number(),
url: z.string(),
editorUrl: z.string(),
published: z.boolean(),
isDraft: z.boolean(),
saveMode: z.enum(['draft', 'checkpoint']),
graphHash: z.string().optional(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
}
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
@@ -45,11 +63,21 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
{
title: 'Save scene',
description:
'Persist the current scene (or a provided graph) to the SceneStore. Returns the SceneMeta along with a `url` pointing to `/scene/<id>`.',
'Save the current scene (or a provided graph) to the SceneStore. Defaults to a browser-visible draft save so agents can iterate without creating many project versions. Use saveMode: "checkpoint" for meaningful version history.',
inputSchema: saveSceneInput,
outputSchema: saveSceneOutput,
},
async ({ id, name, projectId, expectedVersion, thumbnail, includeCurrentScene, graph }) => {
async ({
id,
name,
projectId,
expectedVersion,
saveMode,
publish,
thumbnail,
includeCurrentScene,
graph,
}) => {
let sceneGraph: SceneGraph
if (includeCurrentScene) {
const validation = bridge.validateScene()
@@ -98,23 +126,17 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
graph: sceneGraph,
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}),
saveMode,
...(publish !== undefined ? { publish } : {}),
operation: 'save_scene',
})
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
if (includeCurrentScene) {
bridge.setActiveScene(meta)
}
const payload = {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
url: `/scene/${meta.id}`,
...sceneMetaPayload(meta, sceneGraph),
...currentLevelContext(bridge),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -1,6 +1,8 @@
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from '../../operations'
import {
type ProjectCreateOptions,
type ProjectStatus,
type SceneListOptions,
type SceneMeta,
type SceneMutateOptions,
@@ -10,6 +12,7 @@ import {
SceneVersionConflictError,
type SceneWithGraph,
} from '../../storage/types'
import { computeGraphHash, editorUrlFor } from './metadata'
export type StoredTextContent = { type: string; text: string }
@@ -39,7 +42,40 @@ export function createTestSceneOperations(options?: {
export class InMemorySceneStore implements SceneStore {
readonly backend = 'sqlite' as const
private readonly data = new Map<string, SceneWithGraph>()
private readonly projects = new Map<
string,
{
id: string
name: string
ownerId: string | null
isPrivate: boolean
thumbnailUrl: string | null
createdAt: string
updatedAt: string
}
>()
private idCounter = 0
private projectCounter = 0
async createProject(opts: ProjectCreateOptions): Promise<ProjectStatus> {
const id = opts.id ?? `project_${++this.projectCounter}`
const now = new Date().toISOString()
this.projects.set(id, {
id,
name: opts.name,
ownerId: opts.ownerId ?? null,
isPrivate: opts.isPrivate ?? true,
thumbnailUrl: null,
createdAt: now,
updatedAt: now,
})
return this.toProjectStatus(id)
}
async getProjectStatus(id: string): Promise<ProjectStatus | null> {
if (!(this.projects.has(id) || this.data.has(id))) return null
return this.toProjectStatus(id)
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
const existing = opts.id ? this.data.get(opts.id) : undefined
@@ -63,9 +99,14 @@ export class InMemorySceneStore implements SceneStore {
ownerId: opts.ownerId ?? existing.ownerId,
sizeBytes: serialized.length,
nodeCount,
editorUrl: existing.editorUrl ?? `/editor/${existing.id}`,
url: existing.url ?? `/editor/${existing.id}`,
published: true,
graphHash: computeGraphHash(opts.graph),
graph: opts.graph,
}
this.data.set(existing.id, updated)
this.touchProject(existing.id, opts.name, updated.updatedAt)
return this.toMeta(updated)
}
@@ -80,7 +121,7 @@ export class InMemorySceneStore implements SceneStore {
const record: SceneWithGraph = {
id,
name: opts.name,
projectId: opts.projectId ?? null,
projectId: opts.projectId ?? (this.projects.has(id) ? id : null),
thumbnailUrl: opts.thumbnailUrl ?? null,
version: 1,
createdAt: now,
@@ -88,9 +129,14 @@ export class InMemorySceneStore implements SceneStore {
ownerId: opts.ownerId ?? null,
sizeBytes: serialized.length,
nodeCount,
editorUrl: `/editor/${id}`,
url: `/editor/${id}`,
published: true,
graphHash: computeGraphHash(opts.graph),
graph: opts.graph,
}
this.data.set(id, record)
this.touchProject(id, opts.name, now)
return this.toMeta(record)
}
@@ -142,10 +188,29 @@ export class InMemorySceneStore implements SceneStore {
updatedAt: new Date().toISOString(),
}
this.data.set(id, updated)
this.touchProject(id, newName, updated.updatedAt)
return this.toMeta(updated)
}
private touchProject(id: string, name: string, updatedAt: string): void {
const existing = this.projects.get(id)
if (existing) {
this.projects.set(id, { ...existing, name, updatedAt })
return
}
this.projects.set(id, {
id,
name,
ownerId: null,
isPrivate: true,
thumbnailUrl: null,
createdAt: updatedAt,
updatedAt,
})
}
private toMeta(rec: SceneWithGraph): SceneMeta {
const editorUrl = editorUrlFor(rec)
return {
id: rec.id,
name: rec.name,
@@ -157,6 +222,37 @@ export class InMemorySceneStore implements SceneStore {
ownerId: rec.ownerId,
sizeBytes: rec.sizeBytes,
nodeCount: rec.nodeCount,
editorUrl,
url: editorUrl,
published: rec.published ?? true,
graphHash: rec.graphHash ?? computeGraphHash(rec.graph),
}
}
private toProjectStatus(id: string): ProjectStatus {
const project = this.projects.get(id)
const scene = this.data.get(id)
const now = new Date().toISOString()
const editorUrl = `/editor/${id}`
return {
id,
projectId: id,
name: scene?.name ?? project?.name ?? id,
editorUrl,
url: editorUrl,
ownerId: scene?.ownerId ?? project?.ownerId ?? null,
thumbnailUrl: scene?.thumbnailUrl ?? project?.thumbnailUrl ?? null,
publishedVersion: scene?.version ?? null,
latestVersion: scene?.version ?? null,
draftVersion: null,
browserVisibleVersion: scene?.version ?? null,
version: scene?.version ?? 0,
isEmpty: !scene || scene.nodeCount === 0,
sizeBytes: scene?.sizeBytes ?? 0,
nodeCount: scene?.nodeCount ?? 0,
graphHash: scene?.graphHash ?? (scene ? computeGraphHash(scene.graph) : null),
createdAt: scene?.createdAt ?? project?.createdAt ?? now,
updatedAt: scene?.updatedAt ?? project?.updatedAt ?? now,
}
}
}
+1 -1
View File
@@ -382,7 +382,7 @@ function holeBelongsToStair(
}
function parentListsChild(parent: AnyNode, childId: string): boolean {
if (!('children' in parent) || !Array.isArray(parent.children)) return false
if (!('children' in parent && Array.isArray(parent.children))) return false
return parent.children.some((child) => {
if (typeof child === 'string') return child === childId
return (
@@ -7,6 +7,7 @@ import type { SceneOperations } from '../../operations'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
import { currentLevelContext, sceneMetaPayload } from '../scene-lifecycle/metadata'
export const createFromTemplateInput = {
id: z
@@ -48,6 +49,13 @@ export const createFromTemplateOutput = {
sizeBytes: z.number(),
nodeCount: z.number(),
url: z.string(),
editorUrl: z.string(),
published: z.boolean(),
isDraft: z.boolean(),
saveMode: z.enum(['draft', 'checkpoint']),
graphHash: z.string().optional(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
})
.optional(),
}
@@ -121,10 +129,18 @@ export function registerCreateFromTemplate(server: McpServer, bridge: SceneOpera
}
try {
let saveProjectId = projectId
if (!saveProjectId && bridge.canCreateProject) {
const project = await bridge.createProject({ name: name ?? entry.name })
saveProjectId = project.projectId
}
const meta = await bridge.saveScene({
...(saveProjectId !== undefined ? { id: saveProjectId, projectId: saveProjectId } : {}),
name: name ?? entry.name,
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
saveMode: 'draft',
publish: false,
operation: 'create_from_template',
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
@@ -132,17 +148,8 @@ export function registerCreateFromTemplate(server: McpServer, bridge: SceneOpera
rootNodeIds,
})
const scene = {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
url: `/scene/${meta.id}`,
...sceneMetaPayload(meta, { nodes, rootNodeIds }),
...currentLevelContext(bridge),
}
const payload = { ...basePayload, scene }
return {
@@ -0,0 +1,210 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children'
import type { SceneOperations } from '../../operations'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
import { currentLevelContext, sceneMetaPayload } from '../scene-lifecycle/metadata'
export const createHouseFromBriefInput = {
brief: z.string().min(1),
projectId: z.string().optional(),
projectName: z.string().min(1).max(200).optional(),
bedroomCount: z.number().int().min(0).max(12).optional(),
rooms: z.array(z.string().min(1)).optional(),
style: z.string().optional(),
landscaping: z.boolean().optional(),
constraints: z.string().optional(),
}
export const createHouseFromBriefOutput = {
projectId: z.string().nullable(),
editorUrl: z.string().nullable(),
url: z.string().nullable(),
version: z.number().nullable(),
published: z.boolean(),
isDraft: z.boolean(),
templateId: z.string(),
nodeCount: z.number(),
roomCount: z.number(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
validation: z.object({
valid: z.boolean(),
errors: z.array(z.string()),
}),
summary: z.string(),
limitations: z.array(z.string()),
nextStep: z.string(),
}
function chooseTemplate(args: {
bedroomCount?: number
rooms?: string[]
landscaping?: boolean
}): TemplateId {
const requested = new Set((args.rooms ?? []).map((room) => room.toLowerCase()))
if (
args.landscaping ||
requested.has('garden') ||
requested.has('patio') ||
requested.has('yard')
) {
return 'garden-house'
}
if ((args.bedroomCount ?? 0) <= 1) return 'empty-studio'
if ((args.bedroomCount ?? 0) <= 2) return 'two-bedroom'
return 'garden-house'
}
function countNodeTypes(nodes: Record<AnyNodeId, AnyNode>): {
nodeCount: number
roomCount: number
} {
let roomCount = 0
for (const node of Object.values(nodes)) {
if (node.type === 'zone') roomCount++
}
return {
nodeCount: Object.keys(nodes).length,
roomCount,
}
}
export function registerCreateHouseFromBrief(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_house_from_brief',
{
title: 'Create house from brief',
description:
'High-level hosted workflow for external agents: choose a starter house from a brief, create/save/publish it, and return the editor URL. Use semantic tools afterward for exact customization.',
inputSchema: createHouseFromBriefInput,
outputSchema: createHouseFromBriefOutput,
},
async ({
brief,
projectId,
projectName,
bedroomCount,
rooms,
style,
landscaping,
constraints,
}) => {
const templateId = chooseTemplate({
...(bedroomCount !== undefined ? { bedroomCount } : {}),
...(rooms !== undefined ? { rooms } : {}),
...(landscaping !== undefined ? { landscaping } : {}),
})
if (!isTemplateId(templateId)) {
throwMcpError(ErrorCode.InternalError, `unknown_template: ${templateId}`)
}
const entry = TEMPLATES[templateId]
const cloned = rehydrateSiteChildren(cloneSceneGraph(entry.template))
const nodes = cloned.nodes as Record<AnyNodeId, AnyNode>
const rootNodeIds = cloned.rootNodeIds as AnyNodeId[]
const counts = countNodeTypes(nodes)
try {
bridge.setScene(nodes, rootNodeIds)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InternalError, `apply_failed: ${msg}`)
}
const rawValidation = bridge.validateScene()
const validation = {
valid: rawValidation.valid,
errors: rawValidation.errors.map(
(error) => `${error.nodeId}:${error.path}: ${error.message}`,
),
}
const limitations: string[] = []
if ((bedroomCount ?? 0) > 2) {
limitations.push(
'MVP create_house_from_brief uses the closest built-in template for 3+ bedroom requests; refine with create_room/add_door/add_window for exact room count.',
)
}
if (style || constraints) {
limitations.push(
'Style and constraints are recorded in the summary but not yet fully synthesized into custom geometry.',
)
}
if (!bridge.hasStore) {
const payload = {
projectId: null,
editorUrl: null,
url: null,
version: null,
published: false,
isDraft: false,
templateId,
nodeCount: counts.nodeCount,
roomCount: counts.roomCount,
...currentLevelContext(bridge),
validation,
summary: `Applied ${entry.name} starter scene from brief: ${brief}`,
limitations,
nextStep:
'No SceneStore is attached. Call save_scene later in a hosted MCP session to publish.',
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
try {
let saveProjectId = projectId
if (!saveProjectId && bridge.canCreateProject) {
const project = await bridge.createProject({ name: projectName ?? entry.name })
saveProjectId = project.projectId
}
const meta = await bridge.saveScene({
...(saveProjectId !== undefined ? { id: saveProjectId, projectId: saveProjectId } : {}),
name: projectName ?? entry.name,
graph: { nodes, rootNodeIds },
saveMode: 'draft',
publish: false,
operation: 'create_house_from_brief',
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_house_from_brief', {
nodes,
rootNodeIds,
})
const scene = sceneMetaPayload(meta, { nodes, rootNodeIds })
const payload = {
projectId: scene.projectId ?? scene.id,
editorUrl: scene.editorUrl,
url: scene.url,
version: scene.version,
published: scene.published,
isDraft: scene.isDraft,
templateId,
nodeCount: scene.nodeCount,
roomCount: counts.roomCount,
...currentLevelContext(bridge),
validation,
summary: `Created ${scene.name} from ${entry.name}. Brief: ${brief}`,
limitations,
nextStep:
'Call verify_scene and get_project_status. If the brief needs more specificity, refine with semantic tools and save_scene again.',
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InternalError, `save_failed: ${msg}`)
}
},
)
}
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../../operations'
import { registerCreateFromTemplate } from './create-from-template'
import { registerCreateHouseFromBrief } from './create-house-from-brief'
import { registerListTemplates } from './list-templates'
/**
@@ -13,6 +14,7 @@ import { registerListTemplates } from './list-templates'
export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
registerListTemplates(server)
registerCreateFromTemplate(server, bridge)
registerCreateHouseFromBrief(server, bridge)
}
export {
@@ -20,6 +22,11 @@ export {
createFromTemplateOutput,
registerCreateFromTemplate,
} from './create-from-template'
export {
createHouseFromBriefInput,
createHouseFromBriefOutput,
registerCreateHouseFromBrief,
} from './create-house-from-brief'
export {
listTemplatesInput,
listTemplatesOutput,
@@ -10,6 +10,7 @@ import {
type StoredTextContent,
} from '../scene-lifecycle/test-utils'
import { registerCreateFromTemplate } from './create-from-template'
import { registerCreateHouseFromBrief } from './create-house-from-brief'
import { registerListTemplates } from './list-templates'
describe('list_templates', () => {
@@ -63,6 +64,7 @@ describe('create_from_template', () => {
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, operations)
registerCreateHouseFromBrief(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -106,9 +108,16 @@ describe('create_from_template', () => {
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.scene).toBeDefined()
const scene = parsed.scene as { id: string; name: string; url: string; nodeCount: number }
const scene = parsed.scene as {
id: string
name: string
url: string
editorUrl: string
nodeCount: number
}
expect(scene.name).toBe('My flat')
expect(scene.url).toBe(`/scene/${scene.id}`)
expect(scene.url).toBe(`/editor/${scene.id}`)
expect(scene.editorUrl).toBe(`/editor/${scene.id}`)
expect(scene.nodeCount).toBeGreaterThan(0)
// Confirm the store actually holds it.
@@ -131,6 +140,26 @@ describe('create_from_template', () => {
expect(idsB).not.toContain(id)
}
})
test('create_house_from_brief creates, saves, and returns an editor URL', async () => {
const result = await client.callTool({
name: 'create_house_from_brief',
arguments: {
brief: 'Create a compact modern two-bedroom home with a small patio.',
projectName: 'Brief house',
bedroomCount: 2,
landscaping: true,
},
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(typeof parsed.projectId).toBe('string')
expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`)
expect(parsed.version).toBe(1)
expect(parsed.published).toBe(true)
expect(parsed.nodeCount as number).toBeGreaterThan(0)
expect(parsed.nextStep as string).toContain('get_project_status')
})
})
describe('create_from_template without a store', () => {
@@ -119,7 +119,7 @@ export function registerGenerateVariants(server: McpServer, bridge: SceneOperati
// 2. Seed the RNG. Default seed is a time-ish number so runs vary, but
// tests always pass a fixed seed for determinism.
const initialSeed = seed ?? Math.floor(Math.random() * 0xffffffff)
const initialSeed = seed ?? Math.floor(Math.random() * 0xff_ff_ff_ff)
const mutations = vary as MutationKind[]
const variants: Array<{
+14 -16
View File
@@ -11,21 +11,19 @@ export type MutationKind =
| 'door-positions'
| 'fence-style'
/** Deterministic 32-bit RNG. */
/** Deterministic seeded RNG. */
export type Rng = () => number
/**
* Tiny PRNG. Returns a function that produces uniformly distributed floats in
* [0, 1). Source: https://stackoverflow.com/a/47593316/17118
* Tiny Park-Miller PRNG. Returns a function that produces uniformly
* distributed floats in [0, 1) without bitwise operators.
*/
export function mulberry32(seed: number): Rng {
let state = seed | 0
let state = Math.trunc(Math.abs(seed)) % 2_147_483_647
if (state === 0) state = 1
return () => {
state = (state + 0x6d2b79f5) | 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
state = (state * 16_807) % 2_147_483_647
return (state - 1) / 2_147_483_646
}
}
@@ -74,10 +72,10 @@ function siteBounds(
if (node.type !== 'site') continue
const pts = (node as { polygon?: { points?: Array<[number, number]> } }).polygon?.points
if (!pts || pts.length === 0) continue
let minX = Infinity
let maxX = -Infinity
let minZ = Infinity
let maxZ = -Infinity
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [x, z] of pts) {
if (x < minX) minX = x
if (x > maxX) maxX = x
@@ -101,7 +99,7 @@ function isPerimeterWall(
bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null,
epsilon = 0.01,
): boolean {
if (!bounds || !wall.start || !wall.end) return false
if (!(bounds && wall.start && wall.end)) return false
const onBound = (x: number, z: number): boolean =>
Math.abs(x - bounds.minX) <= epsilon ||
Math.abs(x - bounds.maxX) <= epsilon ||
@@ -154,7 +152,7 @@ function applyRoomProportions(graph: SceneGraph, rng: Rng): SceneGraph {
start?: [number, number]
end?: [number, number]
}
if (!wall.start || !wall.end) continue
if (!(wall.start && wall.end)) continue
if (isPerimeterWall(wall, bounds)) continue
// Nudge each endpoint by ±10% of its current value.
const nudge = (v: number): number => v * (1 + (rng() * 2 - 1) * 0.1)
@@ -193,7 +191,7 @@ function applyOpenPlan(graph: SceneGraph, rng: Rng): SceneGraph {
out.rootNodeIds = out.rootNodeIds.filter((id) => !removal.has(id))
// Drop references from any parent's `children` array.
for (const parent of Object.values(out.nodes)) {
if (!('children' in parent) || !Array.isArray((parent as { children?: unknown[] }).children)) {
if (!('children' in parent && Array.isArray((parent as { children?: unknown[] }).children))) {
continue
}
const children = (parent as { children: unknown[] }).children