Merge pull request #264 from AdrianPerez3/feat/mcp-server

This commit is contained in:
Aymeric Rabot
2026-04-27 14:22:45 -07:00
committed by GitHub
171 changed files with 20776 additions and 65 deletions
+52
View File
@@ -0,0 +1,52 @@
name: mcp-ci
on:
push:
branches: [main]
paths:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
pull_request:
paths:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
jobs:
ci:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Install
run: bun install --frozen-lockfile
- name: Build core
run: bun run --cwd packages/core build
- name: Build mcp
run: bun run --cwd packages/mcp build
- name: Test mcp
run: bun test --cwd packages/mcp
- name: Test editor scene API
run: bun test apps/editor/lib/scene-store-server.test.ts apps/editor/lib/scene-api-security.test.ts
- name: Biome check
run: bunx biome check packages/mcp apps/editor/lib/scene-store-server.ts apps/editor/lib/scene-api-security.ts apps/editor/app/api/scenes
+3
View File
@@ -45,3 +45,6 @@ yarn-error.log*
/.playwright-mcp
og-test
.env*.local
# Worktrees
.worktrees
@@ -0,0 +1,116 @@
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type RouteParams = { params: Promise<{ id: string }> }
const POLL_MS = 250
const HEARTBEAT_MS = 15_000
const MAX_EVENTS_PER_POLL = 50
export function OPTIONS(request: Request) {
return sceneApiPreflight(request)
}
export async function GET(request: Request, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const operations = await getSceneOperations()
if (!operations.canListSceneEvents) {
return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 })
}
const scene = await operations.loadStoredScene(id)
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const url = new URL(request.url)
const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10)
const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10)
let cursor = Math.max(
0,
Number.isFinite(afterFromQuery) ? afterFromQuery : 0,
Number.isFinite(afterFromHeader) ? afterFromHeader : 0,
)
const encoder = new TextEncoder()
let closed = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const enqueue = (chunk: string) => {
if (!closed) controller.enqueue(encoder.encode(chunk))
}
const close = () => {
if (closed) return
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
try {
controller.close()
} catch {
// The client may have already closed the stream.
}
}
request.signal.addEventListener('abort', close, { once: true })
enqueue('retry: 1000\n\n')
const poll = async () => {
if (closed) return
try {
const events = await operations.listSceneEvents(id, {
afterEventId: cursor,
limit: MAX_EVENTS_PER_POLL,
})
for (const event of events) {
cursor = event.eventId
enqueue(`id: ${event.eventId}\n`)
enqueue('event: scene\n')
enqueue(`data: ${JSON.stringify(event)}\n\n`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
enqueue('event: error\n')
enqueue(`data: ${JSON.stringify({ message })}\n\n`)
} finally {
if (!closed) pollTimer = setTimeout(poll, POLL_MS)
}
}
heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS)
void poll()
},
cancel() {
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
},
})
return withSceneApiHeaders(
request,
new Response(stream, {
headers: {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no',
},
}),
)
}
+216
View File
@@ -0,0 +1,216 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
type RouteParams = { params: Promise<{ id: string }> }
const putSceneSchema = z.object({
name: z.string().min(1).max(200).optional(),
graph: apiGraphSchema,
thumbnailUrl: z.string().url().nullable().optional(),
expectedVersion: z.number().int().nonnegative().optional(),
})
const patchSceneSchema = z.object({
name: z.string().min(1).max(200),
expectedVersion: z.number().int().nonnegative().optional(),
})
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const operations = await getSceneOperations()
try {
const scene = await operations.loadStoredScene(id)
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return sceneApiJson(request, scene, {
headers: { ETag: `"${scene.version}"` },
})
} catch (error) {
return handleStoreError(request, error)
}
}
export async function PUT(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = putSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await operations.saveScene({
id,
name: parsed.data.name ?? existing.name,
projectId: existing.projectId,
ownerId: existing.ownerId,
graph: parsed.data.graph as never,
thumbnailUrl:
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
expectedVersion: expectedVersion ?? existing.version,
})
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const operations = await getSceneOperations()
try {
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = patchSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const operations = await getSceneOperations()
try {
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
/**
* Parses an `If-Match` header value per RFC 7232. Accepts `"<version>"` or
* weak `W/"<version>"` forms. Returns `undefined` when the header is absent,
* the wildcard `*`, or unparseable as a non-negative integer.
*/
function parseIfMatch(raw: string | null): number | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (trimmed === '*') return undefined
const match = trimmed.match(/^(?:W\/)?"([^"]+)"$/)
const inner = match ? match[1] : trimmed
if (!inner) return undefined
const n = Number(inner)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined
return n
}
async function handleStoreError(
request: NextRequest,
error: unknown,
opts: { includeCurrentVersionFor?: string } = {},
): Promise<NextResponse> {
const code = (error as { code?: string })?.code
if (code === 'version_conflict') {
let currentVersion: number | undefined
if (opts.includeCurrentVersionFor) {
try {
const operations = await getSceneOperations()
const current = await operations.loadStoredScene(opts.includeCurrentVersionFor)
currentVersion = current?.version
} catch {
// Best-effort; skip reporting currentVersion on secondary failure.
}
}
return sceneApiJson(
request,
currentVersion === undefined
? { error: 'version_conflict' }
: { error: 'version_conflict', currentVersion },
{ status: 409 },
)
}
if (code === 'not_found') {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}
+109
View File
@@ -0,0 +1,109 @@
import type { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
const createSceneSchema = z.object({
id: z.string().min(1).max(64).optional(),
name: z.string().min(1).max(200),
projectId: z.string().min(1).max(200).nullable().optional(),
graph: apiGraphSchema,
thumbnailUrl: z.string().url().nullable().optional(),
})
const listQuerySchema = z.object({
projectId: z.string().min(1).max(200).optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
})
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const url = new URL(request.url)
const parsed = listQuerySchema.safeParse({
projectId: url.searchParams.get('projectId') ?? undefined,
limit: url.searchParams.get('limit') ?? undefined,
})
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
projectId: parsed.data.projectId,
limit: parsed.data.limit,
})
return sceneApiJson(request, { scenes })
}
export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = createSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
try {
const meta = await operations.saveScene({
id: parsed.data.id,
name: parsed.data.name,
projectId: parsed.data.projectId ?? null,
graph: parsed.data.graph as never,
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
})
return sceneApiJson(request, meta, {
status: 201,
headers: { Location: `/scene/${meta.id}` },
})
} catch (error) {
return handleStoreError(request, error)
}
}
function handleStoreError(request: NextRequest, error: unknown): NextResponse {
const code = (error as { code?: string })?.code
if (code === 'version_conflict') {
return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
}
if (code === 'not_found') {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}
+22 -8
View File
@@ -1,11 +1,7 @@
'use client'
import {
Editor,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor'
import Link from 'next/link'
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
@@ -15,12 +11,30 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
},
]
const PROJECT_ID = 'local-editor'
export default function Home() {
return (
<div className="h-screen w-screen">
<div className="relative h-screen w-screen">
{PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
<span className="text-muted-foreground">Local editor scenes are not saved.</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
Open recent scenes
</Link>
<span aria-hidden className="text-muted-foreground">
·
</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
Create new
</Link>
</div>
</div>
)}
<Editor
layoutVersion="v2"
projectId="local-editor"
projectId={PROJECT_ID}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
+73
View File
@@ -0,0 +1,73 @@
import type { SceneGraph } from '@pascal-app/editor'
import { headers } from 'next/headers'
import Link from 'next/link'
import { SceneLoader, type SceneMeta } from '@/components/scene-loader'
export const dynamic = 'force-dynamic'
interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}
async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}
async function fetchScene(id: string): Promise<SceneWithGraph | null> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, {
cache: 'no-store',
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Failed to load scene: ${response.status}`)
}
return (await response.json()) as SceneWithGraph
}
export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const scene = await fetchScene(id)
if (!scene) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 text-center shadow-xl">
<p className="font-mono text-muted-foreground text-xs uppercase tracking-wide">404</p>
<h1 className="mt-2 font-semibold text-lg">Scene not found</h1>
<p className="mt-2 text-muted-foreground text-sm">
We couldn&apos;t find a scene with id <code className="font-mono">{id}</code>.
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<Link
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
href="/scenes"
>
Browse scenes
</Link>
<Link
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
href="/"
>
Back to editor
</Link>
</div>
</div>
</div>
)
}
const { graph, ...meta } = scene
return <SceneLoader initialScene={graph} meta={meta} />
}
+117
View File
@@ -0,0 +1,117 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { CreateSceneButton } from '@/components/save-button'
import type { SceneMeta } from '@/components/scene-loader'
export const dynamic = 'force-dynamic'
async function resolveBaseUrl(): Promise<string> {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL
}
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'http'
if (!host) {
return 'http://localhost:3000'
}
return `${proto}://${host}`
}
async function fetchScenes(): Promise<SceneMeta[]> {
const base = await resolveBaseUrl()
const response = await fetch(`${base}/api/scenes?limit=50`, {
cache: 'no-store',
})
if (!response.ok) {
return []
}
const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[]
if (Array.isArray(payload)) {
return payload
}
return payload.scenes ?? []
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
export default async function ScenesPage() {
const scenes = await fetchScenes()
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
<div className="container mx-auto flex items-center justify-between gap-4 px-6 py-4">
<nav className="flex items-center gap-4 text-sm">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
Home
</Link>
<span className="text-muted-foreground">/</span>
<span className="font-medium text-foreground">Scenes</span>
</nav>
<CreateSceneButton />
</div>
</header>
<main className="container mx-auto max-w-5xl px-6 py-12">
<h1 className="mb-2 font-bold text-3xl">Your scenes</h1>
<p className="mb-8 text-muted-foreground text-sm">
{scenes.length === 0
? 'No scenes yet. Create one to get started.'
: `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`}
</p>
{scenes.length === 0 ? (
<div className="rounded-xl border border-border/60 border-dashed bg-background p-12 text-center">
<p className="text-muted-foreground text-sm">You haven&apos;t saved any scenes yet.</p>
<div className="mt-4 flex justify-center">
<CreateSceneButton />
</div>
</div>
) : (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{scenes.map((scene) => (
<li key={scene.id}>
<Link
className="group block rounded-xl border border-border/60 bg-background p-4 transition-colors hover:border-border hover:bg-accent/30"
href={`/scene/${scene.id}`}
>
<div className="flex aspect-video items-center justify-center overflow-hidden rounded-lg bg-accent/30">
{scene.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={scene.name}
className="h-full w-full object-cover"
src={scene.thumbnailUrl}
/>
) : (
<span className="text-muted-foreground text-xs">No thumbnail</span>
)}
</div>
<div className="mt-3">
<h2 className="truncate font-semibold text-sm group-hover:text-foreground">
{scene.name}
</h2>
<div className="mt-1 flex items-center justify-between text-muted-foreground text-xs">
<span>{scene.nodeCount} nodes</span>
<time dateTime={scene.updatedAt}>{formatDate(scene.updatedAt)}</time>
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</main>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import type { SceneGraph } from '@pascal-app/editor'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
const EMPTY_GRAPH: SceneGraph = {
nodes: {},
rootNodeIds: [],
}
interface SaveButtonProps {
sceneId: string
name: string
version: number
getGraph: () => SceneGraph | null
}
/**
* Creates a new empty scene and navigates the user to it.
*/
export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) {
const router = useRouter()
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCreate = useCallback(async () => {
setIsCreating(true)
setError(null)
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }),
})
if (!response.ok) {
setError(`Failed to create scene (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create scene')
} finally {
setIsCreating(false)
}
}, [router])
return (
<div className="flex items-center gap-3">
{error && <span className="text-destructive text-xs">{error}</span>}
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-sm hover:bg-accent/80 disabled:opacity-50"
disabled={isCreating}
onClick={handleCreate}
type="button"
>
{isCreating ? 'Creating…' : label}
</button>
</div>
)
}
/**
* Save + Save-as buttons that call the scenes API directly.
* Used for UIs that want explicit save controls outside of the Editor's
* built-in autosave plumbing.
*/
export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps) {
const router = useRouter()
const [isSaving, setIsSaving] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const handleSave = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('No scene to save')
return
}
setIsSaving(true)
setStatus(null)
try {
const response = await fetch(`/api/scenes/${sceneId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': String(version),
},
body: JSON.stringify({ name, graph }),
})
if (response.status === 409) {
setStatus('Conflict — reload to continue')
return
}
if (!response.ok) {
setStatus(`Save failed (${response.status})`)
return
}
setStatus('Saved')
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save failed')
} finally {
setIsSaving(false)
}
}, [getGraph, name, sceneId, version])
const handleSaveAs = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('No scene to save')
return
}
const newName = typeof window !== 'undefined' ? window.prompt('New scene name', name) : null
if (!newName) return
setIsSaving(true)
setStatus(null)
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, graph }),
})
if (!response.ok) {
setStatus(`Save-as failed (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save-as failed')
} finally {
setIsSaving(false)
}
}, [getGraph, name, router])
return (
<div className="flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80 disabled:opacity-50"
disabled={isSaving}
onClick={handleSave}
type="button"
>
{isSaving ? 'Saving…' : 'Save'}
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40 disabled:opacity-50"
disabled={isSaving}
onClick={handleSaveAs}
type="button"
>
Save as
</button>
{status && <span className="text-muted-foreground text-xs">{status}</span>}
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
'use client'
import {
applySceneGraphToEditor,
Editor,
type SceneGraph,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface SceneMeta {
id: string
name: string
projectId: string | null
thumbnailUrl: string | null
version: number
createdAt: string
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
id: 'site',
label: 'Scene',
component: () => null, // Built-in SitePanel handles this
},
]
interface SceneLoaderProps {
initialScene: SceneGraph
meta: SceneMeta
}
type SceneGraphWithCollections = SceneGraph & {
collections?: Record<string, unknown>
}
interface LiveSceneEvent {
eventId: number
sceneId: string
version: number
kind: string
createdAt: string
graph: SceneGraphWithCollections
}
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
})
}
export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const router = useRouter()
const versionRef = useRef(meta.version)
const lastRemoteGraphJsonRef = useRef<string | null>(null)
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const handleLoad = useCallback(async () => initialScene, [initialScene])
const handleSave = useCallback(
async (graph: SceneGraph) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
lastRemoteGraphJsonRef.current = null
suppressRemoteSaveUntilRef.current = 0
return
}
if (isRecentRemoteApply) return
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': String(versionRef.current),
},
body: JSON.stringify({ name: meta.name, graph }),
})
if (response.status === 409) {
setConflict(true)
return
}
if (!response.ok) {
setSaveError(`Save failed (${response.status})`)
return
}
const next = (await response.json()) as SceneMeta
versionRef.current = next.version
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
}
},
[meta.id, meta.name],
)
useEffect(() => {
const source = new EventSource(`/api/scenes/${meta.id}/events`)
source.addEventListener('scene', (event) => {
let payload: LiveSceneEvent
try {
payload = JSON.parse((event as MessageEvent<string>).data) as LiveSceneEvent
} catch {
return
}
if (payload.sceneId !== meta.id) return
if (payload.version <= versionRef.current) return
versionRef.current = payload.version
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
setSaveError(null)
})
source.addEventListener('error', () => {
if (source.readyState === EventSource.CLOSED) {
setSaveError('Live scene connection closed')
}
})
return () => source.close()
}, [meta.id])
const handleThumb = useCallback(
async (_blob: Blob) => {
// TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail.
// Stub endpoint is not yet implemented in v0.1 — skip upload for now.
await fetch(`/api/scenes/${meta.id}/thumbnail`, {
method: 'POST',
// Intentionally no body — endpoint is a stub.
}).catch(() => {
// Swallow errors silently; thumbnail upload is best-effort.
})
},
[meta.id],
)
return (
<div className="relative h-screen w-screen">
{conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
<h2 className="font-semibold text-sm">Another session saved first refresh?</h2>
<p className="mt-1 text-muted-foreground text-xs">
Your changes haven&apos;t been saved. Reload to pick up the latest version.
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80"
onClick={() => router.refresh()}
type="button"
>
Reload
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
onClick={() => setConflict(false)}
type="button"
>
Dismiss
</button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-destructive/50 bg-background p-3 shadow-xl">
<p className="font-medium text-destructive text-xs">{saveError}</p>
</div>
)}
<div className="pointer-events-none absolute top-4 right-4 z-40 flex items-center gap-2">
<Link
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
href="/scenes"
>
All scenes
</Link>
</div>
<Editor
layoutVersion="v2"
onLoad={handleLoad}
onSave={handleSave}
onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
/>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { AnyNode } from '@pascal-app/core/schema'
import { z } from 'zod'
/**
* Validates a SceneGraph at an untrusted API boundary. Re-runs
* `AnyNode.safeParse` on every node, which enforces the `AssetUrl`
* allowlist in core (closes the Phase 3 SSRF / arbitrary-URL risk on
* scan/guide/item/material fields).
*
* Shared between `POST /api/scenes` and `PUT /api/scenes/[id]` so neither
* route can silently accept malicious URLs via the `graph` payload.
*
* Phase 8 P4 found the POST bypass; Phase 10 A2 found the PUT bypass.
*/
export const apiGraphSchema = z
.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.unknown().optional(),
})
.superRefine((value, ctx) => {
for (const [nodeId, node] of Object.entries(value.nodes)) {
const res = AnyNode.safeParse(node)
if (!res.success) {
for (const issue of res.error.issues) {
ctx.addIssue({
code: 'custom',
path: ['nodes', nodeId, ...issue.path],
message: issue.message,
})
}
}
}
})
@@ -0,0 +1,64 @@
import { afterEach, expect, test } from 'bun:test'
import { guardSceneApiRequest, sceneApiPreflight } from './scene-api-security'
const OLD_ENV = { ...process.env }
afterEach(() => {
restoreEnv('PASCAL_SCENE_API_TOKEN')
restoreEnv('PASCAL_SCENE_API_ORIGINS')
restoreEnv('PASCAL_SCENE_API_RATE_LIMIT')
})
function restoreEnv(key: keyof NodeJS.ProcessEnv): void {
if (OLD_ENV[key] === undefined) delete process.env[key]
else process.env[key] = OLD_ENV[key]
}
test('allows loopback scene API requests without a token', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('http://127.0.0.1:3000/api/scenes', {
headers: { host: '127.0.0.1:3000' },
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('requires a token for non-loopback scene API requests', async () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('https://editor.example/api/scenes', {
headers: { host: 'editor.example' },
})
const response = guardSceneApiRequest(request)
expect(response?.status).toBe(503)
expect(await response?.json()).toEqual({ error: 'scene_api_token_required' })
})
test('accepts bearer token auth when configured', () => {
process.env.PASCAL_SCENE_API_TOKEN = 'secret'
const request = new Request('https://editor.example/api/scenes', {
headers: {
authorization: 'Bearer secret',
host: 'editor.example',
},
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('applies configured CORS origins for preflight', () => {
process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example'
const request = new Request('https://editor.example/api/scenes', {
method: 'OPTIONS',
headers: {
host: 'editor.example',
origin: 'https://app.example',
},
})
const response = sceneApiPreflight(request)
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example')
})
+180
View File
@@ -0,0 +1,180 @@
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
const DEFAULT_RATE_LIMIT_PER_MINUTE = 120
const WINDOW_MS = 60_000
const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
const ALLOWED_HEADERS = 'authorization, content-type, if-match, last-event-id, x-pascal-scene-token'
type RateBucket = {
resetAt: number
count: number
}
const rateBuckets = new Map<string, RateBucket>()
export function sceneApiPreflight(request: Request): NextResponse {
const guard = guardSceneApiRequest(request, { skipRateLimit: true, skipAuth: true })
if (guard) return guard
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
}
export function guardSceneApiRequest(
request: Request,
opts: { skipRateLimit?: boolean; skipAuth?: boolean } = {},
): NextResponse | null {
const originError = validateOrigin(request)
if (originError) return originError
if (!opts.skipAuth) {
const authError = validateAuth(request)
if (authError) return authError
}
if (!opts.skipRateLimit) {
const rateError = validateRateLimit(request)
if (rateError) return rateError
}
return null
}
export function sceneApiJson(request: Request, body: unknown, init?: ResponseInit): NextResponse {
return withSceneApiHeaders(request, NextResponse.json(body, init))
}
export function withSceneApiHeaders<T extends Response>(request: Request, response: T): T {
const origin = request.headers.get('origin')
if (origin && isOriginAllowed(request, origin)) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.append('Vary', 'Origin')
}
response.headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS)
response.headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS)
response.headers.set('Cache-Control', response.headers.get('Cache-Control') ?? 'no-store')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
function validateOrigin(request: Request): NextResponse | null {
const origin = request.headers.get('origin')
if (!origin || isOriginAllowed(request, origin)) return null
return sceneApiJson(request, { error: 'origin_not_allowed' }, { status: 403 })
}
function validateAuth(request: Request): NextResponse | null {
const token = process.env.PASCAL_SCENE_API_TOKEN
if (!token) {
if (isLoopbackRequest(request)) return null
return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 })
}
const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token')
if (supplied && safeEqual(supplied, token)) return null
return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 })
}
function validateRateLimit(request: Request): NextResponse | null {
const limit = rateLimitPerMinute()
if (limit <= 0) return null
const now = Date.now()
const key = clientIp(request)
const bucket = rateBuckets.get(key)
if (!bucket || bucket.resetAt <= now) {
rateBuckets.set(key, { count: 1, resetAt: now + WINDOW_MS })
return null
}
bucket.count++
if (bucket.count <= limit) return null
const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
const response = sceneApiJson(request, { error: 'rate_limited' }, { status: 429 })
response.headers.set('Retry-After', String(retryAfter))
return response
}
function bearerToken(request: Request): string | null {
const header = request.headers.get('authorization')
if (!header) return null
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1] ?? null
}
function safeEqual(a: string, b: string): boolean {
const aBuffer = Buffer.from(a)
const bBuffer = Buffer.from(b)
if (aBuffer.length !== bBuffer.length) return false
return timingSafeEqual(aBuffer, bBuffer)
}
function rateLimitPerMinute(): number {
const raw = process.env.PASCAL_SCENE_API_RATE_LIMIT
if (!raw) return DEFAULT_RATE_LIMIT_PER_MINUTE
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : DEFAULT_RATE_LIMIT_PER_MINUTE
}
function clientIp(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
if (forwarded) return forwarded
return request.headers.get('x-real-ip') ?? 'unknown'
}
function isOriginAllowed(request: Request, origin: string): boolean {
if (isSameOrigin(request, origin)) return true
const parsed = parseUrl(origin)
if (!parsed) return false
if (isLoopbackHostname(parsed.hostname)) return true
return configuredOrigins().has(normalizeOrigin(parsed))
}
function configuredOrigins(): Set<string> {
const raw = process.env.PASCAL_SCENE_API_ORIGINS
if (!raw) return new Set()
return new Set(
raw
.split(',')
.map((part) => parseUrl(part.trim()))
.filter((url): url is URL => url !== null)
.map(normalizeOrigin),
)
}
function isSameOrigin(request: Request, origin: string): boolean {
const parsedOrigin = parseUrl(origin)
if (!parsedOrigin) return false
const requestUrl = new URL(request.url)
return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl)
}
function isLoopbackRequest(request: Request): boolean {
const host = request.headers.get('host') ?? new URL(request.url).host
return isLoopbackHostname(stripPort(host))
}
function isLoopbackHostname(hostname: string): boolean {
const h = hostname.toLowerCase()
return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
function normalizeOrigin(url: URL): string {
return `${url.protocol}//${url.host}`.toLowerCase()
}
function stripPort(host: string): string {
if (host.startsWith('[')) {
const end = host.indexOf(']')
return end === -1 ? host : host.slice(1, end)
}
return host.split(':')[0] ?? host
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
describe('getSceneStore', () => {
beforeEach(() => {
mock.module('@pascal-app/mcp/operations', () => ({
createSceneOperations: ({ store }: { store: unknown }) => ({
__store: store,
hasStore: true,
}),
}))
mock.module('@pascal-app/mcp/storage', () => {
let callCount = 0
return {
createSceneStore: async (_env?: NodeJS.ProcessEnv) => {
callCount++
return {
backend: 'sqlite' as const,
__instanceNumber: callCount,
save: async () => ({}) as never,
load: async () => null,
list: async () => [],
delete: async () => false,
rename: async () => ({}) as never,
}
},
}
})
})
test('returns the same promise on repeated calls', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const a = mod.getSceneStore()
const b = mod.getSceneStore()
expect(a).toBe(b)
})
test('resolves to the same store instance across calls', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const storeA = await mod.getSceneStore()
const storeB = await mod.getSceneStore()
expect(storeA).toBe(storeB)
// Factory should have been invoked exactly once — asserted indirectly via
// our mock's instance counter.
expect((storeA as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1)
expect((storeB as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1)
})
test('reset helper clears the cached singleton', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const first = await mod.getSceneStore()
mod.__resetSceneStoreForTests()
const second = await mod.getSceneStore()
expect(first).not.toBe(second)
})
test('getSceneOperations wraps the cached store', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const store = await mod.getSceneStore()
const operations = await mod.getSceneOperations()
expect((operations as unknown as { __store: unknown }).__store).toBe(store)
})
})
+44
View File
@@ -0,0 +1,44 @@
import type { SceneOperations } from '@pascal-app/mcp/operations'
import type { SceneStore } from '@pascal-app/mcp/storage'
/**
* Per-process singleton. The factory is async because backend modules are
* dynamically imported — we cache the in-flight promise so concurrent calls
* during a cold start share a single instantiation.
*/
let cachedStore: Promise<SceneStore> | null = null
let cachedOperations: Promise<SceneOperations> | null = null
export function getSceneStore(): Promise<SceneStore> {
if (!cachedStore) {
cachedStore = (async () => {
const mod = (await import('@pascal-app/mcp/storage')) as {
createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore>
}
return mod.createSceneStore(process.env)
})()
}
return cachedStore
}
export function getSceneOperations(): Promise<SceneOperations> {
if (!cachedOperations) {
cachedOperations = (async () => {
const store = await getSceneStore()
const mod = (await import('@pascal-app/mcp/operations')) as {
createSceneOperations: (options: { store: SceneStore }) => SceneOperations
}
return mod.createSceneOperations({ store })
})()
}
return cachedOperations
}
/**
* Test-only helper to reset the cached singleton. Not exported for production
* callers.
*/
export function __resetSceneStoreForTests(): void {
cachedStore = null
cachedOperations = null
}
+7 -1
View File
@@ -4,7 +4,13 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core', '@pascal-app/editor'],
transpilePackages: [
'three',
'@pascal-app/viewer',
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
],
turbopack: {
resolveAlias: {
react: './node_modules/react',
+3 -1
View File
@@ -14,6 +14,7 @@
"@number-flow/react": "^0.5.14",
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/viewer": "*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
@@ -26,7 +27,8 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.184.0"
"three": "^0.184.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@pascal/typescript-config": "*",
+1 -1
View File
@@ -17,7 +17,7 @@
"next.config.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"],
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/viewer" }
+157 -3
View File
@@ -1,6 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "editor",
@@ -19,6 +19,7 @@
"@number-flow/react": "^0.5.14",
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/viewer": "*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
@@ -32,6 +33,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.184.0",
"zod": "^4.3.5",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -144,6 +146,26 @@
"typescript-eslint": "^8.50.0",
},
},
"packages/mcp": {
"name": "@pascal-app/mcp",
"version": "0.1.0",
"bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js",
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.3.5",
},
"devDependencies": {
"@pascal-app/core": "workspace:*",
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"typescript": "5.9.3",
},
"peerDependencies": {
"@pascal-app/core": "workspace:*",
},
},
"packages/typescript-config": {
"name": "@repo/typescript-config",
"version": "0.0.0",
@@ -335,6 +357,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -411,6 +435,8 @@
"@medv/finder": ["@medv/finder@4.0.2", "", {}, "sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
"@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="],
@@ -445,6 +471,8 @@
"@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"],
"@pascal-app/mcp": ["@pascal-app/mcp@workspace:packages/mcp"],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
"@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"],
@@ -661,13 +689,17 @@
"@zappar/msdf-generator": ["@zappar/msdf-generator@1.2.4", "", { "dependencies": { "comlink": "^4.4.2" } }, "sha512-6S/MCk0Ky0ipewZJw4xFEzH/2aYfWmPXEkTdBtNyDDfkbicrNwgJgtxZ4SnTDyNe9XHMqDA4sL9srRsgDLRMqA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agentation": ["agentation@2.3.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-AUZgFCdBQ/nAohlFsHByM9S2Dp7ECMNqVjlOke4hv/90v+wTiwrGladEkgWS60RDQp+CJ5p97meeCthYgTFlKQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -705,6 +737,8 @@
"bippy": ["bippy@0.5.32", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": ">=17.0.1" } }, "sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -713,6 +747,8 @@
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -751,8 +787,18 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -777,6 +823,8 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -797,12 +845,16 @@
"editor": ["editor@workspace:apps/editor"],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.325", "", {}, "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA=="],
"element-source": ["element-source@0.0.3", "", { "dependencies": { "bippy": "^0.5.32" } }, "sha512-o3VMv2BIfY/axhIBKlE9HrR5rNqnhjHN2PEAKxG65O0VCSfONoMi9QMQjY12XVVvMuTzr1cAg/4xLMkvh+/Wlg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -825,6 +877,8 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
@@ -857,6 +911,16 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.7", "", {}, "sha512-zwxwiQqexizSXFZV13zMiEtW1E3lv7RlUv+1f5FBiR4x7wFhEjm3aFTyYkZQWzyN08WnPdox015GoRH5D/E5YA=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
@@ -865,6 +929,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -875,6 +941,8 @@
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
@@ -883,8 +951,12 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
@@ -937,8 +1009,14 @@
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="],
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -951,8 +1029,14 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
@@ -1019,6 +1103,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -1027,7 +1113,9 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -1087,6 +1175,10 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="],
@@ -1095,6 +1187,10 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -1117,6 +1213,8 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="],
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
@@ -1141,6 +1239,10 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -1155,6 +1257,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -1163,12 +1267,16 @@
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -1187,10 +1295,18 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
@@ -1225,6 +1341,8 @@
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -1233,20 +1351,28 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1277,6 +1403,8 @@
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -1325,6 +1453,8 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="],
"troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="],
@@ -1343,6 +1473,8 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -1361,6 +1493,8 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@2.1.0", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -1375,6 +1509,8 @@
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="],
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
@@ -1393,6 +1529,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1401,6 +1539,8 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zundo": ["zundo@2.3.0", "", { "peerDependencies": { "zustand": "^4.3.0 || ^5.0.0" } }, "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ=="],
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
@@ -1409,10 +1549,14 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@pascal-app/editor/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@pascal-app/mcp/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@pascal-app/viewer/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1467,6 +1611,8 @@
"editor/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"eslint-plugin-turbo/dotenv": ["dotenv@16.0.3", "", {}, "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -1487,6 +1633,8 @@
"react-scan/@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
"router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"stats-gl/@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="],
@@ -1497,10 +1645,16 @@
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@pascal-app/mcp/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@pascal-app/viewer/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+30
View File
@@ -15,6 +15,36 @@
"types": "./dist/utils/clone-scene-graph.d.ts",
"import": "./dist/utils/clone-scene-graph.js",
"default": "./dist/utils/clone-scene-graph.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./store": {
"types": "./dist/store/use-scene.d.ts",
"import": "./dist/store/use-scene.js",
"default": "./dist/store/use-scene.js"
},
"./material-library": {
"types": "./dist/material-library.d.ts",
"import": "./dist/material-library.js",
"default": "./dist/material-library.js"
},
"./spatial-grid": {
"types": "./dist/hooks/spatial-grid/spatial-grid-manager.d.ts",
"import": "./dist/hooks/spatial-grid/spatial-grid-manager.js",
"default": "./dist/hooks/spatial-grid/spatial-grid-manager.js"
},
"./wall": {
"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": [
+16
View File
@@ -100,6 +100,21 @@ export interface ThumbnailGenerateEvent {
snapLevels?: boolean
}
export interface CameraControlFitSceneEvent {
/**
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
*/
bounds?: {
min: [number, number]
max: [number, number]
center: [number, number]
size: [number, number]
}
}
type CameraControlEvents = {
'camera-controls:view': CameraControlEvent
'camera-controls:focus': CameraControlEvent
@@ -107,6 +122,7 @@ type CameraControlEvents = {
'camera-controls:top-view': undefined
'camera-controls:orbit-cw': undefined
'camera-controls:orbit-ccw': undefined
'camera-controls:fit-scene': CameraControlFitSceneEvent
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
}
+10 -9
View File
@@ -1,6 +1,7 @@
export type {
BuildingEvent,
CameraControlEvent,
CameraControlFitSceneEvent,
CeilingEvent,
DoorEvent,
EventSuffix,
@@ -37,7 +38,6 @@ export {
type Space,
wallTouchesOthers,
} from './lib/space-detection'
export { baseMaterial, glassMaterial } from './materials'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
@@ -50,6 +50,7 @@ export {
type MaterialCatalogItem,
toLibraryMaterialRef,
} from './material-library'
export { baseMaterial, glassMaterial } from './materials'
export * from './schema'
export {
type ControlValue,
@@ -63,20 +64,14 @@ export {
resumeSceneHistory,
} from './store/history-control'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
export { FenceSystem } from './systems/fence/fence-system'
export { ItemSystem } from './systems/item/item-system'
export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system'
export { StairSystem } from './systems/stair/stair-system'
export {
DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS,
getWallPlanFootprint,
getWallThickness,
} from './systems/wall/wall-footprint'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
@@ -90,12 +85,18 @@ export {
normalizeWallCurveOffset,
sampleWallCenterline,
} from './systems/wall/wall-curve'
export {
DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS,
getWallPlanFootprint,
getWallThickness,
} from './systems/wall/wall-footprint'
export {
calculateLevelMiters,
getWallMiterBoundaryPoints,
type Point2D,
type WallMiterBoundaryPoints,
pointToKey,
type WallMiterBoundaryPoints,
type WallMiterData,
} from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system'
+141
View File
@@ -0,0 +1,141 @@
// @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.
// The tsconfig in packages/core still emits this file; the @ts-expect-error
// keeps the build green while letting `bun test` pick it up normally.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
function isValid(url: string): boolean {
return AssetUrl.safeParse(url).success
}
describe('AssetUrl', () => {
describe('allowed URLs', () => {
const cases: Array<[string, string]> = [
['asset://abc', 'internal asset handle'],
['asset://catalog/items/chair-1', 'nested asset handle'],
['blob:http://example.com/uuid-1234', 'blob URL with http inner'],
['blob:https://example.com/uuid-5678', 'blob URL with https inner'],
['https://cdn.example.com/a.glb', 'https CDN URL'],
['https://cdn.example.com/models/chair.glb?v=2', 'https URL with query string'],
['http://localhost:3000/x', 'http localhost with port'],
['http://localhost/x', 'http localhost without port'],
['http://127.0.0.1:8080/texture.png', 'http 127.0.0.1 loopback'],
['/public/a.glb', 'app-relative path'],
['/material/wood1/albedoMap_basecolor.jpg', 'relative path deep'],
['data:image/png;base64,AAA', 'inline PNG data URL'],
['data:image/jpeg;base64,/9j/', 'inline JPEG data URL'],
['data:image/webp;base64,UklGR', 'inline WebP data URL'],
['data:image/svg+xml,%3Csvg%3E', 'inline SVG data URL'],
]
for (const [url, label] of cases) {
test(`accepts ${label}: ${url}`, () => {
expect(isValid(url)).toBe(true)
})
}
})
describe('rejected URLs', () => {
const cases: Array<[string, string]> = [
['javascript:alert(1)', 'javascript scheme'],
['JAVASCRIPT:alert(1)', 'javascript scheme uppercase'],
['file:///etc/passwd', 'file scheme'],
['file://C:/Windows/System32/config', 'file scheme Windows'],
['http://evil.com/', 'non-loopback http'],
['http://example.com:3000/x', 'http on non-loopback host'],
['http://169.254.169.254/latest/meta-data/', 'http on link-local (cloud metadata)'],
['data:text/html,<script>alert(1)</script>', 'data text/html'],
['data:application/javascript,alert(1)', 'data application/javascript'],
['data:text/plain,hi', 'data text/plain'],
['ftp://a.b.com', 'ftp scheme'],
['ws://example.com/', 'websocket scheme'],
['vbscript:msgbox', 'vbscript scheme'],
['', 'empty string'],
['not a url at all', 'non-url string'],
['://missing-scheme', 'malformed'],
]
for (const [url, label] of cases) {
test(`rejects ${label}: ${url}`, () => {
expect(isValid(url)).toBe(false)
})
}
})
describe(`env allowlist via ${ALLOWED_ORIGINS_ENV}`, () => {
const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
const original = g.process?.env?.[ALLOWED_ORIGINS_ENV]
beforeEach(() => {
if (g.process?.env) delete g.process.env[ALLOWED_ORIGINS_ENV]
})
afterEach(() => {
if (!g.process?.env) return
if (original === undefined) {
delete g.process.env[ALLOWED_ORIGINS_ENV]
} else {
g.process.env[ALLOWED_ORIGINS_ENV] = original
}
})
test('single origin allowlist accepts matching https URL', () => {
if (!g.process?.env) return // browser-only runtime
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true)
expect(isValid('https://cdn.pascal.app/deep/path?q=1')).toBe(true)
})
test('single origin allowlist rejects non-matching https URL', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
expect(isValid('https://cdn.other.com/a.glb')).toBe(false)
expect(isValid('https://attacker.example.com/x')).toBe(false)
})
test('multi-origin allowlist accepts any listed origin', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app, https://assets.pascal.app'
expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true)
expect(isValid('https://assets.pascal.app/tex.webp')).toBe(true)
expect(isValid('https://third.example.com/x')).toBe(false)
})
test('allowlist ignores trailing / in URL path (origin match only)', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
expect(isValid('https://cdn.pascal.app/')).toBe(true)
expect(isValid('https://cdn.pascal.app')).toBe(true)
})
test('empty allowlist behaves like unset', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = ''
expect(isValid('https://cdn.other.com/a.glb')).toBe(true)
})
test('allowlist does not restrict non-https schemes', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
// these should still pass because they match earlier scheme-based branches
expect(isValid('asset://x')).toBe(true)
expect(isValid('blob:https://example.com/abc')).toBe(true)
expect(isValid('data:image/png;base64,AAA')).toBe(true)
expect(isValid('/public/a.glb')).toBe(true)
expect(isValid('http://localhost:3000/x')).toBe(true)
})
test('allowlist rejects subdomain spoofing', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app'
expect(isValid('https://cdn.pascal.app.evil.com/x')).toBe(false)
expect(isValid('https://evil.com/cdn.pascal.app')).toBe(false)
})
test('allowlist respects ports', () => {
if (!g.process?.env) return
g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app:8443'
expect(isValid('https://cdn.pascal.app:8443/x')).toBe(true)
expect(isValid('https://cdn.pascal.app/x')).toBe(false)
})
})
})
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod'
/**
* Scheme allowlist for asset-like URLs embedded in scene graphs.
*
* Phase 3 security audit: `scan.url`, `guide.url`, `material.texture.url`, and
* `item.asset.src` were previously bare `z.string()`. That meant an
* attacker-crafted scene loaded in the editor could beacon to arbitrary URLs
* (e.g. `javascript:`, `file:///etc/passwd`, `http://169.254.169.254/...`).
*
* This validator rejects URLs that don't match the scheme allowlist below.
*/
const ALLOWED_SCHEMES = ['asset:', 'blob:', 'https:', 'data:image/'] as const
/**
* Optional environment variable that narrows which `https:` origins are
* accepted. Set to a comma-separated list (e.g. `https://cdn.pascal.app`).
* When unset, any `https:` origin is permitted.
*/
export const ALLOWED_ORIGINS_ENV = 'PASCAL_ALLOWED_ASSET_ORIGINS'
// Narrow access to the environment variable without requiring @types/node in
// this package. The core package ships to both browser and Node contexts.
function readAllowedOrigins(): readonly string[] | undefined {
const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
const value = g.process?.env?.[ALLOWED_ORIGINS_ENV]
if (!value) return undefined
const list = value
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0)
return list.length > 0 ? list : undefined
}
function isAllowedAssetUrl(url: string): boolean {
if (typeof url !== 'string' || url.length === 0) return false
if (url.startsWith('asset://')) return true // internal handle
if (url.startsWith('blob:')) return true // in-memory reference
if (url.startsWith('data:image/')) return true // inline image only (never data:text/html)
if (url.startsWith('/')) return true // app-relative path
try {
const parsed = new URL(url)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false
// http is only permitted for localhost development
if (parsed.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(parsed.hostname)) {
return false
}
// optional env-driven origin allowlist (only enforced for https URLs)
if (parsed.protocol === 'https:') {
const allowlist = readAllowedOrigins()
if (allowlist) return allowlist.includes(parsed.origin)
}
return true
} catch {
return false
}
}
/**
* Zod validator for asset-style URL fields. Accepts:
* - `asset://…` internal handles
* - `blob:…` in-memory references
* - `data:image/…` inline images (not `data:text/html` or other types)
* - `/…` app-relative paths
* - `https://…` public URLs (optionally narrowed to an env allowlist)
* - `http://localhost[:port]/…` or `http://127.0.0.1/…` for local dev
*
* Rejects every other scheme, including `javascript:`, `file:`, `ftp:`,
* and `data:text/html`, as well as empty strings and non-URL garbage.
*/
export const AssetUrl = z.string().refine(isAllowedAssetUrl, {
message:
'URL must be asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.',
})
export type AssetUrl = z.infer<typeof AssetUrl>
// re-export the scheme allowlist for documentation / downstream validators
export { ALLOWED_SCHEMES }
+12 -11
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { AssetUrl } from './asset-url'
export const MaterialPreset = z.enum([
'white',
@@ -30,7 +31,7 @@ export const MaterialSchema = z.object({
properties: MaterialProperties.optional(),
texture: z
.object({
url: z.string(),
url: AssetUrl,
repeat: z.tuple([z.number(), z.number()]).optional(),
scale: z.number().optional(),
})
@@ -56,16 +57,16 @@ export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat'
export type TextureWrapMode = z.infer<typeof TextureWrapMode>
export const MaterialMapsSchema = z.object({
albedoMap: z.string().optional(),
metalnessMap: z.string().optional(),
roughnessMap: z.string().optional(),
normalMap: z.string().optional(),
displacementMap: z.string().optional(),
aoMap: z.string().optional(),
emissiveMap: z.string().optional(),
bumpMap: z.string().optional(),
alphaMap: z.string().optional(),
lightMap: z.string().optional(),
albedoMap: AssetUrl.optional(),
metalnessMap: AssetUrl.optional(),
roughnessMap: AssetUrl.optional(),
normalMap: AssetUrl.optional(),
displacementMap: AssetUrl.optional(),
aoMap: AssetUrl.optional(),
emissiveMap: AssetUrl.optional(),
bumpMap: AssetUrl.optional(),
alphaMap: AssetUrl.optional(),
lightMap: AssetUrl.optional(),
})
export type MaterialMaps = z.infer<typeof MaterialMapsSchema>
+2 -1
View File
@@ -1,10 +1,11 @@
import { z } from 'zod'
import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base'
export const GuideNode = BaseNode.extend({
id: objectId('guide'),
type: nodeType('guide'),
url: z.string(),
url: AssetUrl,
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.number().default(1),
+2 -1
View File
@@ -1,5 +1,6 @@
import dedent from 'dedent'
import { z } from 'zod'
import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base'
import type { CollectionId } from '../collections'
@@ -79,7 +80,7 @@ const assetSchema = z.object({
category: z.string(),
name: z.string(),
thumbnail: z.string(),
src: z.string(),
src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
tags: z.array(z.string()).optional(),
+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,
+2 -1
View File
@@ -1,10 +1,11 @@
import { z } from 'zod'
import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base'
export const ScanNode = BaseNode.extend({
id: objectId('scan'),
type: nodeType('scan'),
url: z.string(),
url: AssetUrl,
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
scale: z.number().default(1),
@@ -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: {
@@ -1,6 +1,12 @@
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import {
type CameraControlEvent,
type CameraControlFitSceneEvent,
emitter,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
@@ -340,12 +346,30 @@ export const CustomCameraControls = () => {
focusNode(nodeId)
}
const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => {
if (!controls.current || isPreviewMode) return
if (!bounds) {
// Restore default framing pose when no bounds were computed.
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
return
}
const [cx, cz] = bounds.center
const [w, d] = bounds.size
// Use the longer horizontal extent to size the orbit radius so the whole
// footprint sits in view regardless of aspect ratio.
const maxExtent = Math.max(w, d)
const distance = Math.max(maxExtent * 1.4, 15)
const height = Math.max(maxExtent * 0.8, 10)
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:focus', handleNodeFocus)
emitter.on('camera-controls:view', handleNodeView)
emitter.on('camera-controls:top-view', handleTopView)
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
emitter.on('camera-controls:fit-scene', handleFitScene)
return () => {
emitter.off('camera-controls:capture', handleNodeCapture)
@@ -354,8 +378,9 @@ export const CustomCameraControls = () => {
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
emitter.off('camera-controls:fit-scene', handleFitScene)
}
}, [focusNode])
}, [focusNode, isPreviewMode])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
@@ -20,6 +20,7 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import {
@@ -940,6 +941,7 @@ export default function Editor({
commandPaletteEmptyAction,
}: EditorProps) {
useKeyboard({ isVersionPreviewMode })
useAutoFrame()
const { isLoadingSceneRef } = useAutoSave({
onSave,
@@ -0,0 +1,45 @@
'use client'
import { emitter, useScene } from '@pascal-app/core'
import { useEffect, useRef } from 'react'
import { computeSceneBoundsXZ } from '../lib/scene-bounds'
/**
* Auto-frame the camera onto a freshly loaded scene.
*
* Motivation: when the MCP `setScene` tool (or any other entry point) swaps
* the scene graph while the default camera is pointing at empty space, the
* user sees a black viewport. This hook subscribes to the core scene store
* and, whenever `nodes` transitions from empty → non-empty, computes the
* XZ bounds of the new scene and emits `camera-controls:fit-scene`. The
* `<CustomCameraControls />` component picks up that event and frames the
* camera onto the bounds.
*
* Mount in exactly ONE component (the Editor). It holds no state of its own;
* the subscription is torn down on unmount.
*/
export function useAutoFrame(): void {
// Track the previous node count so we can detect the empty → non-empty edge.
const wasEmptyRef = useRef(true)
useEffect(() => {
// Initialise from current store state so a remount after a setScene
// doesn't re-frame an already-populated scene.
wasEmptyRef.current = Object.keys(useScene.getState().nodes).length === 0
const unsubscribe = useScene.subscribe((state) => {
const isEmpty = Object.keys(state.nodes).length === 0
const wasEmpty = wasEmptyRef.current
wasEmptyRef.current = isEmpty
// Only react to empty → non-empty transitions. Normal edits keep both
// flags false; a `clearScene()` goes non-empty → empty and is ignored.
if (!wasEmpty || isEmpty) return
const bounds = computeSceneBoundsXZ(state.nodes)
emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {})
})
return unsubscribe
}, [])
}
@@ -0,0 +1,183 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core/schema'
import { computeSceneBoundsXZ } from './scene-bounds'
function makeWall(start: [number, number], end: [number, number]): AnyNode {
return {
object: 'node',
id: `wall_${start.join('_')}_${end.join('_')}`,
type: 'wall',
parentId: null,
visible: true,
metadata: {},
children: [],
start,
end,
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
}
function makeZone(polygon: [number, number][]): AnyNode {
return {
object: 'node',
id: `zone_${polygon.length}_${polygon[0]?.[0] ?? 0}`,
type: 'zone',
parentId: null,
visible: true,
metadata: {},
name: 'Zone',
polygon,
color: '#000000',
} as unknown as AnyNode
}
function makeSite(points: [number, number][]): AnyNode {
return {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: { type: 'polygon', points },
children: [],
} as unknown as AnyNode
}
describe('computeSceneBoundsXZ', () => {
test('returns null when given an empty array', () => {
expect(computeSceneBoundsXZ([])).toBeNull()
})
test('returns null when no geometry is found on any node', () => {
const barren = [
{
object: 'node',
id: 'building_1',
type: 'building',
parentId: null,
visible: true,
metadata: {},
children: [],
} as unknown as AnyNode,
]
expect(computeSceneBoundsXZ(barren)).toBeNull()
})
test('computes bounds from wall endpoints', () => {
const nodes: AnyNode[] = [makeWall([0, 0], [4, 0]), makeWall([4, 0], [4, 3])]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.min).toEqual([0, 0])
expect(bounds!.max).toEqual([4, 3])
expect(bounds!.size).toEqual([4, 3])
expect(bounds!.center).toEqual([2, 1.5])
})
test('includes zone polygons', () => {
const nodes: AnyNode[] = [
makeZone([
[-10, -5],
[10, -5],
[10, 5],
[-10, 5],
]),
]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.min).toEqual([-10, -5])
expect(bounds!.max).toEqual([10, 5])
expect(bounds!.size).toEqual([20, 10])
})
test('ignores the default 30×30 site bootstrap polygon', () => {
const nodes: AnyNode[] = [
makeSite([
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
]),
makeWall([1, 1], [2, 2]),
]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
// Only the wall should count — the default site polygon is skipped.
expect(bounds!.min).toEqual([1, 1])
expect(bounds!.max).toEqual([2, 2])
})
test('honours a non-default site polygon', () => {
const nodes: AnyNode[] = [
makeSite([
[-25, -20],
[25, -20],
[25, 20],
[-25, 20],
]),
]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.min).toEqual([-25, -20])
expect(bounds!.max).toEqual([25, 20])
})
test('combines walls, zones and positions across the flat dict', () => {
const nodes: Record<string, AnyNode> = {
wallA: makeWall([-8, -3], [4, -3]),
wallB: makeWall([4, -3], [4, 6]),
zoneA: makeZone([
[-8, -3],
[4, -3],
[4, 6],
[-8, 6],
]),
item1: {
object: 'node',
id: 'item_1',
type: 'item',
parentId: null,
visible: true,
metadata: {},
position: [7, 0, 8],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [],
asset: {
id: 'a',
category: 'furniture',
name: 'Chair',
thumbnail: '',
src: '',
dimensions: [1, 1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
} as unknown as AnyNode,
}
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.min).toEqual([-8, -3])
expect(bounds!.max).toEqual([7, 8])
})
test('handles a single degenerate point with a minimum extent', () => {
const nodes: AnyNode[] = [makeWall([2, 2], [2, 2])]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
expect(bounds!.size[0]).toBeGreaterThan(0)
expect(bounds!.size[1]).toBeGreaterThan(0)
expect(bounds!.center).toEqual([2, 2])
})
test('skips non-finite coordinates', () => {
const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
const bounds = computeSceneBoundsXZ(nodes)
expect(bounds).not.toBeNull()
// NaN should be ignored; the usable points are (4,2), (0,0), (1,1).
expect(bounds!.min).toEqual([0, 0])
expect(bounds!.max).toEqual([4, 2])
})
})
+169
View File
@@ -0,0 +1,169 @@
/**
* Scene bounds in the X/Z plane.
*
* Used by the auto-frame hook to fit the camera onto a freshly loaded scene
* (see `../hooks/use-auto-frame`). The hook subscribes to the core scene
* store and, when `nodes` transitions from empty → non-empty, fires a
* `camera-controls:fit-scene` event on the core event bus carrying the
* computed bounds.
*
* This module contains no rendering code: it only walks the flat-dict node
* tree and derives an axis-aligned bounding box on the XZ (plan) plane.
*/
import type { AnyNode } from '@pascal-app/core/schema'
export type SceneBoundsXZ = {
/** Min [x, z] in world units (meters). */
min: [number, number]
/** Max [x, z] in world units (meters). */
max: [number, number]
/** Center [x, z] = (min + max) / 2. */
center: [number, number]
/** Size [w, d] = max - min. */
size: [number, number]
}
// A very small guard against degenerate bounds (e.g. a single wall of zero length).
const MIN_BOUNDS_EXTENT = 0.0001
function extendPoint(
acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean },
x: unknown,
z: unknown,
): void {
if (typeof x !== 'number' || typeof z !== 'number') return
if (!Number.isFinite(x) || !Number.isFinite(z)) return
if (x < acc.minX) acc.minX = x
if (x > acc.maxX) acc.maxX = x
if (z < acc.minZ) acc.minZ = z
if (z > acc.maxZ) acc.maxZ = z
acc.hasPoint = true
}
/**
* Compute the axis-aligned XZ bounds of a scene.
*
* Walks every node and extracts 2D footprint points from the fields most
* nodes carry:
* - `start`/`end` → wall and fence endpoints in level coordinates.
* - `polygon` → zone, slab, site-boundary polygons.
* - `position` → building/item/door/window position; uses [x, z] only.
*
* Site-node polygons are intentionally excluded when they are the default
* 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
* an empty square around the origin. We still include site polygons that
* look intentional (> 4 points, or any point outside the ±15 m default).
*
* Returns `null` if no usable geometry was found.
*/
export function computeSceneBoundsXZ(
nodes: AnyNode[] | Record<string, AnyNode>,
): SceneBoundsXZ | null {
const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
if (list.length === 0) return null
const acc = {
minX: Number.POSITIVE_INFINITY,
minZ: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxZ: Number.NEGATIVE_INFINITY,
hasPoint: false,
}
for (const node of list) {
if (!node || typeof node !== 'object') continue
const anyNode = node as unknown as Record<string, unknown>
// Wall / fence endpoints in level coordinates.
const start = anyNode.start as unknown
const end = anyNode.end as unknown
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
// Zone / slab polygons (and explicit polygon-shaped site boundaries).
const polygon = anyNode.polygon as unknown
if (Array.isArray(polygon)) {
// Zones/slabs expose a plain array of [x,z] tuples. Site nodes nest the
// points under `polygon.points` (a discriminated PropertyLineData shape).
for (const point of polygon) {
if (Array.isArray(point) && point.length >= 2) {
extendPoint(acc, point[0], point[1])
}
}
} else if (
polygon &&
typeof polygon === 'object' &&
Array.isArray((polygon as { points?: unknown }).points)
) {
// Site nodes only: skip the default bootstrap square so a blank scene
// isn't auto-framed around an empty ±15 m box. Include any other site
// polygon (more than 4 points, or any coordinate beyond the default).
const points = (polygon as { points: unknown[] }).points
if (node.type === 'site' && isDefaultSitePolygon(points)) {
// Skip — default bootstrap polygon.
} else {
for (const point of points) {
if (Array.isArray(point) && point.length >= 2) {
extendPoint(acc, point[0], point[1])
}
}
}
}
// Position on the XZ plane (3D position = [x, y, z]).
const position = anyNode.position as unknown
if (Array.isArray(position) && position.length >= 3) {
extendPoint(acc, position[0], position[2])
}
}
if (!acc.hasPoint) return null
// Ensure a minimum extent so a single-point scene still yields a box.
let minX = acc.minX
let minZ = acc.minZ
let maxX = acc.maxX
let maxZ = acc.maxZ
if (maxX - minX < MIN_BOUNDS_EXTENT) {
const cx = (minX + maxX) / 2
minX = cx - MIN_BOUNDS_EXTENT / 2
maxX = cx + MIN_BOUNDS_EXTENT / 2
}
if (maxZ - minZ < MIN_BOUNDS_EXTENT) {
const cz = (minZ + maxZ) / 2
minZ = cz - MIN_BOUNDS_EXTENT / 2
maxZ = cz + MIN_BOUNDS_EXTENT / 2
}
const centerX = (minX + maxX) / 2
const centerZ = (minZ + maxZ) / 2
return {
min: [minX, minZ],
max: [maxX, maxZ],
center: [centerX, centerZ],
size: [maxX - minX, maxZ - minZ],
}
}
/**
* Matches the `SiteNode` bootstrap polygon defined in
* `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
* We ignore it so the default scene doesn't "auto-frame" onto an empty box.
*/
function isDefaultSitePolygon(points: unknown[]): boolean {
if (points.length !== 4) return false
const expected: [number, number][] = [
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
]
for (let i = 0; i < 4; i++) {
const p = points[i]
const e = expected[i]!
if (!Array.isArray(p) || p.length < 2) return false
if (p[0] !== e[0] || p[1] !== e[1]) return false
}
return true
}
+2
View File
@@ -0,0 +1,2 @@
dist/
*.tsbuildinfo
+39
View File
@@ -0,0 +1,39 @@
# Changelog
All notable changes to `@pascal-app/mcp` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-04-18
### Added
- Initial release.
- `SceneBridge` headless adapter for `@pascal-app/core` with RAF polyfill so
the Zustand store and Zundo temporal middleware run cleanly in Node.
- 19 MCP tools covering scene querying (`get_scene`, `get_node`,
`describe_node`, `find_nodes`, `measure`), mutation (`apply_patch`,
`create_level`, `create_wall`, `place_item`, `cut_opening`, `set_zone`,
`duplicate_level`, `delete_node`), undo/redo (`undo`, `redo`), export
(`export_json`, `export_glb`), validation (`validate_scene`,
`check_collisions`), plus 2 vision tools (`analyze_floorplan_image`,
`analyze_room_photo`) backed by MCP sampling.
- 4 MCP resources: `pascal://scene/current`,
`pascal://scene/current/summary`, `pascal://catalog/items`, and
`pascal://constraints/{levelId}`.
- 3 MCP prompts: `from_brief`, `iterate_on_feedback`, and
`renovation_from_photos`.
- stdio and Streamable HTTP transports.
- `pascal-mcp` CLI binary with `--stdio`, `--http --port`, and `--scene`
flags.
- Local `SqliteSceneStore` backed by built-in SQLite drivers (`bun:sqlite` in
the MCP CLI, `node:sqlite` in the Next.js editor server), with WAL mode,
transaction-scoped optimistic locking, revision rows, and shared
`PASCAL_DATA_DIR` / `PASCAL_DB_PATH` configuration for MCP and the editor.
### Removed
- Supabase storage adapter, SQL migrations, and the `@supabase/supabase-js`
runtime dependency.
- Committed MCP `test-reports/` development artifacts.
+232
View File
@@ -0,0 +1,232 @@
# Cross-cutting changes touching packages outside `@pascal-app/mcp`
Integrator review required. Each entry documents:
- **What** was changed
- **Why** (what blocked MCP without it)
- **Impact** on existing consumers
- **Reversibility**
## 1. `packages/core/package.json` — added subpath exports
### What
Added these subpath entries to the `"exports"` map of `@pascal-app/core`:
- `./schema``./dist/schema/index.js`
- `./store``./dist/store/use-scene.js`
- `./material-library``./dist/material-library.js`
- `./spatial-grid``./dist/hooks/spatial-grid/spatial-grid-manager.js`
- `./wall``./dist/systems/wall/wall-footprint.js`
The existing `"."` and `"./clone-scene-graph"` entries are unchanged.
### Why
The main entry (`.`) re-exports every `System*` (`WallSystem`, `SlabSystem`, `CeilingSystem`, `RoofSystem`, `ItemSystem`, `StairSystem`, `DoorSystem`, `WindowSystem`, `FenceSystem`) which side-effect-imports `three`, `three-mesh-bvh`, and `three-bvh-csg`. In Node (no browser), `three-mesh-bvh`'s CJS UMD build fails to resolve its `three.*` globals at module-load time, so merely `import { WallNode } from '@pascal-app/core'` crashes before any user code runs.
By adding subpath exports that point at modules which don't transitively pull graphics code, the MCP server package (and any future Node consumer) can import just the Zod schemas and the Zustand store without dragging in `three` and its GPU-bound dependencies.
### Impact
**Zero** on existing consumers. This is purely additive. `apps/editor` and `@pascal-app/viewer` continue to import from the main entry and get the full surface — they currently don't use these subpaths and don't need to. No types, runtime behavior, or bundle composition is affected.
### Reversibility
Remove the 5 new entries from `exports` and the change is undone. `@pascal-app/mcp` would then have to ship its own shim or the core team would need to split `@pascal-app/core` into a "core-data" package and a "core-systems" package — a larger refactor.
### Suggested follow-up (upstream)
Long-term, consider moving `systems/` into a separate package `@pascal-app/systems` so that `@pascal-app/core` stays data-only. That's a breaking change and out of scope for this PR; the subpath exports are the non-breaking interim fix.
---
## 2. `SiteNode.children` inconsistency (observed, not fixed)
### What
`packages/core/src/schema/nodes/site.ts:36-38` declares:
```ts
children: z.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
.default([BuildingNode.parse({})])
```
`SiteNode.children` therefore holds **full node objects**. Every other container node (`building`, `level`, `wall`, `ceiling`, `roof`, `stair`) stores `string[]` (IDs) in `children`.
### Why this is a problem
- Data duplication: the building exists both in `nodes[building.id]` and embedded inside `site.children[0]`. Updates to the building in the dict don't propagate to the embedded copy.
- Traversal asymmetry: "get children of a container" needs `site`-specific branching.
- `duplicate_level`, `find_nodes({ parentId })`, and scene-serialisation round-trips all need a special case for site.
### Why we didn't fix it
Changing the schema is a breaking change to serialised scene data and would require a migration pass inside `setScene`. Out of scope for a non-breaking MCP addition.
### Workaround (inside MCP)
MCP tools resolve node children through the flat `nodes` dict by scanning for nodes whose `parentId` matches. This is correct regardless of which representation the schema chose.
### Suggested follow-up (upstream)
Align `SiteNode.children` to `z.array(z.string())` + migration in `setScene.migrateNodes` that extracts embedded building/item objects into the flat dict and replaces them with IDs.
---
## 3. `.github/workflows/mcp-ci.yml` — new CI workflow
### What
Adds a CI workflow that runs on pushes to `main` and on pull requests touching `packages/mcp/`, `packages/core/`, the editor scene API surface, `.github/workflows/mcp-ci.yml`, or `bun.lock`. The job installs deps with Bun, builds `@pascal-app/core` then `@pascal-app/mcp`, runs `bun test` in the mcp package, runs focused editor scene API tests, and runs Biome over the MCP package plus the editor scene API files.
### Why
The existing `.github/workflows/release.yml` is `workflow_dispatch`-only (manual releases for `core` / `viewer`). There was no automated pre-merge check for MCP builds/tests. A new workflow is still needed so that PRs touching mcp/core are verified before merge, and it now covers the editor scene API because those routes consume the same MCP operations layer. Full `apps/editor` typecheck was evaluated but is not part of this workflow because it currently fails on unrelated `packages/editor` type errors.
### Impact
None on existing workflows; purely additive. The workflow only triggers for MCP/core/editor scene API paths, the workflow file itself, or `bun.lock`, so unrelated PRs remain unaffected. `release.yml` is untouched.
### Reversibility
Delete `.github/workflows/mcp-ci.yml`.
---
## 4. `packages/mcp/package.json` — added `./storage` and `./operations` subpath exports
### What
Added `./storage` and `./operations` entries to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}` and `dist/operations/index.{js,d.ts}`. The existing `"."` entry is unchanged.
### Why
The Next.js editor (`apps/editor`) needs access to `createSceneStore()`, `SceneStore` types/errors, and the shared `SceneOperations` service layer in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs storage/operations. The subpath exports let `apps/editor` dynamically import storage and operations without re-declaring either contract.
The concrete backend is now `SqliteSceneStore`, backed by built-in SQLite drivers (`bun:sqlite` for the MCP CLI and `node:sqlite` for the Next.js editor server). It writes to `~/.pascal/data/pascal.db` by default and also supports `PASCAL_DATA_DIR`, `PASCAL_DB_PATH`, and `PASCAL_MAX_SCENE_BYTES`.
### Impact
Zero on existing consumers. Purely additive. The `.` entry continues to export `SceneBridge`, `createPascalMcpServer`, etc., exactly as before.
### Reversibility
Remove the `./storage`/`./operations` entries from `exports` and update `apps/editor` to use a different factory. No data or behavior changes — pure module-graph shaping.
### Related
- `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency so the subpath resolves.
- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume these subpaths.
- `packages/mcp/src/storage/sqlite-scene-store.ts` is the only production storage backend.
---
## 5. `packages/core/src/schema/asset-url.ts` — URL scheme allowlist on scene URL fields
### What
Introduced a shared `AssetUrl` Zod validator and replaced the bare `z.string()`
on every URL-bearing field in core's schemas:
- `scan.url` (`packages/core/src/schema/nodes/scan.ts`)
- `guide.url` (`packages/core/src/schema/nodes/guide.ts`)
- `item.asset.src` (`packages/core/src/schema/nodes/item.ts`)
- `material.texture.url` (`packages/core/src/schema/material.ts`)
- `material.maps.*` (`albedoMap`, `normalMap`, `roughnessMap`, `metalnessMap`,
`aoMap`, `displacementMap`, `emissiveMap`, `bumpMap`, `alphaMap`, `lightMap`)
The validator accepts `asset://…`, `blob:…`, `data:image/…`, `/…` app-relative
paths, `https://…`, and `http://localhost|127.0.0.1/…`. Optional origin
narrowing via `process.env.PASCAL_ALLOWED_ASSET_ORIGINS` (comma-separated).
Rejects `javascript:`, `file:`, `ftp:`, `ws:`, `data:text/html`,
`data:application/*`, link-local / private IPs over bare `http`, empty strings,
and non-URL garbage.
### Why
Security review found that an attacker-crafted scene containing
`javascript:alert(1)` or `http://169.254.169.254/latest/meta-data/` for a
texture URL would beacon or exfiltrate when the editor renders it.
`AnyNode.safeParse`, used by the MCP bridge, now rejects those payloads at the
schema boundary.
### Impact
- **Existing scenes**: localStorage-resident scenes bypass strict validation on
load (the store's `setScene` only runs `safeParse` on stair-type via
`migrateNodes`), so this is *not* a breakage for returning users. Legacy
URLs will keep loading; only explicit MCP-bridge `safeParse` calls reject.
- **MCP consumers**: one existing test
(`packages/mcp/src/bridge/scene-bridge.test.ts`, previously using
`src: 'data:model/gltf-binary;base64,'`) now fails because `data:model/` is
not in the allowlist. Replaced with `asset://test/chair.glb` — the only
sanctioned scheme for an in-repo ItemNode fixture.
- **Other packages**: `@pascal-app/viewer`, `@pascal-app/editor`, and
`material-library.ts` all continue to work because every built-in URL is a
`/material/…` app-relative path (allowlisted).
### Known gaps / follow-ups
1. **`item.asset.thumbnail` stayed untyped** — the field is still bare
`z.string()` in `item.ts`. The Phase 3 audit called it out alongside `src`,
but the Phase 7 task scope only required `src`. Follow-up: apply `AssetUrl`
to `thumbnail` as well. Verify the `place-item` tool's default
`thumbnail: ''` (currently empty string) gets a proper fallback first.
2. **`dist/` pollution** — `packages/core/tsconfig.json` `include`s `src` and
doesn't exclude `**/*.test.ts`, so the new `asset-url.test.ts` is emitted
to `dist/schema/`. Harmless (nothing imports it), but should be excluded
for a clean publish. Mirror the `exclude: ["**/*.test.ts"]` pattern used
in `packages/mcp/tsconfig.json`. Out of scope for A7 because tsconfig is
not in the ownership list.
3. **`bun:test` typing** — the test file uses `@ts-expect-error` on its
`bun:test` import because `@pascal-app/core` does not depend on
`@types/bun`. Adding it as a dev dep (or, preferred, excluding tests from
the core tsc build per gap 2) would remove the directive.
4. **`data:image/svg+xml` loophole** — passes the validator because it starts
with `data:image/`, but SVG can carry inline scripts. If the editor ever
renders SVG via unsanitised HTML-injection APIs or `<foreignObject>`, this
becomes an injection vector. Consider a stricter variant
(`data:image/(png|jpe?g|webp|gif)`) for texture slots where SVG isn't needed.
5. **Same-origin HTTP scenes**`http://localhost` is allowed for dev, but a
scene persisted in dev and shared in prod will still validate. Consider
gating on `NODE_ENV` once we have a stable env-flag story.
### Reversibility
Delete `packages/core/src/schema/asset-url.ts` and revert the five imports in
`scan.ts`, `guide.ts`, `item.ts`, and `material.ts` to `z.string()`. The
scene-bridge test update is self-contained.
---
## 6. `apps/editor` / `packages/editor` scene-loading support
### What
The PR still touches the editor app and editor package, but the remaining files
are tied to the MCP scene workflow:
- `apps/editor/app/api/scenes/**`, `apps/editor/components/save-button.tsx`,
and `apps/editor/components/scene-loader.tsx` expose saved MCP scenes in the
web editor.
- `packages/editor/src/hooks/use-auto-frame.ts` plus
`packages/editor/src/lib/scene-bounds.ts` frame the camera after a stored scene
is loaded, avoiding an apparently empty viewport when MCP loads a scene away
from the default camera pose.
- The large demo fixture `apps/editor/public/dev/casa-sol.json` was removed from
this PR to keep the diff focused.
### Why
The MCP package can save scenes without these editor changes, but the PR goal is
to let contributors open and continue scenes saved by MCP. The API/UI pieces and
auto-frame hook are the minimum editor-side bridge for that workflow. They do
not change `@pascal-app/viewer` exports.
### Reversibility
If maintainers want a narrower MCP-only PR, revert the editor app pages/routes
and the auto-frame helper files, then keep only `@pascal-app/mcp`, the required
`@pascal-app/core` subpath/schema changes, and `bun.lock`.
---
+438
View File
@@ -0,0 +1,438 @@
# @pascal-app/mcp — Implementation Plan
> This document is the contract for the 8-agent parallel build. All subagents MUST read it before writing code. Deviations require an entry in `CROSS_CUTTING.md`.
## 0. Ground truth discovered in Phase 0
- **Monorepo layout.** Turborepo + Bun. Root `package.json` already lists `packages/*` in `workspaces`. Our new package sits at `packages/mcp/`.
- **Build tooling.** TypeScript 5.9.3, `tsc --build` per package, outputs to `dist/`. Biome 2.4.x for lint/format (root `biome.jsonc`).
- **AGENTS.md does not exist.** `CLAUDE.md` is a symlink pointing to a non-existent `AGENTS.md`. The conventions referenced in the task prompt are therefore derived from `README.md`, `CONTRIBUTING.md`, and the actual code.
- **`@pascal-app/core` v0.5.1** — already built and consumed by `@pascal-app/viewer` with `workspace:*` via `peerDependencies`. It exports the full Zod schema surface, the `useScene` Zustand store with `temporal` (Zundo) wrapper, systems, hooks, lib utilities, events, and `clone-scene-graph`.
- **MCP SDK** — `@modelcontextprotocol/sdk@1.29.0` (latest stable). Subpath exports include `./server/mcp.js`, `./server/stdio.js`, `./server/streamableHttp.js`, `./client/*`, `./types.js`.
## 0.5 Bridge spike result (CONFIRMED)
Ran `scripts/spike.ts` end-to-end. ✅ All checks pass:
- `useScene.loadScene()` creates default Site → Building → Level (3 nodes)
- `createNode(wall, levelId)` adds wall to `nodes` dict and to `level.children`
- `updateNode(wallId, { thickness, height })` merges update, then RAF polyfill fires `markDirty`
- `temporal.undo()` reverts update, and a second `undo()` removes the wall
- `temporal.redo(2)` restores both steps
- `deleteNode(wallId)` removes wall and cleans parent's children array
- `unloadScene()``setScene(snapshot...)` round-trip preserves node count
Node compatibility requires:
1. **RAF polyfill** loaded before any core import (see §1 below).
2. **Subpath imports**, not the main entry. See §0.6 below.
## 0.6 Import contract (CRITICAL — every subagent must use these)
Do **NOT** `import X from '@pascal-app/core'`. The main entry re-exports Three.js systems and fails at load-time in Node.
Use these subpaths (added to core's `exports` map — see `CROSS_CUTTING.md`):
```ts
// Zod schemas (safe in Node)
import {
AnyNode,
BuildingNode, CeilingNode, DoorNode, FenceNode, GuideNode, ItemNode,
LevelNode, RoofNode, RoofSegmentNode, ScanNode, SiteNode, SlabNode,
StairNode, StairSegmentNode, WallNode, WindowNode, ZoneNode,
type AnyNodeId, type AnyNodeType,
} from '@pascal-app/core/schema'
// Zustand store (default export)
import useScene from '@pascal-app/core/store'
// NOTE: useScene is the DEFAULT export from this subpath
// Clone helpers
import {
cloneLevelSubtree, cloneSceneGraph, forkSceneGraph,
type SceneGraph,
} from '@pascal-app/core/clone-scene-graph'
// Material catalog (safe in Node — no three imports)
import {
MATERIAL_CATALOG, getCatalogMaterialById, getMaterialsForTarget,
} from '@pascal-app/core/material-library'
// Spatial utilities (pure functions — safe in Node)
import { pointInPolygon, spatialGridManager } from '@pascal-app/core/spatial-grid'
// Wall helpers (pure functions)
import {
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS,
getWallPlanFootprint, getWallThickness,
} from '@pascal-app/core/wall'
```
`useScene` is the default export of `@pascal-app/core/store`. Use `useScene.getState()` / `useScene.temporal.getState()` as usual.
## 0.7 SiteNode.children quirk
`SiteNode.children` is declared as `z.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))` — it holds **objects**, not IDs. Every other container node (`building`, `level`, `wall`, `ceiling`, `roof`, `stair`) stores `string[]` of child IDs.
Implications for tools:
- Parent-child traversal through `site` cannot use the generic "children is ID[]" pattern.
- Always resolve children through the flat `nodes` dict via `parentId` scan when you need to enumerate descendants of a site.
- `describe_node` / `find_nodes` / `duplicate_level` must special-case site.
(This is upstream-worthy simplification; filed in `CROSS_CUTTING.md` section 2 as a suggested refactor but not taken in this PR.)
## 1. Node-compatibility: the critical adapter
The core store was written for the browser. Node support requires **one polyfill** at MCP package boot (before `import useScene`):
```ts
// packages/mcp/src/bridge/node-shims.ts
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
;(globalThis as any).requestAnimationFrame = (cb: (t: number) => void): number => {
return setTimeout(() => cb(performance.now()), 0) as unknown as number
}
;(globalThis as any).cancelAnimationFrame = (id: number) => {
clearTimeout(id as unknown as NodeJS.Timeout)
}
}
```
**Why:** `packages/core/src/store/actions/node-actions.ts:330` calls `requestAnimationFrame` inside `updateNodesAction`. `packages/core/src/store/use-scene.ts:462` calls `requestAnimationFrame` inside the temporal subscribe callback that marks affected nodes dirty after undo/redo. Both are load-reachable — the subscribe callback registers at module import time.
`crypto.randomUUID` is available globally in Node 18+, no shim needed. `URL.createObjectURL` is only called in `loadAssetUrl` which we do NOT call in MCP (no browser assets). `idb-keyval` is imported at the top of `asset-storage.ts` but only executes functions when `saveAsset`/`loadAssetUrl` are called; we never import that module in MCP code.
**Persist middleware:** core does NOT apply `zustand/middleware/persist`. Persistence happens in `apps/editor`, not in core. So the store is already Node-clean apart from RAF.
## 2. Node types (17 total)
Every one of these has a Zod schema in `packages/core/src/schema/nodes/` and participates in `AnyNode` (discriminated union on `type`):
| Type literal | Schema export | Parent expected | Container? | Notes |
|-----------------|------------------|------------------|------------|-------|
| `site` | `SiteNode` | — (root) | children via typed array | polygon (2D) |
| `building` | `BuildingNode` | `site` | children: level IDs | position/rotation |
| `level` | `LevelNode` | `building` | children: mixed IDs | level (int) |
| `wall` | `WallNode` | `level` | children: item/door/window IDs | 2D `start`/`end` |
| `fence` | `FenceNode` | `level` | — | 2D `start`/`end` |
| `zone` | `ZoneNode` | `level` | — | polygon (2D) |
| `slab` | `SlabNode` | `level` | — | polygon + holes |
| `ceiling` | `CeilingNode` | `level` | children: item IDs | polygon + holes |
| `roof` | `RoofNode` | `level` | children: roof-segment IDs | position/rotation |
| `roof-segment` | `RoofSegmentNode`| `roof` | — | `roofType` enum |
| `stair` | `StairNode` | `level` | children: stair-segment IDs | from/toLevelId |
| `stair-segment` | `StairSegmentNode`| `stair` | — | flight/landing |
| `item` | `ItemNode` | `wall` / `ceiling` / `site` | children: item IDs | `asset` payload |
| `door` | `DoorNode` | `wall` | — | segments/panels |
| `window` | `WindowNode` | `wall` | — | columns/rows |
| `scan` | `ScanNode` | `level` | — | external GLB url |
| `guide` | `GuideNode` | `level` | — | 2D guide image url |
The full union is `AnyNode` at `packages/core/src/schema/types.ts:20`. `AnyNodeType` and `AnyNodeId` are also exported there. **Subagents MUST reuse these — never redefine.**
## 3. Store API — the only mutation surface
From `packages/core/src/store/use-scene.ts:160-201`. All calls via `useScene.getState()`:
```ts
useScene.getState().createNode(node: AnyNode, parentId?: AnyNodeId): void
useScene.getState().createNodes(ops: { node: AnyNode; parentId?: AnyNodeId }[]): void
useScene.getState().updateNode(id: AnyNodeId, data: Partial<AnyNode>): void
useScene.getState().updateNodes(updates: { id: AnyNodeId; data: Partial<AnyNode> }[]): void
useScene.getState().deleteNode(id: AnyNodeId): void
useScene.getState().deleteNodes(ids: AnyNodeId[]): void
useScene.getState().setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void
useScene.getState().loadScene(): void // initializes empty default Site → Building → Level
useScene.getState().clearScene(): void // unloadScene + loadScene
useScene.getState().unloadScene(): void // truly empties state
useScene.getState().markDirty(id: AnyNodeId): void
useScene.getState().clearDirty(id: AnyNodeId): void
useScene.getState().setReadOnly(readOnly: boolean): void
```
Undo/redo (Zundo temporal wrapper):
```ts
useScene.temporal.getState().undo(steps?: number): void
useScene.temporal.getState().redo(steps?: number): void
useScene.temporal.getState().clear(): void
useScene.temporal.getState().pastStates // readonly
useScene.temporal.getState().futureStates // readonly
```
Plus `import { clearSceneHistory } from '@pascal-app/core'`.
**Dirty bookkeeping in headless mode.** Because no renderer is consuming `dirtyNodes`, the set accumulates. For MCP correctness we don't care — dirty tracking is a renderer concern. We will expose a `flushDirty()` helper in the bridge that simply empties the set after a mutation batch for observability.
## 4. MCP package layout
```
packages/mcp/
├── PLAN.md (this file)
├── PR_DESCRIPTION.md (Phase 3 deliverable)
├── CROSS_CUTTING.md (any proposed upstream changes)
├── README.md
├── CHANGELOG.md
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # programmatic API re-exports
│ ├── server.ts # createPascalMcpServer() factory
│ ├── bridge/
│ │ ├── node-shims.ts # RAF polyfill (load FIRST)
│ │ ├── scene-bridge.ts # SceneBridge class
│ │ └── scene-bridge.test.ts
│ ├── tools/
│ │ ├── index.ts # registerTools(server, bridge)
│ │ ├── schemas.ts # shared patch schemas
│ │ ├── errors.ts # structured MCP error helpers
│ │ ├── get-scene.ts
│ │ ├── get-node.ts
│ │ ├── describe-node.ts
│ │ ├── find-nodes.ts
│ │ ├── measure.ts
│ │ ├── apply-patch.ts
│ │ ├── create-level.ts
│ │ ├── create-wall.ts
│ │ ├── place-item.ts
│ │ ├── cut-opening.ts
│ │ ├── set-zone.ts
│ │ ├── duplicate-level.ts
│ │ ├── delete-node.ts
│ │ ├── undo.ts
│ │ ├── redo.ts
│ │ ├── export-json.ts
│ │ ├── export-glb.ts # stub: not_implemented
│ │ ├── validate-scene.ts
│ │ ├── check-collisions.ts
│ │ ├── analyze-floorplan-image.ts
│ │ ├── analyze-room-photo.ts
│ │ └── *.test.ts (one per tool)
│ ├── resources/
│ │ ├── index.ts
│ │ ├── scene-current.ts
│ │ ├── scene-summary.ts
│ │ ├── catalog-items.ts
│ │ ├── constraints.ts
│ │ └── resources.test.ts
│ ├── prompts/
│ │ ├── index.ts
│ │ ├── from-brief.ts
│ │ ├── iterate-on-feedback.ts
│ │ ├── renovation-from-photos.ts
│ │ └── prompts.test.ts
│ ├── transports/
│ │ ├── stdio.ts
│ │ └── http.ts
│ └── bin/
│ └── pascal-mcp.ts # CLI entry; shebang #!/usr/bin/env node
├── scripts/
│ └── smoke.ts # end-to-end client test
├── examples/
│ ├── generate-apartment.md
│ ├── renovate-from-photos.md
│ └── embed-in-agent.ts
└── dist/ # generated
```
## 5. Tool inventory (exact contracts)
All tools declared with Zod input AND output schemas. Handlers return `{ content: [{ type: 'text', text: JSON.stringify(validatedOutput) }], structuredContent?: output, isError?: boolean }` per MCP SDK 1.x spec. Error handlers throw `McpError` with `ErrorCode.InvalidParams` / `InvalidRequest` / `InternalError`.
### Read-only
1. **`get_scene`** — `() => { nodes, rootNodeIds, collections }`
2. **`get_node`** — `{ id }` → the node or throws `InvalidParams` "node not found"
3. **`describe_node`** — `{ id }``{ id, type, parentId, ancestry[], childrenCount, properties, description }`
4. **`find_nodes`** — `{ type?, parentId?, zoneId?, levelId? }``{ nodes: AnyNode[] }`. `zoneId` filter returns nodes whose position falls inside the zone polygon; `levelId` filter resolves via ancestry using `resolveLevelId`.
5. **`measure`** — `{ fromId, toId }``{ distanceMeters, areaSqMeters?, units: 'meters' }`
### Mutations (undo-safe)
6. **`apply_patch`** — `{ patches: Patch[] }` where `Patch = Create | Update | Delete | Move`. Validates all with Zod, dry-runs first, then batch-applies via `createNodes` / `updateNodes` / `deleteNodes`. Zundo captures this as a single temporal step because of Zustand set batching inside each `*Nodes` call.
7. **`create_level`** — `{ buildingId, elevation, height, label? }``{ levelId }`. Uses `LevelNode.parse({...})` then `createNode`.
8. **`create_wall`** — `{ levelId, start, end, thickness?, height? }``{ wallId }`. Uses `WallNode.parse({...})` with defaults from `DEFAULT_WALL_HEIGHT` / `DEFAULT_WALL_THICKNESS` if omitted.
9. **`place_item`** — `{ catalogItemId, targetNodeId, position, rotation? }``{ itemId }` or `{ error: 'invalid_placement', reason }`. Pre-validation:
- If target is a slab/ceiling: call pure `spatialGridManager.canPlaceOnFloor(...)` equivalent (we inline the pure logic from `hooks/spatial-grid/spatial-grid-manager.ts` rather than using React-bound spatial-grid-sync).
- If target is a wall: compute `wallT` from position along wall centerline; validate via `canPlaceOnWall`.
- Resolve `catalogItemId` → asset payload. Catalog may be unavailable in headless mode — return structured `{ status: 'catalog_unavailable' }` error if so.
10. **`cut_opening`** — `{ wallId, type: 'door' | 'window', position: 0..1, width, height }``{ openingId }`. Creates a `DoorNode` or `WindowNode` with `wallId` set; position maps to wallT.
11. **`set_zone`** — `{ levelId, polygon, label, properties? }``{ zoneId }`. Creates `ZoneNode` via `ZoneNode.parse`.
12. **`duplicate_level`** — `{ levelId }``{ newLevelId, newNodeIds[] }`. Uses `cloneLevelSubtree(levelId, { nodes, rootNodeIds })` from `@pascal-app/core/clone-scene-graph`, then bulk-inserts the cloned nodes via `createNodes`.
13. **`delete_node`** — `{ id, cascade?: boolean }``{ deletedIds: [] }`. If `cascade` is false and node has children, throw `InvalidRequest` "node has children; pass cascade: true to delete recursively". If `cascade` is true, just call `deleteNode(id)` (core's deleteNodesAction already cascades via descendant collection).
### Undo/redo
14. **`undo`** — `{ steps? }``{ undone: number }`
15. **`redo`** — `{ steps? }``{ redone: number }`
### Export
16. **`export_json`** — `{ pretty?: boolean }``{ json: string }`
17. **`export_glb`** — `{}` → throws `InternalError` with `{ status: 'not_implemented', reason: 'GLB export requires the Three.js renderer, which is browser-only' }`
### Validation
18. **`validate_scene`** — `{}``{ valid: boolean, errors: { nodeId, path, message }[] }`. Runs `AnyNode.safeParse(node)` on every node; additionally verifies parent-child integrity.
19. **`check_collisions`** — `{ levelId? }``{ collisions: { aId, bId, kind }[] }`. Uses pure spatial-grid helpers.
### Vision (MCP sampling)
20. **`analyze_floorplan_image`** — `{ image: string (base64 or https URL), scaleHint?: string }``{ walls, rooms, approximateDimensions, confidence }`. Constructs a `CreateMessageRequest` via `server.server.createMessage({ ... })` (MCP sampling). Response JSON validated against the output schema. On absent sampling capability, throws `InvalidRequest` `{ status: 'sampling_unavailable' }`.
21. **`analyze_room_photo`** — `{ image }``{ approximateDimensions, identifiedFixtures, identifiedWindows }`. Same pattern.
## 6. Resources (4)
- `pascal://scene/current``application/json`, full `{ nodes, rootNodeIds, collections }`
- `pascal://scene/current/summary``text/markdown`, human summary with counts + bbox + areas
- `pascal://catalog/items``application/json`, item catalog if available; else `{ status: 'catalog_unavailable', items: [] }`
- `pascal://constraints/{levelId}``application/json`, slab footprints + wall polygons for that level
Register via `server.registerResource(...)` with `readResource` handlers.
## 7. Prompts (3)
- `from_brief` — args `{ brief: string, constraints?: string }`. Returns messages that instruct the agent to call `apply_patch` incrementally starting from an empty site.
- `iterate_on_feedback` — args `{ feedback: string }`. Minimal-diff instructions.
- `renovation_from_photos` — args `{ currentPhotos: string[], referencePhotos: string[], goals: string }`. Tells the agent to call the vision tools first, then propose patches.
## 8. Transports
- **stdio** (default) — `StdioServerTransport` from `@modelcontextprotocol/sdk/server/stdio.js`.
- **HTTP** — `StreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/streamableHttp.js`, bound to a `node:http` server on `--port`.
CLI `pascal-mcp` flags:
- `--stdio` (default) — stdio transport
- `--http --port <n>` — HTTP transport
- `--scene <path>` — load initial scene from JSON file via `setScene`
- `--help`, `--version`
## 9. package.json contract
```jsonc
{
"name": "@pascal-app/mcp",
"version": "0.1.0",
"description": "Model Context Protocol server for Pascal 3D editor",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js" },
"files": ["dist", "README.md", "CHANGELOG.md"],
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"start": "bun dist/bin/pascal-mcp.js",
"test": "bun test",
"smoke": "bun run scripts/smoke.ts",
"prepublishOnly": "bun run build && bun test"
},
"peerDependencies": {
"@pascal-app/core": "workspace:*"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"typescript": "5.9.3"
}
}
```
Note: `@pascal-app/core` is a **peer dependency**, but Bun workspaces auto-resolve it via `workspaces` in the root. In practice we'll also list it under `devDependencies` with `workspace:*` so `bun install` hoists it.
## 10. tsconfig.json contract
Extends `@pascal/typescript-config/base.json` (NOT react-library — no DOM).
```jsonc
{
"extends": "@pascal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"composite": true,
"incremental": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"],
"references": [{ "path": "../core" }]
}
```
Separate tsconfig excludes do not apply to tests (bun runs them directly from TS).
## 11. turbo.json — no changes required
The existing `turbo.json` globs `packages/*` implicitly via Bun workspaces and pipeline tasks are generic (`build`, `lint`, `check-types`, `dev`). MCP picks up for free.
## 12. File-ownership map for 8 parallel subagents
**Rule: an agent may ONLY write files listed in their column. If they need a file outside their column, they must emit a `CROSS_CUTTING.md` entry instead.**
| Path | Agent |
|-------------------------------------------|-------|
| `packages/mcp/package.json` | A |
| `packages/mcp/tsconfig.json` | A |
| `packages/mcp/README.md` | G |
| `packages/mcp/CHANGELOG.md` | G |
| `packages/mcp/src/index.ts` | A |
| `packages/mcp/src/server.ts` | C (registers tools; D extends with resources/prompts)* |
| `packages/mcp/src/bridge/**` | B |
| `packages/mcp/src/tools/**` (except vision) | C |
| `packages/mcp/src/tools/analyze-*.ts` | E |
| `packages/mcp/src/resources/**` | D |
| `packages/mcp/src/prompts/**` | D |
| `packages/mcp/src/transports/**` | F |
| `packages/mcp/src/bin/**` | F |
| `packages/mcp/scripts/smoke.ts` | F |
| `packages/mcp/examples/**` | G |
| Root `turbo.json` / CI workflows | H (only if strictly needed) |
| `packages/mcp/biome.jsonc` (if any) | H |
*Server.ts coordination: **Agent A writes a minimal `server.ts` stub exporting `createPascalMcpServer(bridge)` that returns an empty `McpServer`**. Agents C, D, E each export `register<Tools|Resources|Prompts|VisionTools>(server, bridge)` functions from their subtrees. Integration (me) wires them up in the final `server.ts` during Phase 2.
## 13. Known limitations (Phase 3 will surface these)
- `export_glb` returns `not_implemented`. GLB export depends on Three.js renderer output — not reachable headlessly without a large additional effort.
- Vision tools require MCP host sampling support. Claude Desktop supports this; some MCP clients don't.
- Systems run only via React hooks; headless mode doesn't regenerate geometry. Wall mitering, slab triangulation, CSG cutouts, etc. remain unexecuted in the MCP process — but their inputs (node data) are still fully manipulable. Consumers that need derived geometry call `@pascal-app/viewer` in a browser host.
- Core's `loadAssetUrl`/`saveAsset` are browser-only; items that reference `asset://<id>` URLs aren't resolvable in Node. MCP consumers should supply absolute URLs or `data:` URLs for item assets if they need them usable outside the browser.
- `dirtyNodes` accumulates in headless mode. Consumers who care can call `bridge.flushDirty()`.
## 14. Zod strategy
- Import `z` from `zod`, matching core's `"zod": "^4.3.5"`.
- Input schemas: declared per-tool. Prefer positional tuples for `[x, z]`/`[x, y, z]` to match core.
- Output schemas: declared per-tool; used to validate the handler's return before sending to MCP.
- `AnyNode` / `SiteNode` / `WallNode` etc. imported from `@pascal-app/core`. Do not redeclare.
- For `apply_patch` inputs we use **partial schemas** (`AnyNode.partial()` isn't directly supported for discriminated unions; we declare a per-type update schema that accepts a subset of fields keyed by the type literal).
## 15. Test strategy
- `bun test` with colocated `*.test.ts` files.
- `@modelcontextprotocol/sdk` ships a test-friendly in-memory pair: `import { Client } from '@modelcontextprotocol/sdk/client/index.js'` + `InMemoryTransport`. Use these for handler-level tests.
- Smoke test (Agent F): spawn the stdio binary as a child process, connect from a real MCP client, assert `get_scene`, `create_level`, `create_wall`, `validate_scene`, `undo` round-trip.
- Target: ≥80% line coverage on MCP-owned files. Bridge at ≥95%.
## 16. Conventional commits (one per agent scope)
- `feat(mcp): scaffold @pascal-app/mcp package` — Agent A
- `feat(mcp): add headless scene bridge` — Agent B
- `feat(mcp): implement scene query and mutation tools` — Agent C
- `feat(mcp): add resources and prompts` — Agent D
- `feat(mcp): add multimodal vision tools via sampling` — Agent E
- `feat(mcp): add stdio + HTTP transports and CLI` — Agent F
- `docs(mcp): add README, examples, and changelog` — Agent G
- `chore(mcp): wire biome, tests, and CI` — Agent H
Integration commits land under `feat(mcp): wire server + integration` and `feat(mcp): v0.1.0 ready`.
+202
View File
@@ -0,0 +1,202 @@
# feat(mcp): add `@pascal-app/mcp` — Model Context Protocol server
## Summary
Introduces a new workspace package `@pascal-app/mcp` (v0.1.0) that exposes the Pascal scene graph (`@pascal-app/core`) as MCP **tools**, **resources**, and **prompts** so any MCP-compatible AI host — Claude Desktop, Claude Code, Codex CLI, Cursor, or a custom agent — can read, mutate, save, and reopen Pascal projects programmatically with full Zod validation, atomic patches, undo-safe mutations, multimodal image inputs, and local SQLite persistence.
The branch is now local-first: scenes persist to `~/.pascal/data/pascal.db` through SQLite, using `bun:sqlite` in the MCP CLI and `node:sqlite` when the Next.js editor server imports the storage package. The earlier Supabase adapter, SQL migrations, and committed `test-reports/` artifacts have been removed.
## Motivation
Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/editor/issues/74) opens the question of how external consumers should drive Pascal. The viewer answers "embed in a React app." This PR answers the complementary case: **drive Pascal from anything, without a browser** — AI agents, CLI scripts, background services, or IDE plugins.
## What's in the box
### Tool inventory
| Tool | Purpose |
|------|---------|
| `get_scene` | Return full scene JSON |
| `get_node` | Fetch one node by ID |
| `describe_node` | Human summary: ancestry, children, properties |
| `find_nodes` | Filter by type / parentId / levelId / zoneId |
| `measure` | Distance between two nodes; area if zone |
| `apply_patch` | Atomic multi-op (create / update / delete). All-or-nothing |
| `create_level` | Create a level under a building |
| `create_wall` | Create a wall on a level with 2D endpoints |
| `place_item` | Place an item on a wall / ceiling / site |
| `cut_opening` | Cut a door or window into a wall at t ∈ [0,1] |
| `set_zone` | Create a zone polygon on a level |
| `duplicate_level` | Deep-clone a level subtree with new IDs |
| `delete_node` | Delete a node (with optional cascade) |
| `undo` / `redo` | Drive Zundo temporal store |
| `export_json` | Serialize scene to JSON (pretty or compact) |
| `export_glb` | Stub (`not_implemented` — renderer required) |
| `validate_scene` | Zod-validate every node |
| `check_collisions` | Item placement conflicts per level |
| `analyze_floorplan_image` | (Vision/sampling) Extract structured floor plan |
| `analyze_room_photo` | (Vision/sampling) Extract room dimensions + fixtures |
| `save_scene` / `load_scene` / `list_scenes` / `rename_scene` / `delete_scene` | Persist scenes in local SQLite |
| `list_templates` / `create_from_template` | Seed scenes from bundled templates |
| `generate_variants` | Fork and mutate scene variants |
| `photo_to_scene` | Vision sampling to scene graph, optionally saved |
### Resources
| URI | MIME | Purpose |
|-----|------|---------|
| `pascal://scene/current` | `application/json` | Full scene |
| `pascal://scene/current/summary` | `text/markdown` | Counts, areas, bbox |
| `pascal://catalog/items` | `application/json` | Item catalog (unavailable headless) |
| `pascal://constraints/{levelId}` | `application/json` | Slabs + wall footprints |
### Prompts
| Prompt | Args |
|--------|------|
| `from_brief` | `brief`, `constraints?` |
| `iterate_on_feedback` | `feedback` |
| `renovation_from_photos` | `currentPhotos`, `referencePhotos`, `goals` |
## Architecture
```
┌─── MCP host (Claude Desktop / Code / Cursor / custom) ───┐
│ ▲ │
│ stdio │ HTTP │
│ ▼ │
│ ┌──────── packages/mcp/src/bin/pascal-mcp.ts ────────┐ │
│ │ (Bun CLI, loads node-shims first) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──── createPascalMcpServer({ bridge }) ────┐ │
│ │ registerTools() │ │
│ │ registerVisionTools() │ │
│ │ registerResources() │ │
│ │ registerPrompts() │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────── SceneOperations ─────────────────────┐ │
│ │ shared MCP / REST operation boundary │ │
│ │ wraps SceneBridge + local SQLite SceneStore │ │
│ │ Zod validation at every boundary │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ @pascal-app/core (unchanged, new subpath exports) │
└──────────────────────────────────────────────────────────┘
```
## How to test locally
```bash
# From the repo root
bun install
bun run --cwd packages/core build
bun run --cwd packages/mcp build
# Unit + integration tests (248 tests across 40 files)
bun test --cwd packages/mcp
# End-to-end smoke test (spawns stdio server and exercises 4 tools)
bun run --cwd packages/mcp smoke
# Biome lint
bunx biome check packages/mcp
# Turbo build
bunx turbo build --filter=@pascal-app/mcp
```
### Try it with Claude Desktop, Claude Code, or Codex
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"pascal": {
"command": "bun",
"args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
For Codex CLI:
```bash
codex mcp add pascal-dev \
--env PASCAL_DATA_DIR="$HOME/.pascal/data" \
-- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"
```
Run the editor with the same `PASCAL_DATA_DIR`, then ask the MCP host to create
and `save_scene`; the scene is openable at `/scene/<id>`.
## Known limitations
1. **GLB export is not implemented.** Three.js is browser-only; headless GLB export would require a significant additional effort. `export_glb` returns a structured `{ status: 'not_implemented' }` response.
2. **Vision tools require host sampling support.** `analyze_floorplan_image` / `analyze_room_photo` defer the vision work to the host via MCP sampling. Hosts without sampling capability get a structured `sampling_unavailable` error. No vision model is bundled.
3. **Headless mode doesn't regenerate geometry.** Wall mitering, slab triangulation, CSG cutouts, etc. run only in the browser renderer. MCP clients can manipulate node data freely, but derived geometry (mitered wall corners, cut-out walls with door/window holes) is recomputed only when a browser loads the scene via `@pascal-app/viewer`.
4. **`loadAssetUrl`/`saveAsset` are browser-only.** Items with `asset://<id>` URLs can't be resolved in Node. Supply absolute URLs or `data:` URIs if you need them usable outside the browser.
5. **`SiteNode.children` inconsistency.** Site's children hold full node objects while every other container holds ID strings (see `CROSS_CUTTING.md` §2). MCP works around this by traversing via the flat `nodes` dict. Upstream alignment proposed as a follow-up.
6. **Catalog unavailable in headless mode.** `pascal://catalog/items` and `place_item`'s catalog resolution fall back to a placeholder asset payload until the core exposes a Node-consumable catalog.
7. **HTTP/API exposure is guarded.** MCP HTTP binds to `127.0.0.1` by default and requires `PASCAL_MCP_HTTP_TOKEN`/`--auth-token` before binding non-loopback hosts. The editor scene API allows tokenless loopback development, but non-loopback requests require `PASCAL_SCENE_API_TOKEN`; both paths include CORS handling and in-memory rate limiting.
## Cross-cutting changes
Documented in [`packages/mcp/CROSS_CUTTING.md`](./CROSS_CUTTING.md):
1. **`packages/core/package.json` — additive subpath exports.** Adds `./schema`, `./store`, `./material-library`, `./spatial-grid`, `./wall`. Needed because the main entry re-exports browser-only systems; subpath entries let Node consumers skip them. Zero impact on existing consumers (`apps/editor`, `@pascal-app/viewer` still use the main entry).
2. **`.github/workflows/mcp-ci.yml` — new CI.** Kept because the repo otherwise only has manual release CI. It runs on PRs touching MCP/core/editor scene API code; installs with Bun 1.3.0, builds MCP, runs MCP tests, runs focused editor scene API tests, and biome-checks the touched surface.
3. **`apps/editor` scene routes.** Adds scene API routes and pages that read from the same SQLite-backed `SceneOperations` layer as MCP.
4. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2.
## Checklist
-`bunx biome check packages/mcp` — clean
-`bun run --cwd packages/mcp build` — tsc OK
-`bunx turbo build --filter=@pascal-app/mcp` — 2/2 tasks successful
-`bun test --cwd packages/mcp` — 248/248 tests pass across 40 files (965 expects)
-`bun run --cwd packages/mcp smoke` — spawns stdio server, registers 30 tools, exercises `get_scene` / `create_level` / `validate_scene` / `undo` end-to-end
-`bun test apps/editor/lib/scene-store-server.test.ts` — editor store singleton test passes
- ✅ Editor smoke — `/api/scenes/<id>` and `/scene/<id>` return 200 for a scene saved through MCP using the shared SQLite DB
- ✅ Local Codex MCP probe with `gpt-5.5` — saved a template scene through `pascal-dev`, then reloaded it and created a wall
- ✅ Docs: README with Claude Desktop, Claude Code, Codex CLI, Cursor configs + tool/resource/prompt tables, CHANGELOG, 3 examples
- ✅ Conventional commit series (9 commits on `feat/mcp-server`)
- ✅ No Supabase dependency, SQL migrations, or committed test-report artifacts
-`packages/core` changes are additive subpath exports plus URL-schema hardening
- ✅ Bun CLI; RAF polyfill loads before any core import
- ✅ Strict TypeScript (no `any` without reason; no `@ts-expect-error`); Zod at every boundary
- ✅ Every mutation goes through the Zustand store (undo-safe via Zundo)
## Commit series
```
feat(mcp): scaffold package and confirm headless bridge viability
feat(mcp): finalize scaffolding and factory entry
feat(mcp): add headless scene bridge with RAF polyfill
feat(mcp): implement 19 scene query and mutation tools
feat(mcp): add resources and prompts
feat(mcp): add multimodal vision tools via MCP sampling
feat(mcp): add stdio + streamable HTTP transports, CLI, and smoke test
docs(mcp): add README, examples, and changelog
chore(mcp): add CI workflow and document cross-cutting changes
feat(mcp,editor): add local SQLite scene persistence and editor scene routes
fix(mcp): remove Supabase backend and committed test reports
```
## Follow-up (future PRs)
- Align `SiteNode.children` to IDs-only (with `setScene` migration) — CROSS_CUTTING §2.
- Extract shared operation/service layer so MCP, CLI, and future REST/OpenAPI adapters do not duplicate business validation.
- Expose a Node-consumable item catalog from `@pascal-app/core` so `place_item` can resolve real catalog IDs.
- Surface real spatial-grid collision detection (currently a simple AABB pass in `check_collisions`).
- Post-build `chmod +x dist/bin/pascal-mcp.js` step so fresh installs get an executable bin without a manual chmod.
- Consider a separate `@pascal-app/systems` package so `@pascal-app/core` can go data-only (breaking change, larger refactor).
+330
View File
@@ -0,0 +1,330 @@
# @pascal-app/mcp
Model Context Protocol server for the Pascal 3D editor. Drives the
`@pascal-app/core` scene graph from any MCP-compatible AI host.
The server runs headlessly in Bun with no browser, WebGPU, React, or external
database service. It exposes the same scene mutations used by the editor UI
(create walls, place items, cut openings, undo, etc.) as MCP tools, resources,
and prompts.
## Install
```bash
bun add @pascal-app/mcp
```
`@pascal-app/core` is a peer dependency; Bun workspaces resolve it automatically.
The MCP CLI is intended to run with Bun. When the storage package is consumed by
the Next.js editor server, it opens the same local database through Node's
built-in SQLite driver.
## Quick start
Launch the server over stdio in one line:
```bash
bunx pascal-mcp
```
Load an initial scene from disk:
```bash
pascal-mcp --stdio --scene ./my-scene.json
```
Expose it over loopback HTTP:
```bash
pascal-mcp --http --port 8787
```
Binding a non-loopback host requires a bearer token:
```bash
PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example
```
## Local scene storage
Scenes saved through MCP are stored in a local SQLite database:
```text
~/.pascal/data/pascal.db
```
Set `PASCAL_DATA_DIR` when you want the MCP server and the running editor to
share a different directory, or `PASCAL_DB_PATH` when you need an exact database
file path. The store uses WAL mode and transactional version checks so separate
local processes can save and open the same scene database.
During workspace development, run both sides with the same data directory:
```bash
# Terminal 1: run the editor
PASCAL_DATA_DIR="$HOME/.pascal/data" bun run dev
# Terminal 2 or an MCP host: run the server
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`
(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
If `bunx` is not on your PATH, point `command` at the absolute path to `bun`
and pass the built `dist/bin/pascal-mcp.js` file as the first arg.
## Claude Code config
Via the CLI:
```bash
claude mcp add pascal bunx pascal-mcp
```
Or add to `.mcp.json` at the repo root:
```json
{
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
For local workspace testing before publish, build first and point Claude Code at
the built binary:
```json
{
"mcpServers": {
"pascal": {
"command": "bun",
"args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
## Codex CLI config
Via the CLI:
```bash
codex mcp add pascal --env PASCAL_DATA_DIR="$HOME/.pascal/data" -- bunx pascal-mcp
```
For local workspace testing before publish:
```bash
bun run --cwd packages/mcp build
codex mcp add pascal-dev \
--env PASCAL_DATA_DIR="$HOME/.pascal/data" \
-- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"
```
This writes an entry like this to `~/.codex/config.toml`:
```toml
[mcp_servers.pascal-dev]
command = "bun"
args = ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"]
[mcp_servers.pascal-dev.env]
PASCAL_DATA_DIR = "/Users/you/.pascal/data"
```
## Cursor config
In Cursor settings (`settings.json`):
```json
{
"mcp.servers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
## Programmatic use
Embed the server in your own Bun process using the in-memory transport. The
example below runs a full client/server pair inside a single script — useful
for agent frameworks and tests.
```ts
import { createPascalMcpServer, SceneBridge } from '@pascal-app/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'my-agent', version: '0.1.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
const tools = await client.listTools()
console.log('available tools:', tools.tools.map((t) => t.name))
const scene = await client.callTool({ name: 'get_scene', arguments: {} })
console.log(scene)
```
See [`examples/embed-in-agent.ts`](./examples/embed-in-agent.ts) for a
compilable version.
## Tools
All tools validate their inputs and outputs with Zod. Mutation tools are
captured by Zundo's temporal middleware as a single undoable step.
| Name | Purpose | Key input | Output |
| --- | --- | --- | --- |
| `get_scene` | Return the full scene graph. | — | `{ nodes, rootNodeIds, collections }` |
| `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 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: [] }` |
| `undo` | Step back through temporal history. | `{ steps? }` | `{ undone: number }` |
| `redo` | Step forward through temporal history. | `{ steps? }` | `{ redone: number }` |
| `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 }` |
The vision tools require the MCP host to support the sampling capability
(`createMessage`). Hosts that don't will see a structured
`sampling_unavailable` error.
## Resources
| URI | MIME | Purpose |
| --- | --- | --- |
| `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://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
| Name | Args | Purpose |
| --- | --- | --- |
| `from_brief` | `{ brief: string, constraints?: string }` | Guided workflow for turning a prose brief (e.g. "2-bed apartment in 80 m²") into an incremental sequence of `apply_patch` calls starting from an empty site. |
| `iterate_on_feedback` | `{ feedback: string }` | Minimal-diff instructions: examine the current scene, then propose the smallest patch set that satisfies the feedback. |
| `renovation_from_photos` | `{ currentPhotos: string[], referencePhotos: string[], goals: string }` | Chains the vision tools with the scene mutation tools to produce a renovation plan grounded in photos. |
## Limitations
- `export_glb` returns `not_implemented`. GLB export depends on the Three.js
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.
Consumers that need rendered geometry run `@pascal-app/viewer` in a browser
host.
- Core's `loadAssetUrl` / `saveAsset` are browser-only; items that reference
`asset://<id>` URLs aren't resolvable in Node. Supply absolute URLs or
`data:` URLs for item assets if you need them usable outside the browser.
- `dirtyNodes` accumulates in headless mode because no renderer consumes it.
Call `bridge.flushDirty()` if observability matters to your consumer.
## Development
```bash
bun install
bun run --cwd packages/mcp build
bun test
```
Smoke-test the stdio binary end-to-end:
```bash
bun run --cwd packages/mcp smoke
```
## License
MIT
+82
View File
@@ -0,0 +1,82 @@
/**
* Programmatic `@pascal-app/mcp` usage.
*
* Runs a full MCP client/server pair over the in-memory transport inside a
* single Bun process. Useful for agent frameworks and tests that want to
* drive Pascal without spawning a subprocess.
*
* Compile with the package's `tsc --build`, or run directly with Bun:
*
* bun run packages/mcp/examples/embed-in-agent.ts
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createPascalMcpServer, SceneBridge } from '@pascal-app/mcp'
async function main(): Promise<void> {
// 1. Spin up the headless bridge. `loadDefault()` seeds a Site → Building →
// Level stack so the client has something to query immediately.
const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
// 2. Link the server to an in-memory client. Exactly the same API surface
// as the stdio / HTTP transports, but without any process boundary.
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'my-agent', version: '0.1.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
// 3. Discover available capabilities.
const tools = await client.listTools()
console.log(
'available tools:',
tools.tools.map((t) => t.name),
)
// 4. Inspect the current scene.
const scene = await client.callTool({ name: 'get_scene', arguments: {} })
console.log('scene snapshot:', JSON.stringify(scene, null, 2))
// 5. Find the default level, create a 5 m wall, and undo it.
const levels = await client.callTool({
name: 'find_nodes',
arguments: { type: 'level' },
})
const levelId = (levels.structuredContent as { nodes: Array<{ id: string }> }).nodes[0]?.id
if (levelId) {
const created = await client.callTool({
name: 'create_wall',
arguments: {
levelId,
start: [0, 0],
end: [5, 0],
thickness: 0.2,
height: 2.5,
},
})
console.log('created wall:', created.structuredContent)
const undone = await client.callTool({ name: 'undo', arguments: { steps: 1 } })
console.log('undone:', undone.structuredContent)
}
// 6. Validate and export.
const validation = await client.callTool({ name: 'validate_scene', arguments: {} })
console.log('validation:', validation.structuredContent)
const exported = await client.callTool({
name: 'export_json',
arguments: { pretty: true },
})
console.log('export size:', (exported.structuredContent as { json: string }).json.length)
await client.close()
await server.close()
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+242
View File
@@ -0,0 +1,242 @@
# Generate a 2-bed apartment from a brief
This example walks through a realistic session with an MCP host (Claude
Desktop, Claude Code, or Cursor) that has `pascal-mcp` configured. The agent
uses the `from_brief` prompt to turn a short brief into a concrete scene.
## The brief
> **User:** Claude, create a 2-bedroom 1-bath apartment in 80 m² in Spain.
The host UI lets the user select the **`from_brief`** prompt and fills in:
```text
brief: "2-bedroom 1-bath apartment in 80 m² in Spain, open-plan living /
kitchen, bathroom on the interior wall"
constraints: "Spanish building regulations; ceiling height 2.5 m"
```
## What the agent does
The prompt returns a system message instructing the agent to start from an
empty site, read the current scene, and emit incremental `apply_patch` calls.
The agent proceeds roughly like this:
### 1. Inspect the current scene
```jsonc
// tool: get_scene
{ "name": "get_scene", "arguments": {} }
```
Response (trimmed):
```jsonc
{
"nodes": {
"site-1": { "type": "site", "id": "site-1", "children": [/* ... */] },
"building-1": { "type": "building", "id": "building-1", "parentId": "site-1" },
"level-1": { "type": "level", "id": "level-1", "parentId": "building-1",
"elevation": 0, "height": 2.5 }
},
"rootNodeIds": ["site-1"]
}
```
The default scene is a Site → Building → Level stack with no walls. The
agent decides to work on `level-1` and targets a 10 m × 8 m = 80 m² outline.
### 2. Create the perimeter walls
The agent chooses a rectangular outline with its origin at (0, 0):
```jsonc
// tool: apply_patch
{
"name": "apply_patch",
"arguments": {
"patches": [
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [0, 0], "end": [10, 0],
"thickness": 0.2, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [10, 0], "end": [10, 8],
"thickness": 0.2, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [10, 8], "end": [0, 8],
"thickness": 0.2, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [0, 8], "end": [0, 0],
"thickness": 0.2, "height": 2.5 } }
]
}
}
```
Response:
```jsonc
{ "applied": 4 }
```
### 3. Create interior partitions
Two bedrooms on the east side, bathroom on the interior wall, open-plan
living / kitchen on the west.
```jsonc
// tool: apply_patch
{
"name": "apply_patch",
"arguments": {
"patches": [
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [5.5, 0], "end": [5.5, 8],
"thickness": 0.15, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [5.5, 4], "end": [10, 4],
"thickness": 0.15, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [5.5, 5.5], "end": [8, 5.5],
"thickness": 0.15, "height": 2.5 } },
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [8, 4], "end": [8, 5.5],
"thickness": 0.15, "height": 2.5 } }
]
}
}
```
### 4. Define zones
The agent declares the rooms so later queries and item placement can target
them by name:
```jsonc
// tool: set_zone (called once per zone)
{
"name": "set_zone",
"arguments": {
"levelId": "level-1",
"label": "Living / Kitchen",
"polygon": [[0, 0], [5.5, 0], [5.5, 8], [0, 8]]
}
}
// → { "zoneId": "zone-living" }
{
"name": "set_zone",
"arguments": {
"levelId": "level-1",
"label": "Bedroom 1",
"polygon": [[5.5, 0], [10, 0], [10, 4], [5.5, 4]]
}
}
// → { "zoneId": "zone-bed1" }
{
"name": "set_zone",
"arguments": {
"levelId": "level-1",
"label": "Bedroom 2",
"polygon": [[5.5, 5.5], [10, 5.5], [10, 8], [5.5, 8]]
}
}
// → { "zoneId": "zone-bed2" }
{
"name": "set_zone",
"arguments": {
"levelId": "level-1",
"label": "Bathroom",
"polygon": [[5.5, 4], [8, 4], [8, 5.5], [5.5, 5.5]]
}
}
// → { "zoneId": "zone-bath" }
```
### 5. Cut doors and windows
The agent uses `cut_opening` to add entry doors on each interior partition
and windows on the south and east façades:
```jsonc
// tool: cut_opening (called once per opening)
{
"name": "cut_opening",
"arguments": {
"wallId": "wall-south", // perimeter wall [0,0] → [10,0]
"type": "window",
"position": 0.25, // 25% along centerline
"width": 1.2,
"height": 1.2
}
}
// → { "openingId": "window-south-1" }
```
```jsonc
{
"name": "cut_opening",
"arguments": {
"wallId": "wall-bed1", // partition wall to Bedroom 1
"type": "door",
"position": 0.4,
"width": 0.9,
"height": 2.1
}
}
// → { "openingId": "door-bed1" }
```
The agent repeats this for Bedroom 2's door, the bathroom door, and two
more windows on the east façade.
### 6. Validate and report
```jsonc
// tool: validate_scene
{ "name": "validate_scene", "arguments": {} }
```
Response:
```jsonc
{ "valid": true, "errors": [] }
```
The agent then reads the scene summary for its response to the user:
```jsonc
// resource: pascal://scene/current/summary
{ "uri": "pascal://scene/current/summary" }
```
The host displays the returned Markdown: 1 site, 1 building, 1 level, 8
walls, 4 zones, 3 doors, 3 windows; usable area ~78 m²; perimeter ~36 m.
### 7. Iterate
The user follows up:
> **User:** Swap the bathroom and bedroom 2 — I want the bathroom near the
> entrance.
The agent loads the `iterate_on_feedback` prompt and issues a single
`apply_patch` that updates the polygon of `zone-bath` and `zone-bed2` and
moves the corresponding partition walls. Because mutation goes through the
Zustand store, the user can `undo` the change if they dislike it:
```jsonc
{ "name": "undo", "arguments": { "steps": 1 } }
// → { "undone": 1 }
```
## Takeaways
- Mutations batch inside a single `apply_patch` so that `undo` rolls back
the whole logical change.
- Zones are not walls — they're polygon annotations that make later queries
(`find_nodes({ zoneId })`) and planning steps much easier for the agent.
- The agent never needs to speak to `@pascal-app/viewer`: everything the
host sees flows through tools + resources + prompts.
+119
View File
@@ -0,0 +1,119 @@
# Floor-plan photo to Pascal scene
The `photo_to_scene` orchestrator takes a single floor-plan photo and
returns a saved, navigable Pascal scene. It chains vision (via MCP
sampling) → scene build → save in one call, so an agent doesn't have to
stitch three tools together manually.
> **Note:** `photo_to_scene` uses MCP sampling to call the host's model.
> Hosts that do not advertise `sampling` capability will receive a
> structured `sampling_unavailable` error; fall back to the text-only
> `from_brief` prompt in that case.
## The brief
A user drops a photo of a hand-drawn floor plan into the chat and types:
> **User:** here's a floor plan photo, turn it into a Pascal scene.
## The tool call
The agent reads the attachment as a data URI and issues a single tool call:
```jsonc
// tool: photo_to_scene
{
"name": "photo_to_scene",
"arguments": {
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...",
"scaleHint": "1 cm = 1 m, approx 20 m²",
"name": "Weekend flat"
}
}
```
Optional knobs:
- `save` (default `true`) — if `false`, the response includes `graph`
inline instead of persisting to the `SceneStore`.
- `defaultWallThickness` (default `0.2` m) — used when the vision model
doesn't propose a per-wall thickness.
- `defaultWallHeight` (default `2.6` m) — applied to every generated wall
since the vision schema only captures 2D geometry.
## What happens under the hood
1. The orchestrator issues an MCP sampling request to the host with the
image and a structured JSON-only system prompt, mirroring
`analyze_floorplan_image`. The host's model returns walls, rooms, and
approximate dimensions as JSON.
2. The reply is validated against a strict Zod schema. Unparseable or
schema-failing responses surface as `sampling_response_unparseable` /
`sampling_response_invalid` MCP errors.
3. A fresh `SceneGraph` is built using the core schema factories: a
`site``building``level 0` skeleton, then one `WallNode` per
vision wall and one `ZoneNode` per vision room. Each node is
re-parsed with `AnyNode.safeParse`; invalid ones are dropped with a
warning appended to `notes`.
4. `bridge.setScene(...)` swaps the live scene so any follow-up MCP call
(`find_nodes`, `measure`, `apply_patch`, ...) operates on the new
geometry.
5. If `save: true`, the graph is persisted via `SceneStore.save` and the
response carries `sceneId` + `url: /scene/<id>`.
## The response
```jsonc
{
"sceneId": "scene_01hx8a...",
"url": "/scene/scene_01hx8a...",
"walls": 4,
"rooms": 1,
"confidence": 0.82
}
```
When `save: false` instead:
```jsonc
{
"walls": 4,
"rooms": 1,
"confidence": 0.82,
"graph": {
"nodes": { /* flat id node dict */ },
"rootNodeIds": ["site_..."],
"collections": {}
}
}
```
If any wall or room failed schema validation, the response includes a
`notes` string summarising what was dropped.
## Opening the scene
The user follows `url` in their browser:
```
https://your-pascal-host/scene/scene_01hx8a...
```
...and lands in the editor with the new scene loaded, camera auto-framed
on the building footprint.
## Follow-up prompts
Because the bridge now holds the new scene, subsequent agent turns can
operate on it without reloading:
> **User:** add a door on the south wall between Living and Kitchen.
The agent calls `find_nodes({ type: "wall" })`, picks the appropriate
wall, and issues `cut_opening` — no extra wiring needed.
## Takeaways
- `photo_to_scene` is a one-shot primitive: one call, one scene.
- Vision confidence is surfaced so the agent can warn the user.
- v0.1 covers walls + zones; doors, windows, items are follow-up tools.
@@ -0,0 +1,202 @@
# Renovate an existing flat from photos
This example shows how an agent can combine the `renovation_from_photos`
prompt with the `analyze_floorplan_image` and `analyze_room_photo` vision
tools to propose a renovation plan grounded in real photos.
> **Note:** the vision tools use MCP sampling (`createMessage`), which
> Claude Desktop supports today. Hosts without sampling support will get a
> structured `sampling_unavailable` error; fall back to the text-only
> `from_brief` prompt in that case.
## The brief
The user drops four photos into the chat:
1. A floorplan PDF page (exported as PNG).
2. A photo of the current living room.
3. A photo of the current kitchen.
4. An inspirational photo from a magazine — a minimal Scandinavian loft.
And types:
> **User:** Claude, help me plan a renovation. Here's the current plan and
> two room photos. I want something like this Scandinavian reference —
> open-plan, neutral tones, keep the footprint.
## What the agent does
The host loads the **`renovation_from_photos`** prompt:
```text
currentPhotos: ["data:image/png;base64,...", "data:image/jpeg;base64,..."]
referencePhotos: ["data:image/jpeg;base64,..."]
goals: "Open-plan living/kitchen, neutral tones, keep the footprint."
```
The prompt tells the agent to (1) analyze the floorplan, (2) analyze each
room photo, (3) seed a scene from the floorplan, (4) compare against the
reference, and (5) propose patches.
### 1. Extract the floorplan
```jsonc
// tool: analyze_floorplan_image
{
"name": "analyze_floorplan_image",
"arguments": {
"image": "data:image/png;base64,iVBORw0KGgoAAAANS...",
"scaleHint": "1 m grid, total footprint ~9.5 m × 7 m"
}
}
```
Under the hood, the tool issues an MCP sampling request to the host with
the image and a structured prompt asking for walls, rooms, and
approximate dimensions. The response is validated against the tool's
output schema:
```jsonc
{
"walls": [
{ "start": [0, 0], "end": [9.5, 0], "thickness": 0.25 },
{ "start": [9.5, 0], "end": [9.5, 7], "thickness": 0.25 },
{ "start": [9.5, 7], "end": [0, 7], "thickness": 0.25 },
{ "start": [0, 7], "end": [0, 0], "thickness": 0.25 },
{ "start": [4.5, 0], "end": [4.5, 7], "thickness": 0.15 },
{ "start": [4.5, 3.5], "end": [9.5, 3.5], "thickness": 0.15 }
],
"rooms": [
{ "label": "Living", "polygon": [[0, 0], [4.5, 0], [4.5, 7], [0, 7]] },
{ "label": "Kitchen", "polygon": [[4.5, 0], [9.5, 0], [9.5, 3.5], [4.5, 3.5]] },
{ "label": "Bedroom", "polygon": [[4.5, 3.5], [9.5, 3.5], [9.5, 7], [4.5, 7]] }
],
"approximateDimensions": { "widthMeters": 9.5, "depthMeters": 7, "areaSqMeters": 66.5 },
"confidence": 0.82
}
```
### 2. Analyze the room photos
```jsonc
// tool: analyze_room_photo
{
"name": "analyze_room_photo",
"arguments": { "image": "data:image/jpeg;base64,/9j/4AAQ..." }
}
```
Response:
```jsonc
{
"approximateDimensions": { "widthMeters": 4.4, "depthMeters": 5.8, "heightMeters": 2.5 },
"identifiedFixtures": [
{ "kind": "sofa", "approximatePosition": [2.2, 3.5] },
{ "kind": "coffee-table", "approximatePosition": [2.2, 2.4] },
{ "kind": "tv-unit", "approximatePosition": [0.3, 2.0] }
],
"identifiedWindows": [
{ "wallHint": "south", "approximateWidth": 1.4, "approximateHeight": 1.5 }
]
}
```
The kitchen photo is analyzed the same way.
### 3. Seed the scene
The agent reads `get_scene`, confirms the default empty Site → Building →
Level is present, and then batch-creates walls matching the floorplan:
```jsonc
// tool: apply_patch
{
"name": "apply_patch",
"arguments": {
"patches": [
{ "op": "create", "parentId": "level-1",
"node": { "type": "wall", "start": [0, 0], "end": [9.5, 0],
"thickness": 0.25, "height": 2.5 } },
/* ...remaining perimeter + partition walls from the vision result... */
]
}
}
```
The agent then calls `set_zone` three times to seed the Living / Kitchen /
Bedroom polygons from the floorplan rooms.
### 4. Cut the identified openings
For each window the vision tool reported, the agent calls `cut_opening`
against the corresponding perimeter wall:
```jsonc
{
"name": "cut_opening",
"arguments": {
"wallId": "wall-south",
"type": "window",
"position": 0.5,
"width": 1.4,
"height": 1.5
}
}
```
### 5. Propose the renovation
Guided by the reference photo's analysis (bright neutrals, open plan,
minimal furnishing), the agent proposes a single logical patch:
- Remove the partition wall between Living and Kitchen.
- Relocate the kitchen island further west.
- Delete the bulky TV unit item; leave the sofa and coffee table.
- Re-label the merged zone `"Open-Plan Living / Kitchen"`.
All of that goes into one `apply_patch`:
```jsonc
{
"name": "apply_patch",
"arguments": {
"patches": [
{ "op": "delete", "id": "wall-partition-living-kitchen", "cascade": false },
{ "op": "update", "id": "zone-living", "data": { "label": "Open-Plan Living / Kitchen",
"polygon": [[0, 0], [9.5, 0],
[9.5, 3.5], [0, 3.5]] } },
{ "op": "delete", "id": "zone-kitchen", "cascade": false }
/* + item moves / deletes for the TV unit etc. */
]
}
}
```
The user can walk back with `undo`; `redo` returns them to the proposal.
### 6. Sanity-check
```jsonc
// tool: validate_scene
{ "name": "validate_scene", "arguments": {} }
// → { "valid": true, "errors": [] }
// tool: check_collisions
{ "name": "check_collisions", "arguments": { "levelId": "level-1" } }
// → { "collisions": [] }
```
The agent reports a summary of the changes plus the approximate new
usable area (from the summary resource), and the user opens the scene in
`@pascal-app/viewer` to see the renovated 3D layout.
## Takeaways
- The vision tools only return **data**. They don't mutate the scene —
the agent is explicit about every structural change via `apply_patch`.
- Photos supply priors (approximate dimensions, fixture types) that a
brief-only workflow can't. Combine them with `from_brief`-style
prompts when the user has both a reference and concrete text goals.
- All renovation steps are a single temporal step per patch, so the user
can compare before/after with `undo` / `redo`.
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@pascal-app/mcp",
"version": "0.1.0",
"description": "Model Context Protocol server for Pascal 3D editor",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js",
"default": "./dist/storage/index.js"
},
"./operations": {
"types": "./dist/operations/index.d.ts",
"import": "./dist/operations/index.js",
"default": "./dist/operations/index.js"
}
},
"bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"start": "bun dist/bin/pascal-mcp.js",
"test": "bun test",
"smoke": "bun run scripts/smoke.ts",
"prepublishOnly": "bun run build && bun test"
},
"peerDependencies": {
"@pascal-app/core": "workspace:*"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@pascal-app/core": "workspace:*",
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"typescript": "5.9.3"
},
"keywords": [
"mcp",
"model-context-protocol",
"pascal",
"3d",
"building",
"editor",
"ai"
],
"repository": {
"type": "git",
"url": "https://github.com/pascalorg/editor.git",
"directory": "packages/mcp"
},
"license": "MIT",
"homepage": "https://github.com/pascalorg/editor/tree/main/packages/mcp#readme",
"bugs": "https://github.com/pascalorg/editor/issues"
}
+81
View File
@@ -0,0 +1,81 @@
/**
* End-to-end smoke test for @pascal-app/mcp.
*
* Spawns the compiled stdio binary as a child process, connects as an MCP
* client, and exercises a handful of representative tools. This test requires
* the package to be built first (`bun run build`) — the compiled bin is what
* `package.json`'s `bin` entry ships to users.
*
* Run with: bun run scripts/smoke.ts
*/
import { existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const BIN_PATH = resolve(__dirname, '../dist/bin/pascal-mcp.js')
async function main(): Promise<void> {
if (!existsSync(BIN_PATH)) {
console.error(`[smoke] bin not found at ${BIN_PATH}`)
console.error('[smoke] run `bun run build` first')
process.exit(1)
}
const transport = new StdioClientTransport({
command: process.execPath,
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-smoke', version: '0.0.0' })
try {
await client.connect(transport)
const tools = await client.listTools()
console.log(`[smoke] tools registered: ${tools.tools.length}`)
if (tools.tools.length === 0) {
throw new Error('no tools registered')
}
const getScene = await client.callTool({ name: 'get_scene', arguments: {} })
if (getScene.isError) {
throw new Error(`get_scene failed: ${JSON.stringify(getScene)}`)
}
console.log('[smoke] get_scene: OK')
// create_level — buildingId may not match a real node depending on the
// default scene; we just verify the tool returns a structured response
// rather than crash.
const createLevel = await client.callTool({
name: 'create_level',
arguments: { buildingId: 'tbd', elevation: 1, height: 3 },
})
console.log('[smoke] create_level:', createLevel.isError ? 'structured error (ok)' : 'OK')
const validate = await client.callTool({
name: 'validate_scene',
arguments: {},
})
console.log('[smoke] validate_scene:', validate.isError ? 'ERROR' : 'OK')
const undone = await client.callTool({ name: 'undo', arguments: {} })
console.log('[smoke] undo:', undone.isError ? 'ERROR' : 'OK')
console.log('[smoke] passed')
} finally {
try {
await client.close()
} catch {
// client may already be closed; ignore.
}
}
}
main().catch((err) => {
console.error('[smoke] failed:', err)
process.exit(1)
})
+125
View File
@@ -0,0 +1,125 @@
/**
* Phase 0.5 bridge spike — prove that @pascal-app/core's useScene works in Node
* after a RAF polyfill. Run with: bun run packages/mcp/scripts/spike.ts
*/
// Polyfill BEFORE importing core.
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
;(globalThis as any).requestAnimationFrame = (cb: (t: number) => void): number => {
return setTimeout(() => cb(performance.now()), 0) as unknown as number
}
;(globalThis as any).cancelAnimationFrame = (id: number) => {
clearTimeout(id as unknown as NodeJS.Timeout)
}
}
import { WallNode } from '@pascal-app/core/schema'
import useScene from '@pascal-app/core/store'
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(`ASSERT FAILED: ${msg}`)
}
async function main() {
console.log('---- Phase 0.5 bridge spike ----')
// 1. Load default scene
useScene.getState().loadScene()
const state1 = useScene.getState()
assert(state1.rootNodeIds.length === 1, 'expected 1 root')
// NOTE: SiteNode.children holds node objects (not IDs); everything else uses ID arrays.
// Resolve building/level via the flat nodes dict, filtering by type.
const allNodes = Object.values(state1.nodes)
const building = allNodes.find((n) => n.type === 'building')
const level = allNodes.find((n) => n.type === 'level')
assert(building, 'expected building node in dict')
assert(level, 'expected level node in dict')
const levelId = level.id
console.log('OK 1: default scene loaded —', Object.keys(state1.nodes).length, 'nodes')
// 2. Clear temporal history so we measure our own undo steps
useScene.temporal.getState().clear()
// 3. Create a wall via WallNode.parse
const wall = WallNode.parse({
start: [0, 0],
end: [5, 0],
})
useScene.getState().createNode(wall, levelId as any)
const state2 = useScene.getState()
assert(wall.id in state2.nodes, 'wall not created')
const levelAfter = state2.nodes[levelId]!
assert(
'children' in levelAfter &&
Array.isArray(levelAfter.children) &&
levelAfter.children.includes(wall.id),
'wall not linked as level child',
)
console.log('OK 2: created wall', wall.id)
// 4. Wait for any RAF-queued dirty markings (from updateNodesAction polyfill)
await new Promise((r) => setTimeout(r, 5))
// 5. Update wall
useScene.getState().updateNode(wall.id, { thickness: 0.25, height: 3.0 })
await new Promise((r) => setTimeout(r, 5))
const state3 = useScene.getState()
const w3 = state3.nodes[wall.id] as any
assert(w3.thickness === 0.25, 'thickness not updated')
assert(w3.height === 3.0, 'height not updated')
console.log('OK 3: updated wall thickness + height')
// 6. Undo (thickness/height revert)
useScene.temporal.getState().undo()
await new Promise((r) => setTimeout(r, 5))
const state4 = useScene.getState()
const w4 = state4.nodes[wall.id] as any
console.log(' after undo: thickness =', w4?.thickness, 'height =', w4?.height)
assert(w4, 'wall still exists after 1 undo (update was undone)')
// default thickness/height come from schema defaults, not required to be exact values; just prove they changed back
assert(w4.thickness !== 0.25 || w4.height !== 3.0, 'undo did not revert update')
console.log('OK 4: undo reverted update')
// 7. Undo again (wall creation reverted)
useScene.temporal.getState().undo()
await new Promise((r) => setTimeout(r, 5))
const state5 = useScene.getState()
assert(!(wall.id in state5.nodes), 'wall was not removed by second undo')
console.log('OK 5: undo removed the wall')
// 8. Redo twice (wall comes back with the updated props)
useScene.temporal.getState().redo(2)
await new Promise((r) => setTimeout(r, 5))
const state6 = useScene.getState()
const w6 = state6.nodes[wall.id] as any
assert(w6, 'redo did not restore wall')
assert(w6.thickness === 0.25 && w6.height === 3.0, 'redo did not restore updated props')
console.log('OK 6: redo restored wall + update')
// 9. Delete wall
useScene.getState().deleteNode(wall.id)
const state7 = useScene.getState()
assert(!(wall.id in state7.nodes), 'wall was not deleted')
console.log('OK 7: delete removed wall')
// 10. setScene round-trip
const snapshot = {
nodes: { ...state7.nodes },
rootNodeIds: [...state7.rootNodeIds],
}
useScene.getState().unloadScene()
useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds)
const state8 = useScene.getState()
assert(
Object.keys(state8.nodes).length === Object.keys(snapshot.nodes).length,
'setScene node-count mismatch',
)
console.log('OK 8: setScene round-trip preserved node count')
console.log('\n✅ BRIDGE SPIKE PASSED — headless useScene is viable with RAF polyfill\n')
}
main().catch((err) => {
console.error('\n❌ SPIKE FAILED:', err)
process.exit(1)
})
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bun
// Load shims FIRST so any subsequent core import sees the RAF polyfill.
import '../bridge/node-shims'
import { readFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { SceneBridge } from '../bridge/scene-bridge'
import { version } from '../index'
import { createPascalMcpServer } from '../server'
import { connectHttp } from '../transports/http'
import { connectStdio } from '../transports/stdio'
const HELP = `pascal-mcp — MCP server for the Pascal editor
USAGE:
pascal-mcp [--stdio | --http --port <n>] [--scene <path>]
OPTIONS:
--stdio Use stdio transport (default)
--http Use Streamable HTTP transport
--port <n> HTTP port (default 3917)
--host <host> HTTP bind host (default 127.0.0.1)
--auth-token <t> Bearer token required for HTTP calls
--cors-origin <o> Repeatable allowed HTTP CORS origin
--scene <path> Initial scene JSON to load
--version Print version
--help Print this help
`
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
stdio: { type: 'boolean', default: false },
http: { type: 'boolean', default: false },
port: { type: 'string', default: '3917' },
host: { type: 'string', default: '127.0.0.1' },
'auth-token': { type: 'string' },
'cors-origin': { type: 'string', multiple: true, default: [] },
scene: { type: 'string' },
help: { type: 'boolean', default: false },
version: { type: 'boolean', default: false },
},
})
if (values.help) {
console.log(HELP)
process.exit(0)
}
if (values.version) {
console.log(version)
process.exit(0)
}
const bridge = new SceneBridge()
if (values.scene) {
const raw = readFileSync(values.scene, 'utf8')
bridge.loadJSON(raw)
} else {
bridge.loadDefault()
}
const server = createPascalMcpServer({ bridge })
if (values.http) {
const portNum = Number.parseInt(values.port ?? '3917', 10)
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
throw new Error(`invalid --port value: ${values.port}`)
}
const handle = await connectHttp(server, portNum, {
host: values.host,
authToken: values['auth-token'],
allowedOrigins: values['cors-origin'],
})
console.error(`[pascal-mcp] HTTP server listening on ${handle.host}:${handle.port}`)
const shutdown = async () => {
try {
await handle.close()
} finally {
process.exit(0)
}
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
} else {
// --stdio is the default when no transport flag is passed.
await connectStdio(server)
console.error('[pascal-mcp] stdio server running')
}
}
main().catch((err) => {
console.error('[pascal-mcp] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(1)
})
+33
View File
@@ -0,0 +1,33 @@
/**
* Node-compatibility shims for `@pascal-app/core`.
*
* The core store uses `requestAnimationFrame` inside `updateNodesAction` (to batch
* dirty-marking) and inside the temporal undo/redo subscribe callback. Both are
* load-reachable — the subscribe callback registers at module import time.
*
* This file installs a no-op-if-already-defined polyfill that works both in
* Node and in the browser. It MUST be imported FIRST from any module that
* transitively loads `@pascal-app/core/store`, otherwise the core module will
* throw at import time.
*
* Side-effectful on import: there is no exported API — just import this file.
*/
type RafCallback = (timestamp: number) => void
type GlobalWithRaf = typeof globalThis & {
requestAnimationFrame?: (cb: RafCallback) => number
cancelAnimationFrame?: (id: number) => void
}
const g = globalThis as GlobalWithRaf
if (typeof g.requestAnimationFrame === 'undefined') {
g.requestAnimationFrame = (cb: RafCallback): number => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
return setTimeout(() => cb(now), 0) as unknown as number
}
g.cancelAnimationFrame = (id: number) => {
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
}
}
@@ -0,0 +1,548 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
BuildingNode,
DoorNode,
ItemNode,
LevelNode,
SiteNode,
WallNode,
ZoneNode,
} from '@pascal-app/core/schema'
import { SceneBridge } from './scene-bridge'
function tick() {
return new Promise((r) => setTimeout(r, 5))
}
describe('SceneBridge', () => {
let bridge: SceneBridge
beforeEach(() => {
bridge = new SceneBridge()
// Ensure a clean slate even if a prior test left store state around
// (the core store is a module-singleton).
bridge.setScene({}, [])
bridge.clearHistory()
bridge.loadDefault()
bridge.clearHistory()
bridge.flushDirty()
})
describe('loadDefault / getters', () => {
test('creates default Site → Building → Level', () => {
const nodes = bridge.getNodes()
const types = Object.values(nodes)
.map((n) => n.type)
.sort()
expect(types).toEqual(['building', 'level', 'site'])
expect(bridge.getRootNodeIds().length).toBe(1)
})
test('loadDefault is idempotent when scene already loaded', () => {
const before = Object.keys(bridge.getNodes()).length
bridge.loadDefault()
const after = Object.keys(bridge.getNodes()).length
expect(after).toBe(before)
})
test('getNode returns the node by id', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const fetched = bridge.getNode(level.id)
expect(fetched?.id).toBe(level.id)
})
test('getNode returns null for unknown id', () => {
expect(bridge.getNode('wall_does_not_exist')).toBeNull()
})
})
describe('createNode', () => {
test('creates a wall attached to a level', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
const id = bridge.createNode(wall, level.id)
expect(id).toBe(wall.id)
expect(bridge.getNode(wall.id)).not.toBeNull()
// Level should list the wall as a child.
const freshLevel = bridge.getNode(level.id) as any
expect(freshLevel.children).toContain(wall.id)
})
test('created wall has the correct parentId', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const w = bridge.getNode(wall.id)!
expect(w.parentId).toBe(level.id)
})
})
describe('updateNode', () => {
test('merges new fields on existing node', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
bridge.updateNode(wall.id, { thickness: 0.25, height: 3 } as any)
await tick()
const w = bridge.getNode(wall.id) as any
expect(w.thickness).toBe(0.25)
expect(w.height).toBe(3)
})
test('throws on unknown id', () => {
expect(() => bridge.updateNode('wall_missing' as any, { height: 3 } as any)).toThrow(
/node not found/,
)
})
})
describe('deleteNode', () => {
test('deletes a leaf node', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const removed = bridge.deleteNode(wall.id)
expect(removed).toContain(wall.id)
expect(bridge.getNode(wall.id)).toBeNull()
})
test('cascade=false throws if node has children', () => {
// Level (with a child wall) — deleting non-cascaded must throw.
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
expect(() => bridge.deleteNode(level.id, false)).toThrow(/descendant/)
// Node still exists.
expect(bridge.getNode(level.id)).not.toBeNull()
expect(bridge.getNode(wall.id)).not.toBeNull()
})
test('cascade=true removes node and all descendants', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall1 = WallNode.parse({ start: [0, 0], end: [1, 0] })
const wall2 = WallNode.parse({ start: [1, 0], end: [1, 1] })
bridge.createNode(wall1, level.id)
bridge.createNode(wall2, level.id)
const removed = bridge.deleteNode(level.id, true)
expect(removed).toContain(level.id)
expect(removed).toContain(wall1.id)
expect(removed).toContain(wall2.id)
expect(bridge.getNode(level.id)).toBeNull()
expect(bridge.getNode(wall1.id)).toBeNull()
})
test('throws on unknown id', () => {
expect(() => bridge.deleteNode('wall_nope' as any, false)).toThrow(/node not found/)
})
})
describe('undo / redo', () => {
test('round-trips create + update', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
await tick()
bridge.updateNode(wall.id, { thickness: 0.25 } as any)
await tick()
// Undo update
const u1 = bridge.undo()
await tick()
expect(u1).toBe(1)
const w1 = bridge.getNode(wall.id) as any
expect(w1).not.toBeNull()
expect(w1.thickness).not.toBe(0.25)
// Undo create — wall should be gone
const u2 = bridge.undo()
await tick()
expect(u2).toBe(1)
expect(bridge.getNode(wall.id)).toBeNull()
// Redo both
const r = bridge.redo(2)
await tick()
expect(r).toBe(2)
const w3 = bridge.getNode(wall.id) as any
expect(w3).not.toBeNull()
expect(w3.thickness).toBe(0.25)
})
test('getHistory tracks pointers', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
let h = bridge.getHistory()
expect(h.pastCount).toBe(1)
expect(h.futureCount).toBe(0)
bridge.undo()
await tick()
h = bridge.getHistory()
expect(h.pastCount).toBe(0)
expect(h.futureCount).toBe(1)
})
test('clearHistory wipes past/future', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
await tick()
bridge.clearHistory()
const h = bridge.getHistory()
expect(h.pastCount).toBe(0)
expect(h.futureCount).toBe(0)
})
test('undo/redo without history returns 0', () => {
bridge.clearHistory()
expect(bridge.undo()).toBe(0)
expect(bridge.redo()).toBe(0)
})
})
describe('applyPatch', () => {
test('applies mixed create/update/delete atomically', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] })
const wallB = WallNode.parse({ start: [2, 0], end: [2, 2] })
// pre-seed one wall, then exercise update + delete
bridge.createNode(wallA, level.id)
await tick()
const res = bridge.applyPatch([
{ op: 'create', node: wallB, parentId: level.id },
{ op: 'update', id: wallA.id, data: { thickness: 0.3 } as any },
{ op: 'delete', id: wallA.id },
])
await tick()
expect(res.appliedOps).toBe(3)
expect(res.createdIds).toContain(wallB.id)
expect(res.deletedIds).toContain(wallA.id)
expect(bridge.getNode(wallA.id)).toBeNull()
expect(bridge.getNode(wallB.id)).not.toBeNull()
})
test('is all-or-nothing: invalid op rolls back no changes', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const pre = Object.keys(bridge.getNodes()).length
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
expect(() =>
bridge.applyPatch([
{ op: 'create', node: wall, parentId: level.id },
// This op is invalid — id does not exist.
{ op: 'update', id: 'wall_missing' as any, data: { thickness: 0.1 } as any },
]),
).toThrow(/invalid patch/)
// The wall must NOT have been created.
expect(bridge.getNode(wall.id)).toBeNull()
// Node count is unchanged.
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
})
test('rejects create with non-existent parentId', () => {
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
expect(() =>
bridge.applyPatch([{ op: 'create', node: wall, parentId: 'level_nope' as any }]),
).toThrow(/invalid patch/)
})
test('rejects delete of unknown id', () => {
expect(() => bridge.applyPatch([{ op: 'delete', id: 'wall_nope' as any }])).toThrow(
/invalid patch/,
)
})
test('rejects delete with cascade=false on a node with children', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
bridge.createNode(WallNode.parse({ start: [0, 0], end: [1, 0] }), level.id)
await tick()
expect(() => bridge.applyPatch([{ op: 'delete', id: level.id, cascade: false }])).toThrow(
/invalid patch/,
)
})
test('accepts delete with cascade=true on a node with children', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
const res = bridge.applyPatch([{ op: 'delete', id: level.id, cascade: true }])
expect(res.deletedIds).toContain(level.id)
expect(res.deletedIds).toContain(wall.id)
})
test('rejects create with schema-invalid node', () => {
// Bypass .parse so we can feed an invalid node through the union.
const bogus = {
object: 'node',
id: 'wall_bogus',
type: 'wall',
// missing start/end
} as any
expect(() => bridge.applyPatch([{ op: 'create', node: bogus }])).toThrow(/invalid patch/)
})
test('rejects unknown op', () => {
expect(() => bridge.applyPatch([{ op: 'wat', id: 'x' } as any])).toThrow(/invalid patch/)
})
test('rejects update with non-object data', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
expect(() => bridge.applyPatch([{ op: 'update', id: level.id, data: null as any }])).toThrow(
/invalid patch/,
)
})
test('rejects undefined patch entry', () => {
expect(() => bridge.applyPatch([undefined as any])).toThrow(/invalid patch/)
})
})
describe('validateScene', () => {
test('returns valid for default scene', () => {
const res = bridge.validateScene()
expect(res.valid).toBe(true)
expect(res.errors).toEqual([])
})
test('flags bad nodes fed in via setScene', () => {
const site = SiteNode.parse({})
// Bypass the schema by constructing a bogus wall object directly.
const bogus = {
object: 'node',
id: 'wall_bogus',
type: 'wall',
parentId: site.id,
// missing required `start`/`end`
} as any
bridge.setScene({ [site.id]: site, [bogus.id]: bogus }, [site.id])
const res = bridge.validateScene()
expect(res.valid).toBe(false)
expect(res.errors.some((e) => e.nodeId === 'wall_bogus')).toBe(true)
})
})
describe('traversal: site quirk & generic helpers', () => {
test('getChildren uses the flat dict (handles site children-as-objects)', () => {
const site = bridge.findNodes({ type: 'site' })[0]!
const children = bridge.getChildren(site.id)
// Building is the expected child of site via parentId.
const types = children.map((c) => c.type).sort()
expect(types).toContain('building')
})
test('getChildren works for level (children-as-ids)', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const children = bridge.getChildren(level.id)
expect(children.map((c) => c.id)).toContain(wall.id)
})
test('getAncestry walks to root', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const ancestry = bridge.getAncestry(wall.id)
const types = ancestry.map((n) => n.type)
expect(types[0]).toBe('wall')
expect(types).toContain('level')
expect(types).toContain('building')
expect(types).toContain('site')
})
test('getAncestry returns [] for unknown id', () => {
expect(bridge.getAncestry('wall_nope' as any)).toEqual([])
})
test('resolveLevelId returns the enclosing level', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
expect(bridge.resolveLevelId(wall.id)).toBe(level.id)
})
test('resolveLevelId returns null if no level ancestor', () => {
// Site itself has no level ancestor.
const site = bridge.findNodes({ type: 'site' })[0]!
expect(bridge.resolveLevelId(site.id)).toBeNull()
})
test('findNodes filters by type', () => {
const levels = bridge.findNodes({ type: 'level' })
expect(levels.length).toBe(1)
expect(levels[0]?.type).toBe('level')
})
test('findNodes filters by parentId', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const childrenOfLevel = bridge.findNodes({ parentId: level.id })
expect(childrenOfLevel.map((n) => n.id)).toContain(wall.id)
})
test('findNodes filters by levelId (via ancestry)', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
const door = DoorNode.parse({ wallId: wall.id })
bridge.createNode(door, wall.id)
// Door lives under wall→level; findNodes with levelId should match.
const filtered = bridge.findNodes({ type: 'door', levelId: level.id })
expect(filtered.map((n) => n.id)).toContain(door.id)
})
test('findNodes with parentId: null finds roots', () => {
const roots = bridge.findNodes({ parentId: null })
expect(roots.map((n) => n.type)).toContain('site')
})
})
describe('setScene / exportJSON / loadJSON', () => {
test('exportJSON returns the scene shape', () => {
const exp = bridge.exportJSON()
expect(typeof exp.nodes).toBe('object')
expect(Array.isArray(exp.rootNodeIds)).toBe(true)
expect(exp.rootNodeIds.length).toBe(1)
})
test('exportJSON deep-clones (mutation does not leak back)', () => {
const exp = bridge.exportJSON()
const someId = Object.keys(exp.nodes)[0]!
;(exp.nodes as any)[someId] = 'tampered'
// Store is unchanged.
expect(typeof bridge.getNodes()[someId]).toBe('object')
})
test('loadJSON accepts a parsed object', () => {
const snap = bridge.exportJSON()
// Unload first so loadJSON does the heavy lift.
bridge.setScene({}, [])
bridge.loadJSON(snap)
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
})
test('loadJSON accepts a JSON string', () => {
const snap = bridge.exportJSON()
const str = JSON.stringify(snap)
bridge.setScene({}, [])
bridge.loadJSON(str)
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
})
test('loadJSON throws on malformed JSON string', () => {
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
})
test('loadJSON throws when parsed JSON is not an object', () => {
expect(() => bridge.loadJSON('null')).toThrow(/expected object/)
expect(() => bridge.loadJSON(null as any)).toThrow(/expected object/)
})
test('loadJSON throws on wrong top-level shape', () => {
expect(() => bridge.loadJSON({} as any)).toThrow(/invalid scene/)
expect(() => bridge.loadJSON({ nodes: 1, rootNodeIds: [] } as any)).toThrow(/invalid scene/)
expect(() => bridge.loadJSON({ nodes: {}, rootNodeIds: 'nope' } as any)).toThrow(
/invalid scene/,
)
})
test('loadJSON rejects prototype-polluting keys in string form', () => {
const bad = '{"nodes": {"__proto__": {"polluted": true}}, "rootNodeIds": []}'
expect(() => bridge.loadJSON(bad)).toThrow(/forbidden key/)
})
test('loadJSON rejects prototype-polluting keys in object form', () => {
// Build object so the key is an actual own-property (not a prototype
// assignment).
const nodes: Record<string, unknown> = {}
Object.defineProperty(nodes, '__proto__', {
enumerable: true,
configurable: true,
writable: true,
value: { polluted: true },
})
const bad = { nodes, rootNodeIds: [] }
expect(() => bridge.loadJSON(bad as any)).toThrow(/forbidden key/)
})
test('setScene round-trip preserves node count', () => {
const pre = Object.keys(bridge.getNodes()).length
const snap = bridge.exportJSON()
bridge.setScene({}, [])
bridge.setScene(snap.nodes as any, snap.rootNodeIds as any)
expect(Object.keys(bridge.getNodes()).length).toBe(pre)
})
})
describe('flushDirty', () => {
test('drains the dirty set', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await tick()
const drained = bridge.flushDirty()
// Wall was just created, should have dirty-marked itself + parent.
expect(drained.length).toBeGreaterThan(0)
// Calling again drains nothing new.
const again = bridge.flushDirty()
expect(again.length).toBe(0)
})
})
describe('composite nodes', () => {
test('can build a small scene via LevelNode/BuildingNode helpers', () => {
// Construct a second site via explicit schema parse to exercise
// exportJSON/setScene on custom shapes.
const level = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level.id] })
const site = SiteNode.parse({ children: [] })
bridge.setScene(
{
[site.id]: { ...site, children: [] } as any,
[building.id]: { ...building, parentId: site.id } as any,
[level.id]: { ...level, parentId: building.id } as any,
},
[site.id],
)
expect(bridge.getNodes()[site.id]).toBeDefined()
expect(bridge.resolveLevelId(level.id)).toBe(level.id)
})
test('zone and item nodes are creatable and discoverable', () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const zone = ZoneNode.parse({
name: 'Zone A',
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
})
bridge.createNode(zone, level.id)
const item = ItemNode.parse({
asset: {
id: 'asset_test',
category: 'test',
name: 'Test Asset',
thumbnail: 'data:image/png;base64,',
// AssetUrl validator (asset-url.ts) only allows asset://, blob:,
// data:image/, /path, or https://; `data:model/gltf-binary` is not
// in the allowlist, so this test uses an internal asset handle.
src: 'asset://test/chair.glb',
},
})
// Place item directly on level — ItemNode supports arbitrary parents in the model.
bridge.createNode(item, level.id)
const zones = bridge.findNodes({ type: 'zone' })
const items = bridge.findNodes({ type: 'item' })
expect(zones.map((n) => n.id)).toContain(zone.id)
expect(items.map((n) => n.id)).toContain(item.id)
})
})
})
+537
View File
@@ -0,0 +1,537 @@
// Side-effect import MUST come first: installs RAF polyfill before core loads.
import './node-shims'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
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[] }
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.
*
* All mutation flows through the real core store so undo/redo works via Zundo.
* No renderer is attached; `dirtyNodes` accumulates and can be drained via
* `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()
}
/** Replace entire scene (undoable via Zundo). */
setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void {
useScene.getState().setScene(nodes, rootNodeIds)
}
/** Full snapshot for export, including collections. */
exportJSON(): SceneGraph & { collections: Record<string, unknown> } {
const state = useScene.getState()
// Deep-clone so callers can't mutate store state directly.
return JSON.parse(
JSON.stringify({
nodes: state.nodes,
rootNodeIds: state.rootNodeIds,
collections: state.collections ?? {},
}),
)
}
/**
* Import. Accepts either a JSON string or a parsed SceneGraph object.
* Throws on invalid JSON, unexpected shape, or prototype-polluting keys.
*/
loadJSON(json: string | SceneGraph): void {
let parsed: unknown
if (typeof json === 'string') {
try {
parsed = JSON.parse(json)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`invalid JSON: ${msg}`)
}
} else {
parsed = json
}
if (!parsed || typeof parsed !== 'object') {
throw new Error('invalid scene: expected object with {nodes, rootNodeIds}')
}
const obj = parsed as Record<string, unknown>
const nodes = obj.nodes
const rootNodeIds = obj.rootNodeIds
if (!nodes || typeof nodes !== 'object' || Array.isArray(nodes)) {
throw new Error('invalid scene: `nodes` must be an object')
}
if (!Array.isArray(rootNodeIds)) {
throw new Error('invalid scene: `rootNodeIds` must be an array')
}
// Reject prototype-polluting keys as top-level `nodes` keys.
const BANNED = new Set(['__proto__', 'constructor', 'prototype'])
for (const key of Object.keys(nodes)) {
if (BANNED.has(key)) {
throw new Error(`invalid scene: forbidden key "${key}" in nodes`)
}
}
this.setScene(nodes as Record<AnyNodeId, AnyNode>, rootNodeIds as AnyNodeId[])
}
/** Read a single node, or `null` if not present. */
getNode(id: AnyNodeId): AnyNode | null {
const node = useScene.getState().nodes[id]
return node ?? null
}
/** All nodes (live reference into the store — do NOT mutate). */
getNodes(): Record<AnyNodeId, AnyNode> {
return useScene.getState().nodes
}
/** Root node IDs. */
getRootNodeIds(): AnyNodeId[] {
return useScene.getState().rootNodeIds
}
/**
* Resolve children via the flat `nodes` dict. Uses THREE fallbacks because
* the codebase's parent-tracking is not uniform:
*
* 1. `node.parentId === parentId` (normal case post-store-mutation).
* 2. Parent has `children: string[]` of IDs (building, level, wall, ...).
* 3. Parent has `children: Array<node-object>` (the SiteNode quirk — see
* PLAN §0.7). We resolve each object to its flat-dict entry by `id`.
*
* The `loadScene()` default assembler skips the store mutation paths so the
* default site/building/level tree has `parentId === null` on every node —
* only the `children` arrays reflect the hierarchy.
*
* Results are de-duplicated by id, in flat-dict iteration order.
*/
getChildren(parentId: AnyNodeId): AnyNode[] {
const nodes = useScene.getState().nodes
const out: AnyNode[] = []
const seen = new Set<AnyNodeId>()
// Strategy 1: parentId scan.
for (const node of Object.values(nodes)) {
if (node.parentId === parentId && !seen.has(node.id as AnyNodeId)) {
seen.add(node.id as AnyNodeId)
out.push(node)
}
}
// Strategies 2 & 3: parent's own `children` field.
const parent = nodes[parentId]
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const child of parent.children as unknown[]) {
let childId: string | null = null
if (typeof child === 'string') childId = child
else if (
child &&
typeof child === 'object' &&
'id' in (child as Record<string, unknown>) &&
typeof (child as { id: unknown }).id === 'string'
) {
childId = (child as { id: string }).id
}
if (!childId) continue
const childNode = nodes[childId as AnyNodeId]
if (!childNode) continue
if (seen.has(childNode.id as AnyNodeId)) continue
seen.add(childNode.id as AnyNodeId)
out.push(childNode)
}
}
return out
}
/**
* Walk up `parentId` chain; returns `[self, parent, grandparent, ...]`.
*
* Falls back to reverse-scanning `children` arrays when `parentId` is
* unset (see the default-scene quirk documented on `getChildren`).
*/
getAncestry(id: AnyNodeId): AnyNode[] {
const nodes = useScene.getState().nodes
const out: AnyNode[] = []
let current: AnyNode | undefined = nodes[id]
const seen = new Set<AnyNodeId>()
while (current && !seen.has(current.id)) {
seen.add(current.id)
out.push(current)
const pid = current.parentId as AnyNodeId | null | undefined
if (pid && nodes[pid]) {
current = nodes[pid]
continue
}
// Fallback: scan for any node whose `children` includes this id.
const fallback = this._findParentByChildrenScan(current.id as AnyNodeId)
if (!fallback) break
current = fallback
}
return out
}
/** Find all nodes matching the given filters (all filters ANDed). */
findNodes(filter: {
type?: AnyNodeType
parentId?: AnyNodeId | null
levelId?: AnyNodeId
}): AnyNode[] {
const nodes = useScene.getState().nodes
const out: AnyNode[] = []
for (const node of Object.values(nodes)) {
if (filter.type !== undefined && node.type !== filter.type) continue
if (filter.parentId !== undefined) {
const np = (node.parentId ?? null) as AnyNodeId | null
if (np !== filter.parentId) continue
}
if (filter.levelId !== undefined) {
if (this.resolveLevelId(node.id as AnyNodeId) !== filter.levelId) continue
}
out.push(node)
}
return out
}
/** Resolve the level-ancestor of a node, or `null` if none in the chain. */
resolveLevelId(id: AnyNodeId): AnyNodeId | null {
const ancestry = this.getAncestry(id)
for (const node of ancestry) {
if (node.type === 'level') return node.id as AnyNodeId
}
return null
}
/**
* Create a node. Caller must pass an already-parsed `AnyNode` (with a valid
* `id`, generated by the schema default if they did `XxxNode.parse({...})`).
* Returns the generated id.
*/
createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId {
useScene.getState().createNode(node, parentId)
return node.id as AnyNodeId
}
/** Update node fields (shallow merge through the core store). */
updateNode(id: AnyNodeId, data: Partial<AnyNode>): void {
if (!useScene.getState().nodes[id]) {
throw new Error(`node not found: ${id}`)
}
useScene.getState().updateNode(id, data)
}
/**
* Delete a node. If the node has children and `cascade === false`, throws.
* If `cascade` is true (or undefined and no children), delegates to the core
* action which already recursively removes descendants.
*
* Returns the list of ids actually removed from the scene.
*/
deleteNode(id: AnyNodeId, cascade = false): string[] {
const state = useScene.getState()
const node = state.nodes[id]
if (!node) {
throw new Error(`node not found: ${id}`)
}
const descendants = this._collectDescendants(id)
if (!cascade && descendants.length > 1) {
throw new Error(
`node has ${descendants.length - 1} descendant(s); pass cascade: true to delete recursively`,
)
}
const before = new Set(Object.keys(state.nodes))
useScene.getState().deleteNode(id)
const afterNodes = useScene.getState().nodes
const removed: string[] = []
for (const prevId of before) {
if (!(prevId in afterNodes)) removed.push(prevId)
}
return removed
}
/**
* Atomic multi-op patch. Validates EVERY patch first (dry run); only if all
* pass does it apply in a single batch via `createNodes` / `updateNodes` /
* `deleteNodes`. Throws on any validation failure without mutating state.
*/
applyPatch(patches: Patch[]): {
appliedOps: number
deletedIds: AnyNodeId[]
createdIds: AnyNodeId[]
} {
const state = useScene.getState()
const nodes = state.nodes
// Track synthesized state as we dry-run so later ops can reference
// earlier-created ids and reflect earlier-deleted ids.
const simAvailable = new Set<string>(Object.keys(nodes))
const simDeleted = new Set<string>()
// Parsed create nodes keyed by patch index — so the apply phase can use the
// Zod-normalised copy (which has a generated id if the caller omitted one)
// instead of the unparsed input.
const parsedCreateNodes = new Map<number, AnyNode>()
for (let i = 0; i < patches.length; i++) {
const p = patches[i]
if (!p) throw new Error(`invalid patch: patches[${i}] is undefined`)
if (p.op === 'create') {
const res = AnyNodeSchema.safeParse(p.node)
if (!res.success) {
throw new Error(
`invalid patch: patches[${i}] create node failed schema: ${res.error.message}`,
)
}
if (p.parentId !== undefined && !simAvailable.has(p.parentId)) {
throw new Error(`invalid patch: patches[${i}] create parentId "${p.parentId}" not found`)
}
parsedCreateNodes.set(i, res.data)
simAvailable.add(res.data.id)
} else if (p.op === 'update') {
if (!simAvailable.has(p.id) || simDeleted.has(p.id)) {
throw new Error(`invalid patch: patches[${i}] update id "${p.id}" not found`)
}
if (!p.data || typeof p.data !== 'object') {
throw new Error(`invalid patch: patches[${i}] update data is not an object`)
}
} else if (p.op === 'delete') {
if (!simAvailable.has(p.id) || simDeleted.has(p.id)) {
throw new Error(`invalid patch: patches[${i}] delete id "${p.id}" not found`)
}
if (p.cascade === false) {
// Only inspect the current store state — we don't simulate
// descendant additions during dry-run, because that would require
// building a full shadow tree. This matches the semantics of the
// single-op deleteNode guard.
const desc = this._collectDescendants(p.id)
if (desc.length > 1) {
throw new Error(
`invalid patch: patches[${i}] delete "${p.id}" has descendants; pass cascade: true`,
)
}
}
simAvailable.delete(p.id)
simDeleted.add(p.id)
} else {
throw new Error(`invalid patch: patches[${i}] unknown op`)
}
}
// Dry-run succeeded — apply in order, batching adjacent ops of the same
// op type so Zundo groups them tightly.
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = []
const updateOps: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
const deleteIds: AnyNodeId[] = []
const createdIds: AnyNodeId[] = []
// Simple approach: queue by type, flush in original order by walking
// patches and interleaving flushes when the op type changes, so ids
// created/updated/deleted stay temporally consistent.
const flush = (kind: 'create' | 'update' | 'delete' | 'none') => {
if (kind !== 'create' && createOps.length > 0) {
useScene.getState().createNodes(createOps)
createOps.length = 0
}
if (kind !== 'update' && updateOps.length > 0) {
useScene.getState().updateNodes(updateOps)
updateOps.length = 0
}
if (kind !== 'delete' && deleteIds.length > 0) {
useScene.getState().deleteNodes(deleteIds)
deleteIds.length = 0
}
}
for (let i = 0; i < patches.length; i++) {
const p = patches[i]!
if (p.op === 'create') {
flush('create')
const parsedNode = parsedCreateNodes.get(i)!
createOps.push({ node: parsedNode, parentId: p.parentId })
createdIds.push(parsedNode.id as AnyNodeId)
} else if (p.op === 'update') {
flush('update')
updateOps.push({ id: p.id, data: p.data })
} else {
flush('delete')
deleteIds.push(p.id)
}
}
flush('none')
// Compute actual deleted ids by diffing pre/post snapshots.
const postNodes = useScene.getState().nodes
const deletedIds: AnyNodeId[] = []
for (const prevId of Object.keys(nodes)) {
if (!(prevId in postNodes)) deletedIds.push(prevId as AnyNodeId)
}
return {
appliedOps: patches.length,
deletedIds,
createdIds,
}
}
/** Undo. Returns the number of steps actually undone. */
undo(steps = 1): number {
const before = useScene.temporal.getState().pastStates.length
useScene.temporal.getState().undo(steps)
const after = useScene.temporal.getState().pastStates.length
return Math.max(0, before - after)
}
/** Redo. Returns the number of steps actually redone. */
redo(steps = 1): number {
const before = useScene.temporal.getState().futureStates.length
useScene.temporal.getState().redo(steps)
const after = useScene.temporal.getState().futureStates.length
return Math.max(0, before - after)
}
/**
* Zod-validate every node in the scene. Reports one error per failed node,
* concatenating Zod issue paths.
*/
validateScene(): ValidationResult {
const errors: ValidationError[] = []
const nodes = useScene.getState().nodes
for (const [id, node] of Object.entries(nodes)) {
const res = AnyNodeSchema.safeParse(node)
if (res.success) continue
for (const issue of res.error.issues) {
errors.push({
nodeId: id,
path: issue.path.join('.'),
message: issue.message,
})
}
}
return { valid: errors.length === 0, errors }
}
/**
* Drain the dirtyNodes set. Returns the ids that were present. No-op for
* renderer (there is no renderer in MCP mode); useful for observability.
*/
flushDirty(): string[] {
const state = useScene.getState()
const ids = Array.from(state.dirtyNodes)
for (const id of ids) {
state.clearDirty(id as AnyNodeId)
}
return ids
}
/** Current temporal history pointers. */
getHistory(): { pastCount: number; futureCount: number } {
const t = useScene.temporal.getState()
return {
pastCount: t.pastStates.length,
futureCount: t.futureStates.length,
}
}
/** Clear the temporal undo/redo history. */
clearHistory(): void {
useScene.temporal.getState().clear()
}
// ---- internal helpers ----
/**
* Return the node whose `children` array (string or object form) contains
* the given id, or null if none. Used as a fallback when `parentId` is
* missing on a node.
*/
private _findParentByChildrenScan(id: AnyNodeId): AnyNode | null {
const nodes = useScene.getState().nodes
for (const candidate of Object.values(nodes)) {
if (!('children' in candidate) || !Array.isArray(candidate.children)) continue
for (const child of candidate.children as unknown[]) {
let childId: string | null = null
if (typeof child === 'string') childId = child
else if (
child &&
typeof child === 'object' &&
'id' in (child as Record<string, unknown>) &&
typeof (child as { id: unknown }).id === 'string'
) {
childId = (child as { id: string }).id
}
if (childId === id) return candidate
}
}
return null
}
/**
* Collect ids of a node and all its descendants. Uses the same combined
* strategy as `getChildren` (parentId scan + children-array walk) so that
* the SiteNode quirk and the default-scene parentId-unset case both work.
*/
private _collectDescendants(id: AnyNodeId): AnyNodeId[] {
const nodes = useScene.getState().nodes
if (!nodes[id]) return []
const out: AnyNodeId[] = []
const stack: AnyNodeId[] = [id]
const seen = new Set<AnyNodeId>()
// Precompute parent → child[] index from parentId only. `children` arrays
// are consulted on-the-fly via getChildren.
while (stack.length > 0) {
const curr = stack.pop()!
if (seen.has(curr)) continue
seen.add(curr)
out.push(curr)
const children = this.getChildren(curr)
for (const c of children) stack.push(c.id as AnyNodeId)
}
return out
}
}
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from 'bun:test'
test('version module loads', async () => {
const mod = await import('./index')
expect(mod.version).toBe('0.1.0')
})
test('createPascalMcpServer is a function', async () => {
const mod = await import('./index')
expect(typeof mod.createPascalMcpServer).toBe('function')
})
+5
View File
@@ -0,0 +1,5 @@
export { SceneBridge } from './bridge/scene-bridge'
export { createSceneOperations, type SceneOperations } from './operations'
export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server'
export const version = '0.1.0'
@@ -0,0 +1,40 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode } from '@pascal-app/core/schema'
/**
* `cloneSceneGraph` normalises `SiteNode.children` to an array of node IDs,
* but core's `SiteNode` schema expects an array of embedded `BuildingNode` /
* `ItemNode` objects (see `packages/mcp/CROSS_CUTTING.md` §2). To keep the
* cloned graph validating against `AnyNode`, re-embed the site children from
* the flat dict.
*
* Pure: returns a new graph without mutating the input.
*/
export function rehydrateSiteChildren(graph: SceneGraph): SceneGraph {
const out: SceneGraph = {
nodes: { ...graph.nodes },
rootNodeIds: [...graph.rootNodeIds],
...(graph.collections ? { collections: graph.collections } : {}),
}
for (const [id, node] of Object.entries(out.nodes)) {
if (node.type !== 'site') continue
const childrenField = (node as { children?: unknown[] }).children
if (!Array.isArray(childrenField)) continue
const rehydrated: AnyNode[] = []
for (const child of childrenField) {
if (typeof child === 'string') {
const target = out.nodes[child as keyof typeof out.nodes]
if (target && (target.type === 'building' || target.type === 'item')) {
rehydrated.push(target)
}
} else if (child && typeof child === 'object' && 'id' in (child as Record<string, unknown>)) {
rehydrated.push(child as AnyNode)
}
}
out.nodes[id as keyof typeof out.nodes] = {
...(node as AnyNode),
children: rehydrated,
} as AnyNode
}
return out
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, test } from 'bun:test'
import { McpError } from '@modelcontextprotocol/sdk/types.js'
import { safeFetch } from './safe-fetch'
describe('safeFetch — SSRF protection', () => {
test('rejects non-http schemes', async () => {
for (const url of ['file:///etc/passwd', 'ftp://example.com/', 'javascript:alert(1)']) {
const err = await safeFetch(url).catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_scheme_not_allowed')
}
})
test('rejects loopback addresses', async () => {
for (const url of [
'http://127.0.0.1/',
'http://127.1.2.3/',
'http://localhost:9999/',
'http://[::1]/',
]) {
const err = await safeFetch(url).catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_host_blocked')
}
})
test('rejects link-local / cloud metadata', async () => {
// 169.254.169.254 is the AWS/GCP/Azure instance-metadata endpoint.
const url = 'http://169.254.169.254/latest/meta-data/'
const err = await safeFetch(url).catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_host_blocked')
})
test('rejects private IP ranges', async () => {
for (const url of [
'http://10.0.0.1/',
'http://172.16.5.9/',
'http://172.31.255.254/',
'http://192.168.1.1/',
]) {
const err = await safeFetch(url).catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_host_blocked')
}
})
test('rejects local-style hostnames', async () => {
for (const url of [
'http://mything.local/',
'http://server.internal/',
'http://db.corp/',
'http://nope.localhost/',
]) {
const err = await safeFetch(url).catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_host_blocked')
}
})
test('rejects IPv4-mapped IPv6 loopback', async () => {
const err = await safeFetch('http://[::ffff:127.0.0.1]/').catch((e) => e)
expect(err).toBeInstanceOf(McpError)
})
test('rejects malformed URL', async () => {
const err = await safeFetch('not a url').catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('invalid_url')
})
test('applies PASCAL_ALLOWED_ASSET_ORIGINS env allowlist when set', async () => {
const prev = process.env.PASCAL_ALLOWED_ASSET_ORIGINS
process.env.PASCAL_ALLOWED_ASSET_ORIGINS = 'https://cdn.example.com'
try {
const err = await safeFetch('https://other.example.com/x.png').catch((e) => e)
expect(err).toBeInstanceOf(McpError)
expect((err as Error).message).toContain('url_origin_not_allowlisted')
} finally {
if (prev === undefined) {
delete process.env.PASCAL_ALLOWED_ASSET_ORIGINS
} else {
process.env.PASCAL_ALLOWED_ASSET_ORIGINS = prev
}
}
})
})
+230
View File
@@ -0,0 +1,230 @@
import { isIPv4, isIPv6 } from 'node:net'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
/**
* SSRF-safe fetch for user-supplied URLs (image URLs in vision tools).
*
* Blocks the usual server-side-request-forgery attack surface:
* - loopback (127.0.0.0/8, ::1)
* - link-local (169.254.0.0/16 — includes cloud metadata at 169.254.169.254)
* - private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
* - non-http(s) schemes
* - manual-redirect with the same allowlist applied to each hop
* - max body size (default 20 MB)
* - request timeout (default 10 s)
*
* Optional allowlist via `PASCAL_ALLOWED_ASSET_ORIGINS` env var (comma-separated).
*
* Phase 10 A2 found that photo_to_scene / analyze_floorplan_image /
* analyze_room_photo all called raw `fetch(url)` with no protection, giving
* a direct `169.254.169.254` exfil primitive on any host.
*/
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024 // 20 MB
const DEFAULT_TIMEOUT_MS = 10_000
const MAX_REDIRECTS = 3
function isPrivateOrLoopbackV4(addr: string): boolean {
const parts = addr.split('.').map(Number)
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true // malformed → treat as unsafe
const [a, b] = parts as [number, number, number, number]
if (a === 127) return true // 127.0.0.0/8 loopback
if (a === 10) return true // 10.0.0.0/8 private
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 private
if (a === 192 && b === 168) return true // 192.168.0.0/16 private
if (a === 169 && b === 254) return true // link-local incl. cloud metadata
if (a === 0) return true // current-network
if (a >= 224) return true // multicast / reserved
return false
}
function isPrivateOrLoopbackV6(addr: string): boolean {
const lower = addr.toLowerCase()
if (lower === '::1' || lower === '::') return true
if (
lower.startsWith('fe80:') ||
lower.startsWith('fe90:') ||
lower.startsWith('fea0:') ||
lower.startsWith('feb0:')
)
return true // link-local
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // ULA fc00::/7
if (lower.startsWith('::ffff:')) {
// v4-mapped
const v4 = lower.slice(7)
if (isIPv4(v4)) return isPrivateOrLoopbackV4(v4)
}
return false
}
function isUnsafeHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets if any
if (isIPv4(host)) return isPrivateOrLoopbackV4(host)
if (isIPv6(host)) return isPrivateOrLoopbackV6(host)
// Hostname (not IP) — block well-known local names.
const lower = host.toLowerCase()
if (
lower === 'localhost' ||
lower.endsWith('.localhost') ||
lower === 'broadcasthost' ||
lower.endsWith('.local') ||
lower.endsWith('.internal') ||
lower.endsWith('.corp')
) {
return true
}
return false
}
function assertAllowedUrl(url: string): URL {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new McpError(ErrorCode.InvalidParams, 'invalid_url', { url })
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new McpError(ErrorCode.InvalidParams, 'url_scheme_not_allowed', {
url,
protocol: parsed.protocol,
})
}
if (isUnsafeHost(parsed.hostname)) {
throw new McpError(ErrorCode.InvalidParams, 'url_host_blocked', {
url,
hostname: parsed.hostname,
})
}
// Optional env-allowlist narrowing.
const allowEnv = process.env.PASCAL_ALLOWED_ASSET_ORIGINS
if (allowEnv) {
const origins = allowEnv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (!origins.includes(parsed.origin)) {
throw new McpError(ErrorCode.InvalidParams, 'url_origin_not_allowlisted', {
url,
origin: parsed.origin,
})
}
}
return parsed
}
export type SafeFetchOptions = {
maxBytes?: number
timeoutMs?: number
/** Request `Accept` header to send. */
accept?: string
}
export type SafeFetchResult = {
buffer: Buffer
contentType: string | null
finalUrl: string
hops: string[]
}
/**
* SSRF-safe fetch that follows redirects manually, revalidating the host
* allowlist + private-IP check on every hop. Throws `McpError` for blocked
* URLs, non-2xx responses, oversize bodies, or timeouts.
*/
export async function safeFetch(
urlStr: string,
opts: SafeFetchOptions = {},
): Promise<SafeFetchResult> {
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const hops: string[] = []
let current = urlStr
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const parsed = assertAllowedUrl(current)
hops.push(parsed.toString())
const res = await fetch(parsed, {
redirect: 'manual',
signal: controller.signal,
headers: opts.accept ? { Accept: opts.accept } : undefined,
})
// Manual redirect handling
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location')
if (!location) {
throw new McpError(ErrorCode.InvalidParams, 'redirect_without_location', {
url: parsed.toString(),
status: res.status,
})
}
current = new URL(location, parsed).toString()
continue
}
if (!res.ok) {
throw new McpError(ErrorCode.InvalidParams, 'fetch_failed', {
url: parsed.toString(),
status: res.status,
statusText: res.statusText,
})
}
// Enforce Content-Length up front if present.
const declared = Number(res.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maxBytes) {
throw new McpError(ErrorCode.InvalidParams, 'response_too_large', {
url: parsed.toString(),
declared,
maxBytes,
})
}
// Stream with a running cap so servers that lie about length still get bounded.
const reader = res.body?.getReader()
if (!reader) {
throw new McpError(ErrorCode.InvalidParams, 'empty_response', {
url: parsed.toString(),
})
}
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value) {
total += value.byteLength
if (total > maxBytes) {
try {
await reader.cancel()
} catch {
// ignore
}
throw new McpError(ErrorCode.InvalidParams, 'response_too_large', {
url: parsed.toString(),
received: total,
maxBytes,
})
}
chunks.push(value)
}
}
return {
buffer: Buffer.concat(chunks.map((c) => Buffer.from(c))),
contentType: res.headers.get('content-type'),
finalUrl: parsed.toString(),
hops,
}
}
throw new McpError(ErrorCode.InvalidParams, 'too_many_redirects', {
hops: hops.slice(0, MAX_REDIRECTS + 1),
})
} catch (err) {
if (err instanceof McpError) throw err
if ((err as { name?: string }).name === 'AbortError') {
throw new McpError(ErrorCode.InvalidParams, 'fetch_timeout', { url: urlStr, timeoutMs })
}
const message = err instanceof Error ? err.message : String(err)
throw new McpError(ErrorCode.InvalidParams, 'fetch_error', { url: urlStr, message })
} finally {
clearTimeout(timer)
}
}
+5
View File
@@ -0,0 +1,5 @@
export {
type CreateSceneOperationsOptions,
createSceneOperations,
type SceneOperations,
} from './scene-operations'
@@ -0,0 +1,273 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema'
import type { ActiveSceneMeta, Patch, SceneBridge, ValidationResult } from '../bridge/scene-bridge'
import type {
SceneEvent,
SceneEventAppendOptions,
SceneEventListOptions,
SceneListOptions,
SceneMeta,
SceneMutateOptions,
SceneSaveOptions,
SceneStore,
SceneWithGraph,
} from '../storage/types'
export type CreateSceneOperationsOptions = {
bridge?: SceneBridge
store?: SceneStore
}
export interface SceneOperations {
readonly hasBridge: boolean
readonly hasStore: boolean
readonly hasSceneEvents: boolean
readonly canAppendSceneEvents: boolean
readonly canListSceneEvents: boolean
readonly storeBackend: SceneStore['backend'] | null
setActiveScene(meta: ActiveSceneMeta): void
getActiveScene(): ActiveSceneMeta | null
clearActiveScene(): void
loadDefault(): void
setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void
exportJSON(): SceneGraph & { collections: Record<string, unknown> }
exportSceneGraph(): SceneGraph
loadJSON(json: string | SceneGraph): void
getNode(id: AnyNodeId): AnyNode | null
getNodes(): Record<AnyNodeId, AnyNode>
getRootNodeIds(): AnyNodeId[]
getChildren(parentId: AnyNodeId): AnyNode[]
getAncestry(id: AnyNodeId): AnyNode[]
findNodes(filter: {
type?: AnyNodeType
parentId?: AnyNodeId | null
levelId?: AnyNodeId
}): AnyNode[]
resolveLevelId(id: AnyNodeId): AnyNodeId | null
createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId
updateNode(id: AnyNodeId, data: Partial<AnyNode>): void
deleteNode(id: AnyNodeId, cascade?: boolean): string[]
applyPatch(patches: Patch[]): {
appliedOps: number
deletedIds: AnyNodeId[]
createdIds: AnyNodeId[]
}
undo(steps?: number): number
redo(steps?: number): number
validateScene(): ValidationResult
flushDirty(): string[]
getHistory(): { pastCount: number; futureCount: number }
clearHistory(): void
saveScene(options: SceneSaveOptions): Promise<SceneMeta>
loadStoredScene(id: string): Promise<SceneWithGraph | null>
listScenes(options?: SceneListOptions): Promise<SceneMeta[]>
deleteStoredScene(id: string, options?: SceneMutateOptions): Promise<boolean>
renameStoredScene(id: string, newName: string, options?: SceneMutateOptions): Promise<SceneMeta>
appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent | null>
listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]>
}
export function createSceneOperations(options: CreateSceneOperationsOptions): SceneOperations {
return new SceneOperationsFacade(options)
}
class SceneOperationsFacade implements SceneOperations {
readonly #bridge?: SceneBridge
readonly #store?: SceneStore
constructor(options: CreateSceneOperationsOptions) {
this.#bridge = options.bridge
this.#store = options.store
}
get hasBridge(): boolean {
return this.#bridge !== undefined
}
get hasStore(): boolean {
return this.#store !== undefined
}
get hasSceneEvents(): boolean {
return this.canAppendSceneEvents && this.canListSceneEvents
}
get canAppendSceneEvents(): boolean {
return typeof this.#store?.appendSceneEvent === 'function'
}
get canListSceneEvents(): boolean {
return typeof this.#store?.listSceneEvents === 'function'
}
get storeBackend(): SceneStore['backend'] | null {
return this.#store?.backend ?? null
}
setActiveScene(meta: ActiveSceneMeta): void {
this.requireBridge().setActiveScene(meta)
}
getActiveScene(): ActiveSceneMeta | null {
return this.requireBridge().getActiveScene()
}
clearActiveScene(): void {
this.requireBridge().clearActiveScene()
}
loadDefault(): void {
this.requireBridge().loadDefault()
}
setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void {
this.requireBridge().setScene(nodes, rootNodeIds)
}
exportJSON(): SceneGraph & { collections: Record<string, unknown> } {
return this.requireBridge().exportJSON()
}
exportSceneGraph(): SceneGraph {
const exported = this.exportJSON()
return {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
}
loadJSON(json: string | SceneGraph): void {
this.requireBridge().loadJSON(json)
}
getNode(id: AnyNodeId): AnyNode | null {
return this.requireBridge().getNode(id)
}
getNodes(): Record<AnyNodeId, AnyNode> {
return this.requireBridge().getNodes()
}
getRootNodeIds(): AnyNodeId[] {
return this.requireBridge().getRootNodeIds()
}
getChildren(parentId: AnyNodeId): AnyNode[] {
return this.requireBridge().getChildren(parentId)
}
getAncestry(id: AnyNodeId): AnyNode[] {
return this.requireBridge().getAncestry(id)
}
findNodes(filter: {
type?: AnyNodeType
parentId?: AnyNodeId | null
levelId?: AnyNodeId
}): AnyNode[] {
return this.requireBridge().findNodes(filter)
}
resolveLevelId(id: AnyNodeId): AnyNodeId | null {
return this.requireBridge().resolveLevelId(id)
}
createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId {
return this.requireBridge().createNode(node, parentId)
}
updateNode(id: AnyNodeId, data: Partial<AnyNode>): void {
this.requireBridge().updateNode(id, data)
}
deleteNode(id: AnyNodeId, cascade?: boolean): string[] {
return this.requireBridge().deleteNode(id, cascade)
}
applyPatch(patches: Patch[]): {
appliedOps: number
deletedIds: AnyNodeId[]
createdIds: AnyNodeId[]
} {
return this.requireBridge().applyPatch(patches)
}
undo(steps?: number): number {
return this.requireBridge().undo(steps)
}
redo(steps?: number): number {
return this.requireBridge().redo(steps)
}
validateScene(): ValidationResult {
return this.requireBridge().validateScene()
}
flushDirty(): string[] {
return this.requireBridge().flushDirty()
}
getHistory(): { pastCount: number; futureCount: number } {
return this.requireBridge().getHistory()
}
clearHistory(): void {
this.requireBridge().clearHistory()
}
async saveScene(options: SceneSaveOptions): Promise<SceneMeta> {
return this.requireStore().save(options)
}
async loadStoredScene(id: string): Promise<SceneWithGraph | null> {
return this.requireStore().load(id)
}
async listScenes(options?: SceneListOptions): Promise<SceneMeta[]> {
return this.requireStore().list(options)
}
async deleteStoredScene(id: string, options?: SceneMutateOptions): Promise<boolean> {
return this.requireStore().delete(id, options)
}
async renameStoredScene(
id: string,
newName: string,
options?: SceneMutateOptions,
): Promise<SceneMeta> {
return this.requireStore().rename(id, newName, options)
}
async appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent | null> {
const append = this.requireStore().appendSceneEvent
if (!append) return null
return append(options)
}
async listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]> {
const list = this.requireStore().listSceneEvents
if (!list) {
throw new Error('scene_events_unavailable')
}
return list(id, options)
}
private requireBridge(): SceneBridge {
if (!this.#bridge) {
throw new Error('scene_bridge_unavailable')
}
return this.#bridge
}
private requireStore(): SceneStore {
if (!this.#store) {
throw new Error('scene_store_unavailable')
}
return this.#store
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are a Pascal 3D scene designer.',
'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).',
' - 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')
/**
* Build the user-facing prompt text for `from_brief`. Pure function for testability.
*/
export function buildFromBriefPrompt(args: {
brief: string
constraints?: string | undefined
}): string {
const parts: string[] = [PREAMBLE, '', '## Brief', args.brief.trim()]
if (args.constraints && args.constraints.trim().length > 0) {
parts.push('', '## Constraints', args.constraints.trim())
}
parts.push(
'',
'## Task',
'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')
}
export function registerFromBrief(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'from_brief',
{
title: 'Generate a Pascal scene from a brief',
description:
'Produces a plan of apply_patch calls to create a scene from a natural-language brief.',
argsSchema: {
brief: z.string(),
constraints: z.string().optional(),
},
},
async ({ brief, constraints }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: buildFromBriefPrompt({ brief, constraints }),
},
},
],
}),
)
}
+17
View File
@@ -0,0 +1,17 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
import { registerFromBrief } from './from-brief'
import { registerIterateOnFeedback } from './iterate-on-feedback'
import { registerRenovationFromPhotos } from './renovation-from-photos'
/**
* Registers all MCP prompts exposed by `@pascal-app/mcp`:
* - `from_brief` — generate a scene from a natural-language brief
* - `iterate_on_feedback` — minimal-diff patches from user feedback
* - `renovation_from_photos` — photo-driven renovation plan via vision tools
*/
export function registerPrompts(server: McpServer, operations: SceneOperations): void {
registerFromBrief(server, operations)
registerIterateOnFeedback(server, operations)
registerRenovationFromPhotos(server, operations)
}
@@ -0,0 +1,51 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are iterating on an existing Pascal scene based on user feedback.',
'Given the current state (read via the `pascal://scene/current` resource) and the user feedback below, propose the MINIMUM set of `apply_patch` operations that satisfies the feedback.',
'Rules:',
' - 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')
/**
* Build the user-facing prompt text for `iterate_on_feedback`.
* Pure function for testability.
*/
export function buildIterateOnFeedbackPrompt(args: { feedback: string }): string {
return [PREAMBLE, '', '## User feedback', args.feedback.trim()].join('\n')
}
export function registerIterateOnFeedback(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'iterate_on_feedback',
{
title: 'Iterate on a scene from user feedback',
description:
'Produces a minimal-diff plan of apply_patch calls in response to user feedback on the current scene.',
argsSchema: {
feedback: z.string(),
},
},
async ({ feedback }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: buildIterateOnFeedbackPrompt({ feedback }),
},
},
],
}),
)
}
+223
View File
@@ -0,0 +1,223 @@
// Side-effect import MUST come first: installs RAF polyfill before core loads.
import '../bridge/node-shims'
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 useScene from '@pascal-app/core/store'
import { SceneBridge } from '../bridge/scene-bridge'
import { buildFromBriefPrompt, registerFromBrief } from './from-brief'
import { buildIterateOnFeedbackPrompt, registerIterateOnFeedback } from './iterate-on-feedback'
import { buildRenovationMessages, registerRenovationFromPhotos } from './renovation-from-photos'
type ClientServerPair = {
client: Client
server: McpServer
bridge: SceneBridge
close: () => Promise<void>
}
async function spinUp(
register: (server: McpServer, bridge: SceneBridge) => void,
): Promise<ClientServerPair> {
const bridge = new SceneBridge()
const server = new McpServer({ name: 'test', version: '0.0.0' })
register(server, bridge)
const client = new Client({ name: 'test-client', version: '0.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
return {
client,
server,
bridge,
close: async () => {
await client.close()
await server.close()
},
}
}
function resetScene(): void {
useScene.getState().unloadScene()
useScene.temporal.getState().clear()
}
describe('from_brief', () => {
beforeEach(() => resetScene())
test('includes brief in the returned user message', async () => {
const pair = await spinUp(registerFromBrief)
try {
const res = await pair.client.getPrompt({
name: 'from_brief',
arguments: { brief: 'A 60 sqm studio with a kitchenette' },
})
expect(res.messages).toHaveLength(1)
const m = res.messages[0]
expect(m).toBeDefined()
if (!m) return
expect(m.role).toBe('user')
expect(m.content.type).toBe('text')
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()
}
})
test('appends constraints section when provided', async () => {
const pair = await spinUp(registerFromBrief)
try {
const res = await pair.client.getPrompt({
name: 'from_brief',
arguments: {
brief: 'Tiny house',
constraints: 'footprint under 40 sqm',
},
})
const m = res.messages[0]
expect(m).toBeDefined()
if (!m) return
if (m.content.type === 'text') {
expect(m.content.text).toContain('## Constraints')
expect(m.content.text).toContain('footprint under 40 sqm')
}
} finally {
await pair.close()
}
})
test('buildFromBriefPrompt omits constraints section when empty', () => {
const text = buildFromBriefPrompt({ brief: 'Studio', constraints: '' })
expect(text).not.toContain('## Constraints')
expect(text).toContain('Studio')
})
})
describe('iterate_on_feedback', () => {
beforeEach(() => resetScene())
test('returns single user message referencing the feedback and the scene resource', async () => {
const pair = await spinUp(registerIterateOnFeedback)
try {
const res = await pair.client.getPrompt({
name: 'iterate_on_feedback',
arguments: { feedback: 'Move the fridge to the opposite wall' },
})
expect(res.messages).toHaveLength(1)
const m = res.messages[0]
expect(m).toBeDefined()
if (!m) return
expect(m.role).toBe('user')
if (m.content.type === 'text') {
expect(m.content.text).toContain('Move the fridge')
expect(m.content.text).toContain('pascal://scene/current')
expect(m.content.text).toContain('apply_patch')
}
} finally {
await pair.close()
}
})
test('buildIterateOnFeedbackPrompt emphasises minimal diff', () => {
const text = buildIterateOnFeedbackPrompt({ feedback: 'x' })
expect(text.toLowerCase()).toContain('minimum')
})
})
describe('renovation_from_photos', () => {
beforeEach(() => resetScene())
test('parses JSON-array photo lists and emits image/text content', async () => {
const pair = await spinUp(registerRenovationFromPhotos)
try {
const longBase64 = 'A'.repeat(40) // length % 4 == 0, pure base64 chars.
const res = await pair.client.getPrompt({
name: 'renovation_from_photos',
arguments: {
currentPhotos: JSON.stringify(['https://example.com/current1.jpg', longBase64]),
referencePhotos: JSON.stringify(['data:image/png;base64,iVBORw0K']),
goals: 'make it look mid-century modern',
},
})
expect(res.messages.length).toBeGreaterThan(1)
// Intro text should mention goals + counts.
const intro = res.messages[0]
expect(intro).toBeDefined()
if (!intro) return
if (intro.content.type !== 'text') throw new Error('intro not text')
expect(intro.content.text).toContain('mid-century modern')
expect(intro.content.text).toContain('Current photos: 2')
expect(intro.content.text).toContain('Reference photos: 1')
// There should be at least one image content (from the base64) and one
// URL text fallback (from the https URL).
const kinds = res.messages.map((m) => m.content.type)
expect(kinds).toContain('image')
const textMessages = res.messages.filter((m) => m.content.type === 'text')
const hasUrlFallback = textMessages.some(
(m) => m.content.type === 'text' && m.content.text.startsWith('URL: https://'),
)
expect(hasUrlFallback).toBe(true)
// Final message should be a task directive.
const last = res.messages[res.messages.length - 1]
expect(last).toBeDefined()
if (!last) return
if (last.content.type === 'text') {
expect(last.content.text).toContain('## Task')
expect(last.content.text).toContain('apply_patch')
}
} finally {
await pair.close()
}
})
test('data-URL with explicit mimeType becomes image content', () => {
const messages = buildRenovationMessages({
currentPhotos: JSON.stringify(['data:image/png;base64,aGVsbG8='] as string[]),
referencePhotos: '[]',
goals: 'test',
})
const imageMsg = messages.find((m) => m.content.type === 'image')
expect(imageMsg).toBeDefined()
if (imageMsg && imageMsg.content.type === 'image') {
expect(imageMsg.content.mimeType).toBe('image/png')
expect(imageMsg.content.data).toBe('aGVsbG8=')
}
})
test('comma-separated fallback parses a list correctly', () => {
const messages = buildRenovationMessages({
currentPhotos: 'https://a.example/1.jpg, https://b.example/2.jpg',
referencePhotos: '',
goals: 'test',
})
const urlTextMsgs = messages.filter(
(m) => m.content.type === 'text' && m.content.text.startsWith('URL: https://'),
)
expect(urlTextMsgs.length).toBe(2)
})
test('empty lists produce no per-photo sections but still include task directive', () => {
const messages = buildRenovationMessages({
currentPhotos: '',
referencePhotos: '',
goals: 'nothing to do',
})
// 1 intro + 1 task = 2 messages.
expect(messages.length).toBe(2)
const last = messages[messages.length - 1]
expect(last).toBeDefined()
if (last && last.content.type === 'text') {
expect(last.content.text).toContain('## Task')
}
})
})
@@ -0,0 +1,160 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
const PREAMBLE = [
'You are renovating an existing room based on photos of the current space and reference photos of the target aesthetic.',
'',
'Follow this procedure:',
' 1. Call `analyze_floorplan_image` and/or `analyze_room_photo` on EACH current photo to extract walls, rooms, fixtures, and approximate dimensions. Do the same for reference photos.',
' 2. Compare the current-state analyses to the reference-state analyses. Identify concrete deltas (materials, fixtures, layout changes) that align with the renovation goals.',
' 3. Emit a single `apply_patch` call containing the minimum set of patches needed to converge the current scene toward the goals.',
'',
'Rules:',
' - Do not invent dimensions. Pull them from the analysis tool results.',
' - Do not modify nodes that are unrelated to the goals.',
' - Respond ONLY with tool calls. No prose.',
].join('\n')
function isDataUrl(s: string): boolean {
return s.startsWith('data:')
}
function isHttpUrl(s: string): boolean {
return s.startsWith('http://') || s.startsWith('https://')
}
/**
* Rough base64 detector: length multiple of 4, only base64 chars, at least 32 chars long.
* Deliberately conservative — when in doubt we fall back to text `URL: ...`.
*/
function looksLikeBase64(s: string): boolean {
if (s.length < 32) return false
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
type PromptContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
/** Extract a base64 payload from a data-URL, or return the raw string. */
function toImageContent(source: string): PromptContent {
if (isDataUrl(source)) {
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/.exec(source)
if (match) {
const mimeType = match[1] && match[1].length > 0 ? match[1] : 'image/jpeg'
const data = match[2] ?? ''
return { type: 'image', data, mimeType }
}
return { type: 'text', text: `URL: ${source}` }
}
if (isHttpUrl(source)) {
return { type: 'text', text: `URL: ${source}` }
}
if (looksLikeBase64(source)) {
return { type: 'image', data: source, mimeType: 'image/jpeg' }
}
return { type: 'text', text: `URL: ${source}` }
}
/** Parse the stringified list argument (JSON array or comma-separated fallback). */
function parsePhotoList(raw: string | string[] | undefined): string[] {
if (Array.isArray(raw)) {
return raw.map((s) => String(s)).filter((s) => s.length > 0)
}
const str = (raw ?? '').trim()
if (str.length === 0) return []
if (str.startsWith('[')) {
try {
const parsed = JSON.parse(str)
if (Array.isArray(parsed)) {
return parsed.map((s) => String(s)).filter((s) => s.length > 0)
}
} catch {
/* fall through to comma-split */
}
}
return str
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
/**
* Build the full messages array. Pure function for testability.
*/
export function buildRenovationMessages(args: {
currentPhotos: string[] | string | undefined
referencePhotos: string[] | string | undefined
goals: string
}): Array<{
role: 'user'
content: PromptContent
}> {
const current = parsePhotoList(args.currentPhotos)
const reference = parsePhotoList(args.referencePhotos)
const intro = [
PREAMBLE,
'',
'## Goals',
args.goals.trim(),
'',
'## Inputs',
`Current photos: ${current.length} item(s)`,
`Reference photos: ${reference.length} item(s)`,
].join('\n')
const messages: Array<{ role: 'user'; content: PromptContent }> = [
{ role: 'user', content: { type: 'text', text: intro } },
]
if (current.length > 0) {
messages.push({
role: 'user',
content: { type: 'text', text: '## Current photos' },
})
for (const src of current) {
messages.push({ role: 'user', content: toImageContent(src) })
}
}
if (reference.length > 0) {
messages.push({
role: 'user',
content: { type: 'text', text: '## Reference photos' },
})
for (const src of reference) {
messages.push({ role: 'user', content: toImageContent(src) })
}
}
messages.push({
role: 'user',
content: {
type: 'text',
text: '## Task\nProduce `apply_patch` operations that drive the current scene toward the goals, using only dimensions and fixtures you derived from the analysis tools.',
},
})
return messages
}
export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'renovation_from_photos',
{
title: 'Plan a renovation from photos',
description:
'Plan a minimal-patch renovation given current-state photos, reference-state photos, and free-form goals.',
argsSchema: {
// MCP prompt arguments are stringly-typed; accept a JSON array or a
// comma-separated list of base64 payloads / data URLs / http(s) URLs.
currentPhotos: z.string(),
referencePhotos: z.string(),
goals: z.string(),
},
},
async ({ currentPhotos, referencePhotos, goals }) => ({
messages: buildRenovationMessages({ currentPhotos, referencePhotos, goals }),
}),
)
}
@@ -0,0 +1,24 @@
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.',
' - Treat requested story count as occupied stories, not raw level count; dedicated roof/support levels are allowed and must not be deleted just to match a story count.',
'',
'Preferred phased tool workflow:',
' - Query first with list_levels, get_level_summary, get_walls, or get_zones when editing an existing scene.',
' - 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.',
' - verify_scene reports both levelCount and occupiedStoryCount. Use occupiedStoryCount when checking whether a one-story/two-story brief was satisfied.',
' - add_door/add_window use t = 0..1 along a wall: 0 is start, 0.5 is center, 1 is end.',
' - Use search_assets before place_item when placing a specific catalog item.',
' - Use apply_patch for precise bulk edits that the semantic tools cannot express.',
' - 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')
+69
View File
@@ -0,0 +1,69 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
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.',
'- Story count means occupied stories, not raw level count. A two-story house may correctly have three levels when the third level has metadata role `roof`; do not delete roof/support levels to satisfy a requested story count.',
'- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.',
'',
'## Scene model facts exposed here so agents do not need repo inspection',
'',
'- 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.',
'- `verify_scene` reports `occupiedStoryCount`, `supportLevelCount`, and `roofLevelIds`; use those fields instead of `levelCount` when checking story-count requirements.',
'- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.',
'- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.',
'',
'## 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: SceneOperations): 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,
},
],
}),
)
}
@@ -0,0 +1,38 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog'
/**
* `pascal://catalog/items` — small built-in item catalog for standalone MCP.
*
* 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: SceneOperations): void {
server.registerResource(
'catalog-items',
'pascal://catalog/items',
{
title: 'Item catalog',
description:
'Dependency-free catalog subset of placeable items available in standalone MCP mode.',
mimeType: 'application/json',
},
async (uri) => {
const payload = {
status: 'ok' as const,
items: MCP_CATALOG_ITEMS,
note: 'Standalone MCP catalog subset; host applications can still expose a larger catalog separately.',
}
return {
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(payload),
},
],
}
},
)
}
+103
View File
@@ -0,0 +1,103 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, SlabNode, WallNode } from '@pascal-app/core/schema'
import { getWallPlanFootprint } from '@pascal-app/core/wall'
import type { SceneOperations } from '../operations'
type WallFootprint = {
wallId: string
footprint: Array<[number, number]>
}
type ConstraintsPayload = {
levelId: string
slabs: SlabNode[]
wallPolygons: WallFootprint[]
}
type ConstraintsError = {
error: 'level_not_found'
levelId: string
slabs: never[]
wallPolygons: never[]
}
/**
* Empty `WallMiterData` — we don't compute junctions here. The footprint
* falls back to a simple rectangle based on start/end + thickness, which is
* correct for non-intersecting walls and an acceptable approximation for
* constraint hints.
*
* Typed via `Parameters<typeof getWallPlanFootprint>[1]` to avoid `any` and
* to stay in sync with the core signature.
*/
const EMPTY_MITER_DATA: Parameters<typeof getWallPlanFootprint>[1] = {
junctionData: new Map(),
junctions: new Map(),
}
function buildPayload(
bridge: SceneOperations,
levelId: string,
): ConstraintsPayload | ConstraintsError {
const level = bridge.getNode(levelId as never)
if (!level || level.type !== 'level') {
return {
error: 'level_not_found',
levelId,
slabs: [] as never[],
wallPolygons: [] as never[],
}
}
const all = bridge.findNodes({ levelId: levelId as never })
const slabs: SlabNode[] = []
const walls: WallNode[] = []
for (const n of all as AnyNode[]) {
if (n.type === 'slab') slabs.push(n as SlabNode)
else if (n.type === 'wall') walls.push(n as WallNode)
}
const wallPolygons: WallFootprint[] = []
for (const wall of walls) {
const points = getWallPlanFootprint(wall, EMPTY_MITER_DATA)
wallPolygons.push({
wallId: wall.id,
footprint: points.map((p) => [p.x, p.y] as [number, number]),
})
}
return { levelId, slabs, wallPolygons }
}
/**
* `pascal://constraints/{levelId}` — per-level geometric constraints used as
* input hints for agents: slab nodes (with polygons/holes/elevation) + each
* wall's plan-view footprint polygon.
*/
export function registerConstraints(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'constraints',
new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }),
{
title: 'Level constraints',
description:
'Per-level constraints: slab nodes and wall plan footprints. Returns {error:"level_not_found"} if the level id is unknown.',
mimeType: 'application/json',
},
async (uri, variables) => {
const rawLevelId = variables.levelId
const levelId = Array.isArray(rawLevelId) ? rawLevelId[0] : rawLevelId
const payload = buildPayload(bridge, levelId ?? '')
return {
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(payload),
},
],
}
},
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
import { registerSceneSummary } from './scene-summary'
/**
* Registers all MCP resources exposed by `@pascal-app/mcp`.
*
* Resources:
* - `pascal://scene/current` — application/json, full snapshot
* - `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, operations: SceneOperations): void {
registerAgentGuide(server, operations)
registerSceneCurrent(server, operations)
registerSceneSummary(server, operations)
registerCatalogItems(server, operations)
registerConstraints(server, operations)
}
@@ -0,0 +1,283 @@
// Side-effect import MUST come first: installs RAF polyfill before core loads.
import '../bridge/node-shims'
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, 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'
import { registerSceneSummary } from './scene-summary'
type ClientServerPair = {
client: Client
server: McpServer
bridge: SceneBridge
close: () => Promise<void>
}
async function spinUp(
register: (server: McpServer, bridge: SceneBridge) => void,
): Promise<ClientServerPair> {
const bridge = new SceneBridge()
const server = new McpServer({ name: 'test', version: '0.0.0' })
register(server, bridge)
const client = new Client({ name: 'test-client', version: '0.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
return {
client,
server,
bridge,
close: async () => {
await client.close()
await server.close()
},
}
}
/** Reset the store between tests so temporal history and nodes don't leak. */
function resetScene(): void {
useScene.getState().unloadScene()
useScene.temporal.getState().clear()
}
describe('pascal://scene/current', () => {
beforeEach(() => resetScene())
test('returns the full scene JSON', async () => {
const pair = await spinUp(registerSceneCurrent)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({ uri: 'pascal://scene/current' })
expect(res.contents).toHaveLength(1)
const content = res.contents[0]
expect(content).toBeDefined()
const c = content as { uri: string; mimeType?: string; text?: string }
expect(c.mimeType).toBe('application/json')
expect(c.uri).toBe('pascal://scene/current')
const parsed = JSON.parse(c.text ?? '{}')
expect(parsed).toHaveProperty('nodes')
expect(parsed).toHaveProperty('rootNodeIds')
expect(parsed).toHaveProperty('collections')
expect(Array.isArray(parsed.rootNodeIds)).toBe(true)
expect(parsed.rootNodeIds.length).toBeGreaterThan(0)
} finally {
await pair.close()
}
})
test('reflects mutations to the store', async () => {
const pair = await spinUp(registerSceneCurrent)
try {
pair.bridge.loadDefault()
const beforeRes = await pair.client.readResource({
uri: 'pascal://scene/current',
})
const beforeText = (beforeRes.contents[0] as { text: string }).text
const before = JSON.parse(beforeText)
const beforeCount = Object.keys(before.nodes).length
// Add a zone.
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
const zone = ZoneNode.parse({
name: 'Living',
parentId: level.id,
polygon: [
[0, 0],
[3, 0],
[3, 3],
[0, 3],
],
})
pair.bridge.createNode(zone, level.id as never)
const afterRes = await pair.client.readResource({
uri: 'pascal://scene/current',
})
const afterText = (afterRes.contents[0] as { text: string }).text
const after = JSON.parse(afterText)
expect(Object.keys(after.nodes).length).toBe(beforeCount + 1)
} finally {
await pair.close()
}
})
})
describe('pascal://scene/current/summary', () => {
beforeEach(() => resetScene())
test('returns markdown with counts and bbox', async () => {
const pair = await spinUp(registerSceneSummary)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('text/markdown')
const text = content.text ?? ''
expect(text.startsWith('# Scene summary')).toBe(true)
expect(text).toContain('Sites:')
expect(text).toContain('Buildings:')
expect(text).toContain('Levels:')
expect(text).toContain('Scene bbox')
} finally {
await pair.close()
}
})
test('estimated floor area sums zone polygon areas', async () => {
const pair = await spinUp(registerSceneSummary)
try {
pair.bridge.loadDefault()
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
const zone = ZoneNode.parse({
name: 'Big',
parentId: level.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
pair.bridge.createNode(zone, level.id as never)
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const text = (res.contents[0] as { text: string }).text
expect(text).toContain('12.00 m^2')
} finally {
await pair.close()
}
})
test('empty scene returns a markdown skeleton without crashing', async () => {
const pair = await spinUp(registerSceneSummary)
try {
// deliberately do NOT call loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const text = (res.contents[0] as { text: string }).text
expect(text.startsWith('# Scene summary')).toBe(true)
expect(text).toContain('Total nodes: 0')
} finally {
await pair.close()
}
})
})
describe('pascal://catalog/items', () => {
beforeEach(() => resetScene())
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('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()
}
})
})
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())
test('returns slabs + wall footprints for a known level', async () => {
const pair = await spinUp(registerConstraints)
try {
pair.bridge.loadDefault()
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
// Add a wall so wallPolygons is non-empty.
const wall = WallNode.parse({
parentId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
pair.bridge.createNode(wall, level.id as never)
const res = await pair.client.readResource({
uri: `pascal://constraints/${level.id}`,
})
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.levelId).toBe(level.id)
expect(Array.isArray(parsed.slabs)).toBe(true)
expect(Array.isArray(parsed.wallPolygons)).toBe(true)
expect(parsed.wallPolygons.length).toBe(1)
expect(parsed.wallPolygons[0].wallId).toBe(wall.id)
expect(Array.isArray(parsed.wallPolygons[0].footprint)).toBe(true)
expect(parsed.wallPolygons[0].footprint.length).toBeGreaterThan(0)
// Each footprint point should be [x, y].
for (const pt of parsed.wallPolygons[0].footprint) {
expect(pt).toHaveLength(2)
}
} finally {
await pair.close()
}
})
test('returns {error:"level_not_found"} for unknown levelId', async () => {
const pair = await spinUp(registerConstraints)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://constraints/level_nope',
})
const content = res.contents[0] as { text?: string }
const parsed = JSON.parse(content.text ?? '{}')
expect(parsed.error).toBe('level_not_found')
expect(parsed.slabs).toEqual([])
expect(parsed.wallPolygons).toEqual([])
} finally {
await pair.close()
}
})
})
@@ -0,0 +1,29 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
/**
* `pascal://scene/current` — full `{ nodes, rootNodeIds, collections }` snapshot.
*
* Static URI (not a template). MIME `application/json`.
*/
export function registerSceneCurrent(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'scene-current',
'pascal://scene/current',
{
title: 'Current scene',
description:
'Complete snapshot of the live Pascal scene: nodes dict, rootNodeIds, collections.',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(bridge.exportJSON()),
},
],
}),
)
}
+218
View File
@@ -0,0 +1,218 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeType } from '@pascal-app/core/schema'
import type { SceneOperations } from '../operations'
type Poly2D = ReadonlyArray<readonly [number, number]>
/** Shoelace polygon area (absolute, square meters). */
function polygonArea(poly: Poly2D): number {
if (!Array.isArray(poly) || poly.length < 3) return 0
let sum = 0
for (let i = 0; i < poly.length; i++) {
const a = poly[i]
const b = poly[(i + 1) % poly.length]
if (!a || !b) continue
sum += a[0] * b[1] - b[0] * a[1]
}
return Math.abs(sum) / 2
}
type BBox = {
min: [number, number, number]
max: [number, number, number]
empty: boolean
}
function emptyBBox(): BBox {
return {
min: [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
max: [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
empty: true,
}
}
function expandBBox(bbox: BBox, x: number, y: number, z: number): void {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return
bbox.empty = false
if (x < bbox.min[0]) bbox.min[0] = x
if (y < bbox.min[1]) bbox.min[1] = y
if (z < bbox.min[2]) bbox.min[2] = z
if (x > bbox.max[0]) bbox.max[0] = x
if (y > bbox.max[1]) bbox.max[1] = y
if (z > bbox.max[2]) bbox.max[2] = z
}
/** Fold a node's world-relevant points into the running bbox. */
function foldNodeIntoBBox(node: AnyNode, bbox: BBox): void {
// Walls / fences: 2D start/end. Treat missing y as 0.
if (node.type === 'wall' || node.type === 'fence') {
const anyNode = node as { start?: [number, number]; end?: [number, number] }
if (anyNode.start) expandBBox(bbox, anyNode.start[0], 0, anyNode.start[1])
if (anyNode.end) expandBBox(bbox, anyNode.end[0], 0, anyNode.end[1])
return
}
// Zone / slab / ceiling: polygon + optional holes. Treat ground plane y=0.
if (node.type === 'zone' || node.type === 'slab' || node.type === 'ceiling') {
const poly = (node as { polygon?: Array<[number, number]> }).polygon
if (Array.isArray(poly)) {
for (const p of poly) {
if (Array.isArray(p) && p.length >= 2) expandBBox(bbox, p[0], 0, p[1])
}
}
return
}
// Positioned nodes (building/item/roof/stair/scan/guide/...):
const pos = (node as { position?: [number, number, number] }).position
if (Array.isArray(pos) && pos.length >= 3) {
expandBBox(bbox, pos[0], pos[1], pos[2])
}
}
function countByType(nodes: AnyNode[]): Record<string, number> {
const out: Record<string, number> = {}
for (const n of nodes) {
out[n.type] = (out[n.type] ?? 0) + 1
}
return out
}
/** Build the markdown summary. Pure over the SceneGraph snapshot. */
export function buildSceneSummaryMarkdown(
snapshot: ReturnType<SceneOperations['exportJSON']>,
): string {
const { nodes, rootNodeIds } = snapshot
const allNodes = Object.values(nodes) as AnyNode[]
const sites = allNodes.filter((n) => n.type === 'site')
const buildings = allNodes.filter((n) => n.type === 'building')
const levels = allNodes.filter((n) => n.type === 'level')
const bbox = emptyBBox()
for (const n of allNodes) foldNodeIntoBBox(n, bbox)
const lines: string[] = []
lines.push('# Scene summary')
lines.push('')
lines.push(`- Sites: ${sites.length} Buildings: ${buildings.length} Levels: ${levels.length}`)
lines.push(`- Root nodes: ${rootNodeIds.length}`)
lines.push(`- Total nodes: ${allNodes.length}`)
lines.push('')
// Hierarchy table
lines.push('## Hierarchy')
lines.push('')
lines.push('| Site | Building | Level |')
lines.push('| --- | --- | --- |')
if (sites.length === 0 && buildings.length === 0 && levels.length === 0) {
lines.push('| _(empty scene)_ | | |')
} else {
for (const site of sites) {
const sName = (site as { name?: string }).name ?? site.id
const siteBuildings = allNodes.filter((n) => n.type === 'building' && n.parentId === site.id)
if (siteBuildings.length === 0) {
lines.push(`| ${sName} | _(none)_ | |`)
continue
}
for (const b of siteBuildings) {
const bName = (b as { name?: string }).name ?? b.id
const bLevels = allNodes.filter((n) => n.type === 'level' && n.parentId === b.id)
if (bLevels.length === 0) {
lines.push(`| ${sName} | ${bName} | _(none)_ |`)
continue
}
for (const l of bLevels) {
const lName = (l as { name?: string }).name ?? l.id
lines.push(`| ${sName} | ${bName} | ${lName} |`)
}
}
}
}
lines.push('')
// Per-level detail
if (levels.length > 0) {
lines.push('## Per level')
lines.push('')
for (const level of levels) {
const lName = (level as { name?: string }).name ?? level.id
// Nodes whose ancestry includes this level.
const levelNodes = allNodes.filter(
(n) => n.id !== level.id && walkToLevel(n, nodes as Record<string, AnyNode>) === level.id,
)
const counts = countByType(levelNodes)
const countKeys = Object.keys(counts).sort() as AnyNodeType[]
// Estimated floor area = sum of zone polygon areas on this level.
const zones = levelNodes.filter((n) => n.type === 'zone') as Array<
AnyNode & { polygon: Array<[number, number]> }
>
let floorAreaSq = 0
for (const z of zones) {
floorAreaSq += polygonArea(z.polygon)
}
lines.push(`### ${lName}`)
lines.push('')
if (countKeys.length === 0) {
lines.push('- _(no descendants)_')
} else {
const parts = countKeys.map((k) => `${k}=${counts[k]}`)
lines.push(`- Node counts: ${parts.join(', ')}`)
}
lines.push(`- Estimated floor area (zones): ${floorAreaSq.toFixed(2)} m^2`)
lines.push('')
}
}
// BBox
lines.push('## Scene bbox (meters)')
lines.push('')
if (bbox.empty) {
lines.push('- _(no positioned nodes)_')
} else {
lines.push(`- min: [${bbox.min.map((v) => v.toFixed(3)).join(', ')}]`)
lines.push(`- max: [${bbox.max.map((v) => v.toFixed(3)).join(', ')}]`)
}
return lines.join('\n')
}
/** Walk up parentId until we find a level; return its id or null. */
function walkToLevel(node: AnyNode, nodes: Record<string, AnyNode>): string | null {
const seen = new Set<string>()
let current: AnyNode | undefined = node
while (current && !seen.has(current.id)) {
seen.add(current.id)
if (current.type === 'level') return current.id
const pid: string | null = current.parentId
if (!pid) return null
current = nodes[pid]
}
return null
}
/**
* `pascal://scene/current/summary` — human-readable scene overview.
* MIME `text/markdown`.
*/
export function registerSceneSummary(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'scene-summary',
'pascal://scene/current/summary',
{
title: 'Scene summary (markdown)',
description:
'Markdown overview: sites/buildings/levels, per-level node counts, zone floor areas, scene bbox.',
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: buildSceneSummaryMarkdown(bridge.exportJSON()),
},
],
}),
)
}
+94
View File
@@ -0,0 +1,94 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from './bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from './operations'
import { registerPrompts } from './prompts'
import { registerResources } from './resources'
import { createSceneStore } from './storage'
import type {
SceneEvent,
SceneEventAppendOptions,
SceneEventListOptions,
SceneListOptions,
SceneMeta,
SceneMutateOptions,
SceneSaveOptions,
SceneStore,
SceneWithGraph,
} from './storage/types'
import { registerTools } from './tools'
import { registerVisionTools } from './tools/vision'
export type CreatePascalMcpServerOptions = {
bridge: SceneBridge
operations?: SceneOperations
/** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */
store?: SceneStore
name?: string
version?: string
}
export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpServer {
const server = new McpServer({
name: opts.name ?? 'pascal-mcp',
version: opts.version ?? '0.1.0',
})
const store = opts.store ?? createLazySceneStore()
const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store })
registerTools(server, operations)
registerVisionTools(server, operations)
registerResources(server, operations)
registerPrompts(server, operations)
return server
}
/**
* Wrap `createSceneStore()` (which is async) behind a synchronous `SceneStore`
* facade so that `createPascalMcpServer` can remain synchronous. Each method
* resolves the underlying store on first use and memoizes it afterwards.
*/
function createLazySceneStore(): SceneStore {
let cached: Promise<SceneStore> | null = null
const resolve = (): Promise<SceneStore> => {
if (!cached) cached = createSceneStore()
return cached
}
return {
get backend(): 'sqlite' {
return 'sqlite'
},
async save(options: SceneSaveOptions): Promise<SceneMeta> {
const real = await resolve()
return real.save(options)
},
async load(id: string): Promise<SceneWithGraph | null> {
const real = await resolve()
return real.load(id)
},
async list(options?: SceneListOptions): Promise<SceneMeta[]> {
const real = await resolve()
return real.list(options)
},
async delete(id: string, options?: SceneMutateOptions): Promise<boolean> {
const real = await resolve()
return real.delete(id, options)
},
async rename(id: string, newName: string, options?: SceneMutateOptions): Promise<SceneMeta> {
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)
},
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { SceneStore } from './types'
export * from './slug'
export * from './sqlite-scene-store'
export * from './types'
/**
* Factory for Pascal's local-first scene store.
*
* The store is backed by the runtime's built-in SQLite driver. By default it
* writes to `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file
* path or `PASCAL_DATA_DIR` for a directory containing `pascal.db`.
*/
export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise<SceneStore> {
const mod = await import('./sqlite-scene-store')
return new mod.SqliteSceneStore({ env })
}
+66
View File
@@ -0,0 +1,66 @@
const MAX_SLUG_LENGTH = 64
const GENERATED_SLUG_LENGTH = 12
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
/**
* Normalizes a raw string into a slug:
* - lowercase
* - spaces → hyphen
* - strip non [a-z0-9-]
* - collapse consecutive hyphens
* - trim hyphens from ends
* - enforce ≤ 64 chars
*
* Throws if the result is empty.
*/
export function sanitizeSlug(raw: string): string {
const sanitized = raw
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG_LENGTH)
.replace(/-+$/g, '')
if (sanitized.length === 0) {
throw new Error('Slug cannot be empty after sanitization')
}
return sanitized
}
/**
* Checks if a string is already a valid slug (no sanitization performed).
*/
export function isValidSlug(s: string): boolean {
if (typeof s !== 'string') return false
if (s.length === 0 || s.length > MAX_SLUG_LENGTH) return false
return SLUG_PATTERN.test(s)
}
/**
* Generates a fresh 12-char lowercase alphanumeric slug using crypto randomness.
*/
export function generateSlug(): string {
const raw = globalThis.crypto?.randomUUID?.().replace(/-/g, '') ?? fallbackRandom()
const base = raw.toLowerCase().replace(/[^a-z0-9]/g, '')
if (base.length >= GENERATED_SLUG_LENGTH) {
return base.slice(0, GENERATED_SLUG_LENGTH)
}
// Pad with additional random chars if for any reason the base is short.
let out = base
while (out.length < GENERATED_SLUG_LENGTH) {
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
}
return out.slice(0, GENERATED_SLUG_LENGTH)
}
function fallbackRandom(): string {
let out = ''
for (let i = 0; i < GENERATED_SLUG_LENGTH * 2; i++) {
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
}
return out
}
+77
View File
@@ -0,0 +1,77 @@
type SqliteBinding = string | number | bigint | boolean | null | Uint8Array
export interface SqliteRunResult {
changes: number
lastInsertRowid: number | bigint
}
export interface SqliteStatement {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
export interface SqliteDatabase {
exec(sql: string): void
query(sql: string): SqliteStatement
close(): void
}
type BunSqliteModule = {
Database: new (
filename: string,
options?: { create?: boolean; readwrite?: boolean },
) => SqliteDatabase
}
type NodeStatementSync = {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
type NodeDatabaseSync = {
exec(sql: string): void
prepare(sql: string): NodeStatementSync
close(): void
}
type NodeSqliteModule = {
DatabaseSync: new (filename: string) => NodeDatabaseSync
}
export async function openSqliteDatabase(filename: string): Promise<SqliteDatabase> {
if ('Bun' in globalThis) {
const mod = (await import('bun:sqlite')) as BunSqliteModule
return new mod.Database(filename, { create: true, readwrite: true })
}
try {
const mod = (await import('node:sqlite')) as NodeSqliteModule
return adaptNodeDatabase(new mod.DatabaseSync(filename))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`SQLite requires Bun or a Node runtime with node:sqlite support. Failed to open ${filename}: ${reason}`,
)
}
}
function adaptNodeDatabase(db: NodeDatabaseSync): SqliteDatabase {
return {
exec(sql: string): void {
db.exec(sql)
},
query(sql: string): SqliteStatement {
const stmt = db.prepare(sql)
return {
all: (...params) => stmt.all(...params),
get: (...params) => stmt.get(...params),
run: (...params) => stmt.run(...params),
}
},
close(): void {
db.close()
},
}
}
@@ -0,0 +1,326 @@
import { Database } from 'bun:sqlite'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import {
resolveDefaultDatabasePath,
SqliteSceneStore,
type SqliteSceneStoreOptions,
} from './sqlite-scene-store'
import { SceneInvalidError, SceneTooLargeError, SceneVersionConflictError } from './types'
function makeGraph(overrides: Partial<SceneGraph> = {}): SceneGraph {
return {
nodes: {
site_abc: {
object: 'node',
id: 'site_abc',
type: 'site',
parentId: null,
visible: true,
metadata: {},
},
building_def: {
object: 'node',
id: 'building_def',
type: 'building',
parentId: 'site_abc',
visible: true,
metadata: {},
},
} as SceneGraph['nodes'],
rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'],
...overrides,
}
}
async function mkTmpRoot(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-sqlite-test-'))
}
async function rmrf(p: string): Promise<void> {
await fs.rm(p, { recursive: true, force: true })
}
function createStore(rootDir: string, opts: Partial<SqliteSceneStoreOptions> = {}) {
return new SqliteSceneStore({
databasePath: path.join(rootDir, 'pascal.db'),
...opts,
})
}
describe('resolveDefaultDatabasePath', () => {
test('respects PASCAL_DB_PATH when set', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DB_PATH: '/tmp/custom.db' })).toBe('/tmp/custom.db')
})
test('resolves PASCAL_DATA_DIR to pascal.db', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DATA_DIR: '/tmp/pascal-data' })).toBe(
path.join('/tmp/pascal-data', 'pascal.db'),
)
})
test('falls back to XDG_DATA_HOME on Unix', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({ XDG_DATA_HOME: '/xdg/share' })).toBe(
path.join('/xdg/share', 'pascal', 'data', 'pascal.db'),
)
})
test('falls back to homedir + .pascal/data/pascal.db', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({}).endsWith(path.join('.pascal', 'data', 'pascal.db'))).toBe(
true,
)
})
})
describe('SqliteSceneStore', () => {
let rootDir: string
let store: SqliteSceneStore
beforeEach(async () => {
rootDir = await mkTmpRoot()
store = createStore(rootDir)
})
afterEach(async () => {
store.close()
await rmrf(rootDir)
})
test('backend is "sqlite"', () => {
expect(store.backend).toBe('sqlite')
})
test('round-trips a saved scene through a reopened database', async () => {
const graph = makeGraph()
const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph })
expect(saved.id).toBe('kitchen')
expect(saved.version).toBe(1)
expect(saved.nodeCount).toBe(2)
expect(saved.sizeBytes).toBe(Buffer.byteLength(JSON.stringify(graph), 'utf8'))
store.close()
store = createStore(rootDir)
const loaded = await store.load('kitchen')
expect(loaded).not.toBeNull()
expect(loaded!.graph).toEqual(graph)
expect(loaded!.name).toBe('Kitchen')
})
test('stores optional metadata verbatim', async () => {
await store.save({
id: 'meta-test',
name: 'Meta',
graph: makeGraph(),
projectId: 'proj-1',
ownerId: 'user-42',
thumbnailUrl: 'https://example.com/t.png',
})
const loaded = await store.load('meta-test')
expect(loaded?.projectId).toBe('proj-1')
expect(loaded?.ownerId).toBe('user-42')
expect(loaded?.thumbnailUrl).toBe('https://example.com/t.png')
})
test('generates ids for new scenes and rejects explicit slug collisions', async () => {
const a = await store.save({ name: 'A', graph: makeGraph() })
const b = await store.save({ name: 'B', graph: makeGraph() })
expect(a.id).not.toBe(b.id)
await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() })
await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
})
test('sanitizes explicit ids', async () => {
const meta = await store.save({ id: '../My Kitchen!', name: 'Kitchen', graph: makeGraph() })
expect(meta.id).toBe('my-kitchen')
expect(await store.load('my-kitchen')).not.toBeNull()
})
test('increments version and preserves createdAt on overwrite', async () => {
const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() })
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await store.save({
id: 'bump',
name: 'Bump 2',
graph: makeGraph(),
expectedVersion: 1,
})
expect(second.version).toBe(2)
expect(second.createdAt).toBe(first.createdAt)
expect(second.updatedAt >= first.updatedAt).toBe(true)
})
test('enforces optimistic locking for save, rename, and delete', async () => {
await store.save({ id: 'locked', name: 'Locked', graph: makeGraph() })
await expect(
store.save({ id: 'locked', name: 'Locked', graph: makeGraph(), expectedVersion: 99 }),
).rejects.toThrow(SceneVersionConflictError)
await expect(store.rename('locked', 'New', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
await expect(store.delete('locked', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
})
test('expectedVersion=0 creates a brand-new explicit id', async () => {
const meta = await store.save({
id: 'fresh',
name: 'Fresh',
graph: makeGraph(),
expectedVersion: 0,
})
expect(meta.version).toBe(1)
})
test('lists newest first and supports project, owner, and limit filters', async () => {
await store.save({ id: 'a', name: 'A', graph: makeGraph(), projectId: 'p1', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'b', name: 'B', graph: makeGraph(), projectId: 'p2', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'c', name: 'C', graph: makeGraph(), projectId: 'p1', ownerId: 'u2' })
expect((await store.list()).map((m) => m.id)).toEqual(['c', 'b', 'a'])
expect((await store.list({ projectId: 'p1' })).map((m) => m.id)).toEqual(['c', 'a'])
expect((await store.list({ ownerId: 'u1' })).map((m) => m.id)).toEqual(['b', 'a'])
expect((await store.list({ limit: 2 })).map((m) => m.id)).toEqual(['c', 'b'])
})
test('rename writes a revision row and delete cascades revisions', async () => {
await store.save({ id: 'rev', name: 'Rev', graph: makeGraph() })
await store.rename('rev', 'Renamed', { expectedVersion: 1 })
const dbPath = path.join(rootDir, 'pascal.db')
const db = new Database(dbPath)
try {
const beforeDelete = db
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(beforeDelete.count).toBe(2)
} finally {
db.close()
}
expect(await store.delete('rev', { expectedVersion: 2 })).toBe(true)
const reopened = new Database(dbPath)
try {
const afterDelete = reopened
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(afterDelete.count).toBe(0)
} finally {
reopened.close()
}
})
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(
SceneInvalidError,
)
const tinyStore = createStore(rootDir, {
databasePath: path.join(rootDir, 'tiny.db'),
maxSceneBytes: 100,
})
try {
await expect(tinyStore.save({ id: 'big', name: 'Big', graph: makeGraph() })).rejects.toThrow(
SceneTooLargeError,
)
} finally {
tinyStore.close()
}
})
test('load returns null for missing scenes and errors on corrupt graph rows', async () => {
expect(await store.load('missing')).toBeNull()
const db = new Database(path.join(rootDir, 'pascal.db'), { create: true })
try {
db.exec(`
CREATE TABLE IF NOT EXISTS scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
owner_id TEXT,
thumbnail_url TEXT,
version INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
node_count INTEGER NOT NULL,
graph_json TEXT NOT NULL
);
`)
db.query(
`INSERT INTO scenes (
id, name, version, created_at, updated_at, size_bytes, node_count, graph_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run('bad', 'Bad', 1, '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 2, 0, '{}')
} finally {
db.close()
}
await expect(store.load('bad')).rejects.toThrow(SceneInvalidError)
})
})
@@ -0,0 +1,579 @@
import { mkdirSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
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,
type SceneMutateOptions,
SceneNotFoundError,
type SceneSaveOptions,
type SceneStore,
SceneTooLargeError,
SceneVersionConflictError,
type SceneWithGraph,
} from './types'
const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024
const DEFAULT_LIST_LIMIT = 100
const MAX_NAME_LENGTH = 200
const MIN_NAME_LENGTH = 1
export interface SqliteSceneStoreOptions {
/** Exact SQLite database file path. If omitted, resolved from env. */
databasePath?: string
/** Optional env override for default path and size-limit resolution. */
env?: NodeJS.ProcessEnv
/** Maximum UTF-8 byte length of graph JSON. Defaults to 10 MB. */
maxSceneBytes?: number
}
interface SceneRow {
id: string
name: string
project_id: string | null
owner_id: string | null
thumbnail_url: string | null
version: number
created_at: string
updated_at: string
size_bytes: number
node_count: number
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()),
collections: z.record(z.string(), z.unknown()).optional(),
})
/**
* Resolves Pascal's local SQLite database path.
*
* Precedence:
* 1. `PASCAL_DB_PATH`
* 2. `PASCAL_DATA_DIR/pascal.db`
* 3. On Windows: `%APPDATA%/Pascal/data/pascal.db`
* 4. `$XDG_DATA_HOME/pascal/data/pascal.db`
* 5. `$HOME/.pascal/data/pascal.db`
*/
export function resolveDefaultDatabasePath(env: NodeJS.ProcessEnv = process.env): string {
if (env.PASCAL_DB_PATH && env.PASCAL_DB_PATH.length > 0) {
return env.PASCAL_DB_PATH
}
if (env.PASCAL_DATA_DIR && env.PASCAL_DATA_DIR.length > 0) {
return path.join(env.PASCAL_DATA_DIR, 'pascal.db')
}
if (process.platform === 'win32') {
const appData = env.APPDATA
if (appData && appData.length > 0) {
return path.join(appData, 'Pascal', 'data', 'pascal.db')
}
return path.join(os.homedir(), '.pascal', 'data', 'pascal.db')
}
const xdg = env.XDG_DATA_HOME
if (xdg && xdg.length > 0) {
return path.join(xdg, 'pascal', 'data', 'pascal.db')
}
return path.join(os.homedir(), '.pascal', 'data', 'pascal.db')
}
function resolveMaxSceneBytes(
env: NodeJS.ProcessEnv | undefined,
explicit: number | undefined,
): number {
if (explicit !== undefined) {
if (!Number.isInteger(explicit) || explicit <= 0) {
throw new SceneInvalidError('maxSceneBytes must be a positive integer')
}
return explicit
}
const raw = env?.PASCAL_MAX_SCENE_BYTES
if (raw === undefined || raw === '') return DEFAULT_MAX_SCENE_BYTES
const parsed = Number.parseInt(raw, 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new SceneInvalidError('PASCAL_MAX_SCENE_BYTES must be a positive integer')
}
return parsed
}
function rowToMeta(row: SceneRow): SceneMeta {
return {
id: row.id,
name: row.name,
projectId: row.project_id,
ownerId: row.owner_id,
thumbnailUrl: row.thumbnail_url,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at,
sizeBytes: row.size_bytes,
nodeCount: row.node_count,
}
}
function assertValidName(name: string): void {
if (typeof name !== 'string') {
throw new SceneInvalidError('Scene name must be a string')
}
const trimmed = name.trim()
if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) {
throw new SceneInvalidError(
`Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`,
)
}
}
function serializeGraph(graph: SceneGraph): string {
return JSON.stringify(graph)
}
function parseGraph(raw: string, context: string): SceneGraph {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (err) {
throw new SceneInvalidError(
`Failed to parse scene graph for ${context}: ${err instanceof Error ? err.message : String(err)}`,
)
}
const result = GraphSchema.safeParse(parsed)
if (!result.success) {
throw new SceneInvalidError(`Scene graph for ${context} has invalid shape: ${result.error}`)
}
const graph = result.data
for (const [nodeId, node] of Object.entries(graph.nodes)) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw new SceneInvalidError(`Scene graph for ${context} has non-object node at "${nodeId}"`)
}
const typeField = (node as { type?: unknown }).type
if (typeof typeField !== 'string' || typeField.length === 0) {
throw new SceneInvalidError(
`Scene graph for ${context} has node "${nodeId}" missing a string "type"`,
)
}
}
return graph as SceneGraph
}
function asSceneRow(value: unknown): SceneRow | null {
if (!value || typeof value !== 'object') return 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`.
*
* Uses one local database file, WAL mode, and transaction-scoped version checks
* so a local editor and MCP process can safely share scenes on one machine.
*/
export class SqliteSceneStore implements SceneStore {
readonly backend = 'sqlite' as const
readonly databasePath: string
private readonly maxSceneBytes: number
private db: SqliteDatabase | null = null
private dbPromise: Promise<SqliteDatabase> | null = null
constructor(opts: SqliteSceneStoreOptions = {}) {
const env = opts.env ?? process.env
this.databasePath = path.resolve(opts.databasePath ?? resolveDefaultDatabasePath(env))
this.maxSceneBytes = resolveMaxSceneBytes(env, opts.maxSceneBytes)
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
return this.withWriteTransaction((db) => {
assertValidName(opts.name)
if (!opts.graph || typeof opts.graph !== 'object') {
throw new SceneInvalidError('graph is required')
}
const providedId = opts.id
const id = providedId ? sanitizeSlug(providedId) : this.generateUniqueId(db)
if (!isValidSlug(id)) {
throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`)
}
const existing = this.getRow(db, id)
if (existing && providedId !== undefined && opts.expectedVersion === undefined) {
throw new SceneInvalidError(
`Scene with id "${id}" already exists. Pass a different id or provide expectedVersion to overwrite.`,
)
}
if (opts.expectedVersion !== undefined) {
const currentVersion = existing?.version ?? 0
if (currentVersion !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`,
)
}
}
const graphJson = serializeGraph(opts.graph)
const sizeBytes = Buffer.byteLength(graphJson, 'utf8')
if (sizeBytes > this.maxSceneBytes) {
throw new SceneTooLargeError(
`Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${this.maxSceneBytes} bytes`,
)
}
const now = new Date().toISOString()
const version = (existing?.version ?? 0) + 1
const createdAt = existing?.created_at ?? now
const nodeCount = Object.keys(opts.graph.nodes ?? {}).length
if (existing) {
db.query(
`UPDATE scenes
SET name = ?,
project_id = ?,
owner_id = ?,
thumbnail_url = ?,
version = ?,
updated_at = ?,
size_bytes = ?,
node_count = ?,
graph_json = ?
WHERE id = ?`,
).run(
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
version,
now,
sizeBytes,
nodeCount,
graphJson,
id,
)
} else {
db.query(
`INSERT INTO scenes (
id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
version,
createdAt,
now,
sizeBytes,
nodeCount,
graphJson,
)
}
db.query(
`INSERT INTO scene_revisions (
scene_id, version, graph_json, author_kind, author_id, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, version, graphJson, 'mcp', opts.ownerId ?? null, now)
return {
id,
name: opts.name,
projectId: opts.projectId ?? null,
ownerId: opts.ownerId ?? null,
thumbnailUrl: opts.thumbnailUrl ?? null,
version,
createdAt,
updatedAt: now,
sizeBytes,
nodeCount,
}
})
}
async load(id: string): Promise<SceneWithGraph | null> {
const db = await this.database()
const row = this.getRow(db, sanitizeSlug(id))
if (!row) return null
return {
...rowToMeta(row),
graph: parseGraph(row.graph_json, row.id),
}
}
async list(opts: SceneListOptions = {}): Promise<SceneMeta[]> {
const clauses: string[] = []
const bindings: Array<string | number> = []
if (opts.projectId !== undefined) {
clauses.push('project_id = ?')
bindings.push(opts.projectId)
}
if (opts.ownerId !== undefined) {
clauses.push('owner_id = ?')
bindings.push(opts.ownerId)
}
const requestedLimit = opts.limit ?? DEFAULT_LIST_LIMIT
const limit = Number.isInteger(requestedLimit) && requestedLimit >= 0 ? requestedLimit : 0
bindings.push(limit)
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
const db = await this.database()
const rows = db
.query(
`SELECT id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
FROM scenes
${where}
ORDER BY updated_at DESC, id ASC
LIMIT ?`,
)
.all(...bindings)
return rows.map((row) => rowToMeta(row as SceneRow))
}
async delete(id: string, opts: SceneMutateOptions = {}): Promise<boolean> {
return this.withWriteTransaction((db) => {
const safeId = sanitizeSlug(id)
const existing = this.getRow(db, safeId)
if (!existing) return false
if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`,
)
}
db.query('DELETE FROM scenes WHERE id = ?').run(safeId)
return true
})
}
async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise<SceneMeta> {
return this.withWriteTransaction((db) => {
assertValidName(newName)
const safeId = sanitizeSlug(id)
const existing = this.getRow(db, safeId)
if (!existing) {
throw new SceneNotFoundError(`Scene "${safeId}" not found`)
}
if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`,
)
}
const now = new Date().toISOString()
const nextVersion = existing.version + 1
db.query('UPDATE scenes SET name = ?, version = ?, updated_at = ? WHERE id = ?').run(
newName,
nextVersion,
now,
safeId,
)
db.query(
`INSERT INTO scene_revisions (
scene_id, version, graph_json, author_kind, author_id, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).run(safeId, nextVersion, existing.graph_json, 'mcp', existing.owner_id, now)
return {
...rowToMeta(existing),
name: newName,
version: nextVersion,
updatedAt: now,
}
})
}
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
this.dbPromise = null
}
private async database(): Promise<SqliteDatabase> {
if (this.db) return this.db
if (!this.dbPromise) {
this.dbPromise = (async () => {
mkdirSync(path.dirname(this.databasePath), { recursive: true })
const db = await openSqliteDatabase(this.databasePath)
db.exec('PRAGMA foreign_keys = ON')
db.exec('PRAGMA journal_mode = WAL')
db.exec('PRAGMA busy_timeout = 5000')
this.migrate(db)
this.db = db
return db
})()
}
return this.dbPromise
}
private migrate(db: SqliteDatabase): void {
db.exec(`
CREATE TABLE IF NOT EXISTS scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL CHECK (length(name) >= 1 AND length(name) <= 200),
project_id TEXT,
owner_id TEXT,
thumbnail_url TEXT,
version INTEGER NOT NULL CHECK (version >= 1),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
node_count INTEGER NOT NULL CHECK (node_count >= 0),
graph_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS scenes_project_updated_idx
ON scenes(project_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS scenes_owner_updated_idx
ON scenes(owner_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS scene_revisions (
scene_id TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version >= 1),
graph_json TEXT NOT NULL,
author_kind TEXT NOT NULL,
author_id TEXT,
created_at TEXT NOT NULL,
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);
`)
}
private async withWriteTransaction<T>(fn: (db: SqliteDatabase) => T | Promise<T>): Promise<T> {
const db = await this.database()
db.exec('BEGIN IMMEDIATE')
try {
const result = await fn(db)
db.exec('COMMIT')
return result
} catch (err) {
try {
db.exec('ROLLBACK')
} catch {
// Ignore rollback errors so the original failure is preserved.
}
throw err
}
}
private getRow(db: SqliteDatabase, id: string): SceneRow | null {
return asSceneRow(
db
.query(
`SELECT id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
FROM scenes
WHERE id = ?`,
)
.get(id),
)
}
private generateUniqueId(db: SqliteDatabase): string {
for (let attempt = 0; attempt < 20; attempt++) {
const id = generateSlug()
if (!this.getRow(db, id)) return id
}
throw new SceneInvalidError('Failed to generate a unique scene id')
}
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
describe('sanitizeSlug', () => {
test('lowercases input', () => {
expect(sanitizeSlug('MyScene')).toBe('myscene')
})
test('converts spaces to hyphens', () => {
expect(sanitizeSlug('my awesome scene')).toBe('my-awesome-scene')
})
test('strips non alphanumeric characters', () => {
expect(sanitizeSlug('hello@world!_$%scene')).toBe('helloworldscene')
})
test('collapses runs of hyphens', () => {
expect(sanitizeSlug('a---b--c')).toBe('a-b-c')
})
test('collapses runs from mixed input', () => {
expect(sanitizeSlug('a b c')).toBe('a-b-c')
})
test('trims hyphens from the ends', () => {
expect(sanitizeSlug('---foo---')).toBe('foo')
})
test('enforces 64-char maximum', () => {
const long = 'a'.repeat(200)
const result = sanitizeSlug(long)
expect(result.length).toBeLessThanOrEqual(64)
expect(result).toBe('a'.repeat(64))
})
test('trims trailing hyphen after truncation', () => {
const raw = `${'a'.repeat(63)}-bbbbb`
const result = sanitizeSlug(raw)
expect(result.endsWith('-')).toBe(false)
expect(result.length).toBeLessThanOrEqual(64)
})
test('throws when result is empty', () => {
expect(() => sanitizeSlug('')).toThrow()
expect(() => sanitizeSlug('!!!')).toThrow()
expect(() => sanitizeSlug(' ')).toThrow()
})
test('preserves already-valid slugs', () => {
expect(sanitizeSlug('already-valid-123')).toBe('already-valid-123')
})
})
describe('isValidSlug', () => {
test('accepts typical slugs', () => {
expect(isValidSlug('my-scene')).toBe(true)
expect(isValidSlug('scene123')).toBe(true)
expect(isValidSlug('a')).toBe(true)
})
test('rejects empty string', () => {
expect(isValidSlug('')).toBe(false)
})
test('rejects uppercase', () => {
expect(isValidSlug('MyScene')).toBe(false)
})
test('rejects underscores and other punctuation', () => {
expect(isValidSlug('my_scene')).toBe(false)
expect(isValidSlug('my.scene')).toBe(false)
expect(isValidSlug('my/scene')).toBe(false)
})
test('rejects leading or trailing hyphens', () => {
expect(isValidSlug('-foo')).toBe(false)
expect(isValidSlug('foo-')).toBe(false)
})
test('rejects consecutive hyphens', () => {
expect(isValidSlug('foo--bar')).toBe(false)
})
test('rejects strings > 64 chars', () => {
expect(isValidSlug('a'.repeat(65))).toBe(false)
})
test('accepts exactly 64 chars', () => {
expect(isValidSlug('a'.repeat(64))).toBe(true)
})
})
describe('generateSlug', () => {
test('returns a 12-char string', () => {
const slug = generateSlug()
expect(slug).toHaveLength(12)
})
test('is lowercase alphanumeric', () => {
for (let i = 0; i < 50; i++) {
const slug = generateSlug()
expect(slug).toMatch(/^[a-z0-9]{12}$/)
}
})
test('passes isValidSlug', () => {
for (let i = 0; i < 20; i++) {
expect(isValidSlug(generateSlug())).toBe(true)
}
})
test('produces unique values across many calls', () => {
const seen = new Set<string>()
for (let i = 0; i < 200; i++) {
seen.add(generateSlug())
}
// Allow for a tiny chance of collision but near-certain uniqueness.
expect(seen.size).toBeGreaterThan(195)
})
})
// Note: createSceneStore() factory behavior is covered by the SQLite store
// tests. We avoid mock.module() here because bun's module mocks persist
// process-wide and pollute sibling test files.
+111
View File
@@ -0,0 +1,111 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
/**
* Slug-safe scene identifier: lowercase alphanumerics and hyphens, ≤ 64 chars.
*/
export type SceneId = string
export interface SceneMeta {
id: SceneId
name: string
projectId: string | null
thumbnailUrl: string | null
/** Monotonic, incremented on every save. */
version: number
/** ISO 8601 timestamp. */
createdAt: string
/** ISO 8601 timestamp. */
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
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
projectId?: string | null
ownerId?: string | null
graph: SceneGraph
thumbnailUrl?: string | null
/** When set, save fails with `SceneVersionConflictError` on mismatch. */
expectedVersion?: number
}
export interface SceneListOptions {
projectId?: string
ownerId?: string
limit?: number
}
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>
load(id: SceneId): Promise<SceneWithGraph | null>
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 {
readonly code = 'not_found' as const
constructor(message = 'Scene not found') {
super(message)
this.name = 'SceneNotFoundError'
}
}
export class SceneVersionConflictError extends Error {
readonly code = 'version_conflict' as const
constructor(message = 'Scene version conflict') {
super(message)
this.name = 'SceneVersionConflictError'
}
}
export class SceneInvalidError extends Error {
readonly code = 'invalid' as const
constructor(message = 'Scene invalid') {
super(message)
this.name = 'SceneInvalidError'
}
}
export class SceneTooLargeError extends Error {
readonly code = 'too_large' as const
constructor(message = 'Scene too large') {
super(message)
this.name = 'SceneTooLargeError'
}
}
+264
View File
@@ -0,0 +1,264 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
/**
* 40 m² studio apartment — a single open room with one window, one front door
* and a single "Living/Kitchen" zone. Used as a starting point for small unit
* briefs. Deterministic ids (`site_empty`, `building_empty`, `level_0`, etc.)
* are regenerated by the MCP tool via `cloneSceneGraph` before applying.
*/
// Footprint: 8 m × 5 m = 40 m² (centered at origin).
// Walls traverse the boundary counter-clockwise (right-handed XZ plane).
const W = 4 // half-width
const D = 2.5 // half-depth
type StudioNodes = {
site: AnyNode
building: AnyNode
level: AnyNode
walls: AnyNode[]
zone: AnyNode
door: AnyNode
window: AnyNode
}
function buildNodes(): StudioNodes {
const site: AnyNode = {
object: 'node',
id: 'site_empty' as AnyNodeId,
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: {
type: 'polygon',
points: [
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
],
},
children: ['building_empty' as AnyNodeId],
} as unknown as AnyNode
const building: AnyNode = {
object: 'node',
id: 'building_empty' as AnyNodeId,
type: 'building',
parentId: 'site_empty' as AnyNodeId,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
children: ['level_0' as AnyNodeId],
} as unknown as AnyNode
// Walls with deterministic ids; child ids are listed below after doors/windows
// are created, so we fill this array after computing them.
const wallIds = ['wall_n', 'wall_e', 'wall_s', 'wall_w'] as const
// South wall carries the front door; west wall carries the window.
const door: AnyNode = {
object: 'node',
id: 'door_front' as AnyNodeId,
type: 'door',
parentId: 'wall_s' as AnyNodeId,
visible: true,
metadata: {},
wallId: 'wall_s',
position: [0, 1.05, 0],
rotation: [0, 0, 0],
width: 0.9,
height: 2.1,
frameThickness: 0.05,
frameDepth: 0.07,
threshold: true,
thresholdHeight: 0.02,
hingesSide: 'left',
swingDirection: 'inward',
segments: [
{
type: 'panel',
heightRatio: 0.4,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
{
type: 'panel',
heightRatio: 0.6,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
],
handle: true,
handleHeight: 1.05,
handleSide: 'right',
contentPadding: [0.04, 0.04],
doorCloser: false,
panicBar: false,
panicBarHeight: 1.0,
} as unknown as AnyNode
const windowNode: AnyNode = {
object: 'node',
id: 'window_w' as AnyNodeId,
type: 'window',
parentId: 'wall_w' as AnyNodeId,
visible: true,
metadata: {},
wallId: 'wall_w',
position: [0, 1.2, 0],
rotation: [0, 0, 0],
width: 1.5,
height: 1.2,
frameThickness: 0.05,
frameDepth: 0.07,
columnRatios: [1],
rowRatios: [1],
columnDividerThickness: 0.03,
rowDividerThickness: 0.03,
sill: true,
sillDepth: 0.08,
sillThickness: 0.03,
} as unknown as AnyNode
const wallNorth: AnyNode = {
object: 'node',
id: 'wall_n' as AnyNodeId,
type: 'wall',
parentId: 'level_0' as AnyNodeId,
visible: true,
metadata: {},
children: [],
thickness: 0.1,
height: 2.5,
start: [-W, -D],
end: [W, -D],
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
const wallEast: AnyNode = {
object: 'node',
id: 'wall_e' as AnyNodeId,
type: 'wall',
parentId: 'level_0' as AnyNodeId,
visible: true,
metadata: {},
children: [],
thickness: 0.1,
height: 2.5,
start: [W, -D],
end: [W, D],
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
const wallSouth: AnyNode = {
object: 'node',
id: 'wall_s' as AnyNodeId,
type: 'wall',
parentId: 'level_0' as AnyNodeId,
visible: true,
metadata: {},
children: ['door_front'],
thickness: 0.1,
height: 2.5,
start: [W, D],
end: [-W, D],
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
const wallWest: AnyNode = {
object: 'node',
id: 'wall_w' as AnyNodeId,
type: 'wall',
parentId: 'level_0' as AnyNodeId,
visible: true,
metadata: {},
children: ['window_w'],
thickness: 0.1,
height: 2.5,
start: [-W, D],
end: [-W, -D],
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
const zone: AnyNode = {
object: 'node',
id: 'zone_living' as AnyNodeId,
type: 'zone',
parentId: 'level_0' as AnyNodeId,
visible: true,
metadata: {},
name: 'Living / Kitchen',
color: '#60a5fa',
polygon: [
[-W, -D],
[W, -D],
[W, D],
[-W, D],
],
} as unknown as AnyNode
const level: AnyNode = {
object: 'node',
id: 'level_0' as AnyNodeId,
type: 'level',
parentId: 'building_empty' as AnyNodeId,
visible: true,
metadata: {},
level: 0,
children: [...wallIds, 'zone_living'] as AnyNodeId[],
} as unknown as AnyNode
return {
site,
building,
level,
walls: [wallNorth, wallEast, wallSouth, wallWest],
zone,
door,
window: windowNode,
}
}
function buildTemplate(): SceneGraph {
const n = buildNodes()
const nodes: Record<AnyNodeId, AnyNode> = {}
for (const node of [n.site, n.building, n.level, ...n.walls, n.zone, n.door, n.window]) {
nodes[node.id as AnyNodeId] = node
}
// SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects
// (not string ids) — so the site must embed the full building node. The
// rest of the tree uses string ids per the BaseNode/LevelNode/WallNode
// schemas. We mutate the flat-dict copy of the site here so the nested
// representation round-trips through AnyNode.safeParse.
const siteInDict = nodes['site_empty' as AnyNodeId] as unknown as {
children: unknown[]
}
siteInDict.children = [nodes['building_empty' as AnyNodeId]]
return {
nodes,
rootNodeIds: ['site_empty'] as AnyNodeId[],
}
}
export const template: SceneGraph = buildTemplate()
export const metadata = {
id: 'empty-studio',
name: 'Empty studio',
description:
'40 m² single-room studio apartment: 4 walls, 1 living/kitchen zone, 1 window, 1 front door.',
} as const
+283
View File
@@ -0,0 +1,283 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
/**
* "Garden house" — a simplified take on the Casa del Sol layout used in the
* MCP research fixtures.
*
* Footprint: 12 m × 8 m house centered at the origin, with a 12 m × 6 m
* back garden zone immediately to the north of the house, surrounded by a
* privacy fence on three sides.
*
* Contents:
* - 4 perimeter walls around the house
* - 1 front door (south wall), 1 large garden door (north wall)
* - 2 windows on the south wall, 1 window on each of east and west
* - 1 indoor "living" zone, 1 outdoor "garden" zone
* - 3 fence segments bounding the north/east/west of the garden
*/
const HOUSE_W = 6 // half-width of the house (12 m total)
const HOUSE_D = 4 // half-depth of the house (8 m total)
const GARDEN_DEPTH = 6 // depth of the back-garden zone along +z direction
const WALL_THICKNESS = 0.15
const WALL_HEIGHT = 2.7
type NodeMap = Record<string, AnyNode>
function wall(
id: string,
start: [number, number],
end: [number, number],
children: string[] = [],
): AnyNode {
return {
object: 'node',
id,
type: 'wall',
parentId: 'level_0',
visible: true,
metadata: {},
children,
thickness: WALL_THICKNESS,
height: WALL_HEIGHT,
start,
end,
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
}
function door(id: string, parentWallId: string, width = 0.9): AnyNode {
return {
object: 'node',
id,
type: 'door',
parentId: parentWallId,
visible: true,
metadata: {},
wallId: parentWallId,
position: [0, 1.05, 0],
rotation: [0, 0, 0],
width,
height: 2.1,
frameThickness: 0.05,
frameDepth: 0.07,
threshold: true,
thresholdHeight: 0.02,
hingesSide: 'left',
swingDirection: 'inward',
segments: [
{
type: 'panel',
heightRatio: 0.5,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
{
type: 'panel',
heightRatio: 0.5,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
],
handle: true,
handleHeight: 1.05,
handleSide: 'right',
contentPadding: [0.04, 0.04],
doorCloser: false,
panicBar: false,
panicBarHeight: 1.0,
} as unknown as AnyNode
}
function makeWindow(id: string, parentWallId: string, width = 1.2): AnyNode {
return {
object: 'node',
id,
type: 'window',
parentId: parentWallId,
visible: true,
metadata: {},
wallId: parentWallId,
position: [0, 1.2, 0],
rotation: [0, 0, 0],
width,
height: 1.2,
frameThickness: 0.05,
frameDepth: 0.07,
columnRatios: [1],
rowRatios: [1],
columnDividerThickness: 0.03,
rowDividerThickness: 0.03,
sill: true,
sillDepth: 0.08,
sillThickness: 0.03,
} as unknown as AnyNode
}
function fence(id: string, start: [number, number], end: [number, number]): AnyNode {
return {
object: 'node',
id,
type: 'fence',
parentId: 'level_0',
visible: true,
metadata: {},
start,
end,
height: 1.8,
thickness: 0.08,
baseHeight: 0.22,
postSpacing: 2,
postSize: 0.1,
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.015,
baseStyle: 'grounded',
color: '#f3f4f6',
style: 'privacy',
} as unknown as AnyNode
}
function buildTemplate(): SceneGraph {
const nodes: NodeMap = {}
nodes.site_garden = {
object: 'node',
id: 'site_garden',
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: {
type: 'polygon',
points: [
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
],
},
children: ['building_garden'],
} as unknown as AnyNode
nodes.building_garden = {
object: 'node',
id: 'building_garden',
type: 'building',
parentId: 'site_garden',
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
children: ['level_0'],
} as unknown as AnyNode
// Openings
nodes.door_front = door('door_front', 'wall_s', 1.0)
nodes.door_garden = door('door_garden', 'wall_n', 1.6)
nodes.window_s1 = makeWindow('window_s1', 'wall_s', 1.2)
nodes.window_s2 = makeWindow('window_s2', 'wall_s', 1.2)
nodes.window_e = makeWindow('window_e', 'wall_e', 1.0)
nodes.window_w = makeWindow('window_w', 'wall_w', 1.0)
// House perimeter (south is front, north opens to the garden)
nodes.wall_n = wall('wall_n', [-HOUSE_W, -HOUSE_D], [HOUSE_W, -HOUSE_D], ['door_garden'])
nodes.wall_e = wall('wall_e', [HOUSE_W, -HOUSE_D], [HOUSE_W, HOUSE_D], ['window_e'])
nodes.wall_s = wall(
'wall_s',
[HOUSE_W, HOUSE_D],
[-HOUSE_W, HOUSE_D],
['door_front', 'window_s1', 'window_s2'],
)
nodes.wall_w = wall('wall_w', [-HOUSE_W, HOUSE_D], [-HOUSE_W, -HOUSE_D], ['window_w'])
// Zones
nodes.zone_living = {
object: 'node',
id: 'zone_living',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Living',
color: '#60a5fa',
polygon: [
[-HOUSE_W, -HOUSE_D],
[HOUSE_W, -HOUSE_D],
[HOUSE_W, HOUSE_D],
[-HOUSE_W, HOUSE_D],
],
} as unknown as AnyNode
nodes.zone_garden = {
object: 'node',
id: 'zone_garden',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Back garden',
color: '#86efac',
polygon: [
[-HOUSE_W, -HOUSE_D - GARDEN_DEPTH],
[HOUSE_W, -HOUSE_D - GARDEN_DEPTH],
[HOUSE_W, -HOUSE_D],
[-HOUSE_W, -HOUSE_D],
],
} as unknown as AnyNode
// Privacy fence along 3 sides of the garden.
nodes.fence_n = fence(
'fence_n',
[-HOUSE_W, -HOUSE_D - GARDEN_DEPTH],
[HOUSE_W, -HOUSE_D - GARDEN_DEPTH],
)
nodes.fence_e = fence('fence_e', [HOUSE_W, -HOUSE_D - GARDEN_DEPTH], [HOUSE_W, -HOUSE_D])
nodes.fence_w = fence('fence_w', [-HOUSE_W, -HOUSE_D], [-HOUSE_W, -HOUSE_D - GARDEN_DEPTH])
nodes.level_0 = {
object: 'node',
id: 'level_0',
type: 'level',
parentId: 'building_garden',
visible: true,
metadata: {},
level: 0,
children: [
'wall_n',
'wall_e',
'wall_s',
'wall_w',
'zone_living',
'zone_garden',
'fence_n',
'fence_e',
'fence_w',
],
} as unknown as AnyNode
// SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects
// (not string ids) per the schema — embed the full building node here.
;(nodes.site_garden as unknown as { children: unknown[] }).children = [nodes.building_garden!]
return {
nodes: nodes as Record<AnyNodeId, AnyNode>,
rootNodeIds: ['site_garden'] as AnyNodeId[],
}
}
export const template: SceneGraph = buildTemplate()
export const metadata = {
id: 'garden-house',
name: 'Garden house',
description:
'12 × 8 m single-level house with a fenced back-garden zone; 4 walls, 2 doors, 4 windows, 3 privacy fences.',
} as const
+41
View File
@@ -0,0 +1,41 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import * as emptyStudio from './empty-studio'
import * as gardenHouse from './garden-house'
import * as twoBedroom from './two-bedroom'
export type TemplateMetadata = {
id: string
name: string
description: string
}
export type TemplateEntry = {
/** Stable template id used by `create_from_template`. */
id: string
name: string
description: string
/** Static SceneGraph — ids are placeholders; regenerate via `cloneSceneGraph`. */
template: SceneGraph
}
function makeEntry(template: SceneGraph, metadata: TemplateMetadata): TemplateEntry {
return {
id: metadata.id,
name: metadata.name,
description: metadata.description,
template,
}
}
export const TEMPLATES = {
'empty-studio': makeEntry(emptyStudio.template, emptyStudio.metadata),
'two-bedroom': makeEntry(twoBedroom.template, twoBedroom.metadata),
'garden-house': makeEntry(gardenHouse.template, gardenHouse.metadata),
} as const
export type TemplateId = keyof typeof TEMPLATES
/** Type guard for external callers that receive arbitrary string ids. */
export function isTemplateId(id: string): id is TemplateId {
return Object.hasOwn(TEMPLATES, id)
}
@@ -0,0 +1,85 @@
import { describe, expect, test } from 'bun:test'
import { AnyNode } from '@pascal-app/core/schema'
import { TEMPLATES, type TemplateId } from './index'
describe('scene templates', () => {
const ids: TemplateId[] = Object.keys(TEMPLATES) as TemplateId[]
for (const id of ids) {
const entry = TEMPLATES[id]
test(`${id} has required metadata`, () => {
expect(entry.id).toBe(id)
expect(typeof entry.name).toBe('string')
expect(entry.name.length).toBeGreaterThan(0)
expect(typeof entry.description).toBe('string')
expect(entry.description.length).toBeGreaterThan(0)
})
test(`${id} template nodes all pass AnyNode.safeParse`, () => {
const { nodes, rootNodeIds } = entry.template
expect(rootNodeIds.length).toBeGreaterThan(0)
expect(Object.keys(nodes).length).toBeGreaterThan(0)
for (const [nodeId, node] of Object.entries(nodes)) {
const res = AnyNode.safeParse(node)
if (!res.success) {
// Surface the path/message of the first issue for debuggability.
const first = res.error.issues[0]
throw new Error(
`template ${id} node ${nodeId} failed AnyNode.safeParse at ${first?.path.join('.')}: ${first?.message}`,
)
}
expect(res.success).toBe(true)
}
})
test(`${id} root ids resolve and parent links point to existing nodes`, () => {
const { nodes, rootNodeIds } = entry.template
for (const rid of rootNodeIds) {
expect(nodes[rid]).toBeDefined()
}
for (const node of Object.values(nodes)) {
if (node.parentId && !(node.parentId in nodes)) {
throw new Error(
`template ${id} node ${node.id} has parentId ${node.parentId} which does not exist`,
)
}
}
})
}
test('empty-studio has 4 walls, 1 zone, 1 door, 1 window', () => {
const { nodes } = TEMPLATES['empty-studio'].template
const byType = groupByType(nodes)
expect(byType.wall ?? 0).toBe(4)
expect(byType.zone ?? 0).toBe(1)
expect(byType.door ?? 0).toBe(1)
expect(byType.window ?? 0).toBe(1)
})
test('two-bedroom has 9 walls, 4 zones, 4 doors, 5 windows', () => {
const { nodes } = TEMPLATES['two-bedroom'].template
const byType = groupByType(nodes)
expect(byType.wall ?? 0).toBe(9)
expect(byType.zone ?? 0).toBe(4)
expect(byType.door ?? 0).toBe(4)
expect(byType.window ?? 0).toBe(5)
})
test('garden-house has a fenced garden zone', () => {
const { nodes } = TEMPLATES['garden-house'].template
const byType = groupByType(nodes)
expect(byType.zone ?? 0).toBeGreaterThanOrEqual(2)
expect(byType.fence ?? 0).toBeGreaterThanOrEqual(3)
expect(byType.wall ?? 0).toBeGreaterThanOrEqual(4)
})
})
function groupByType(nodes: Record<string, { type: string }>): Record<string, number> {
const out: Record<string, number> = {}
for (const node of Object.values(nodes)) {
out[node.type] = (out[node.type] ?? 0) + 1
}
return out
}
+311
View File
@@ -0,0 +1,311 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
/**
* 80 m² two-bedroom apartment.
*
* Footprint: 10 m × 8 m = 80 m², centered near the origin.
* Contents: 9 walls (4 perimeter + 5 interior), 4 zones
* (living/kitchen, bedroom1, bedroom2, bath), 4 doors (front + 3 interior),
* 5 windows (2 on the living/kitchen, 1 per bedroom, 1 on the bath).
* Interior partitions split the north half into two bedrooms and a bath.
*
* Coordinate system: `[x, z]` on the XZ plane, with `x` running east/west
* and `z` running north/south (positive z points south).
*/
// Perimeter extents: 10 m × 8 m.
const X_MIN = -5
const X_MAX = 5
const Z_MIN = -4
const Z_MAX = 4
// Interior split lines.
const CORRIDOR_Z = 0 // horizontal wall separating north half (bedrooms+bath) from south (living)
const BED_X = -1 // vertical wall between bedroom 1 (west) and bath (east of it)
const BATH_X = 2 // vertical wall between bath (middle) and bedroom 2 (east)
const WALL_THICKNESS = 0.1
const WALL_HEIGHT = 2.5
type NodeMap = Record<string, AnyNode>
function wall(
id: string,
start: [number, number],
end: [number, number],
children: string[] = [],
): AnyNode {
return {
object: 'node',
id,
type: 'wall',
parentId: 'level_0',
visible: true,
metadata: {},
children,
thickness: WALL_THICKNESS,
height: WALL_HEIGHT,
start,
end,
frontSide: 'unknown',
backSide: 'unknown',
} as unknown as AnyNode
}
function door(id: string, parentWallId: string): AnyNode {
return {
object: 'node',
id,
type: 'door',
parentId: parentWallId,
visible: true,
metadata: {},
wallId: parentWallId,
position: [0, 1.05, 0],
rotation: [0, 0, 0],
width: 0.8,
height: 2.1,
frameThickness: 0.05,
frameDepth: 0.07,
threshold: true,
thresholdHeight: 0.02,
hingesSide: 'left',
swingDirection: 'inward',
segments: [
{
type: 'panel',
heightRatio: 0.5,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
{
type: 'panel',
heightRatio: 0.5,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
],
handle: true,
handleHeight: 1.05,
handleSide: 'right',
contentPadding: [0.04, 0.04],
doorCloser: false,
panicBar: false,
panicBarHeight: 1.0,
} as unknown as AnyNode
}
function makeWindow(id: string, parentWallId: string, width = 1.2): AnyNode {
return {
object: 'node',
id,
type: 'window',
parentId: parentWallId,
visible: true,
metadata: {},
wallId: parentWallId,
position: [0, 1.2, 0],
rotation: [0, 0, 0],
width,
height: 1.2,
frameThickness: 0.05,
frameDepth: 0.07,
columnRatios: [1],
rowRatios: [1],
columnDividerThickness: 0.03,
rowDividerThickness: 0.03,
sill: true,
sillDepth: 0.08,
sillThickness: 0.03,
} as unknown as AnyNode
}
function buildTemplate(): SceneGraph {
const nodes: NodeMap = {}
// Root nodes
nodes.site_2br = {
object: 'node',
id: 'site_2br',
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: {
type: 'polygon',
points: [
[-15, -15],
[15, -15],
[15, 15],
[-15, 15],
],
},
children: ['building_2br'],
} as unknown as AnyNode
nodes.building_2br = {
object: 'node',
id: 'building_2br',
type: 'building',
parentId: 'site_2br',
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
children: ['level_0'],
} as unknown as AnyNode
// Openings — declared up front so walls can list them as children.
nodes.door_front = door('door_front', 'wall_s')
nodes.door_bed1 = door('door_bed1', 'wall_corr_1')
nodes.door_bath = door('door_bath', 'wall_corr_2')
nodes.door_bed2 = door('door_bed2', 'wall_corr_3')
nodes.window_living_a = makeWindow('window_living_a', 'wall_s', 1.5)
nodes.window_living_b = makeWindow('window_living_b', 'wall_e', 1.2)
nodes.window_bed1 = makeWindow('window_bed1', 'wall_n', 1.2)
nodes.window_bath = makeWindow('window_bath', 'wall_n', 0.6)
nodes.window_bed2 = makeWindow('window_bed2', 'wall_n', 1.2)
// Perimeter walls (N, E, S, W) — 4 walls.
// Interior partitions — 5 walls (the east/west corridor wall is split into
// three segments by the two vertical partitions so doors have a clear host).
nodes.wall_n = wall(
'wall_n',
[X_MIN, Z_MIN],
[X_MAX, Z_MIN],
['window_bed1', 'window_bath', 'window_bed2'],
)
nodes.wall_e = wall('wall_e', [X_MAX, Z_MIN], [X_MAX, Z_MAX], ['window_living_b'])
nodes.wall_s = wall('wall_s', [X_MAX, Z_MAX], [X_MIN, Z_MAX], ['door_front', 'window_living_a'])
nodes.wall_w = wall('wall_w', [X_MIN, Z_MAX], [X_MIN, Z_MIN])
// Corridor wall is broken into 3 segments so each has its own interior door.
// Segment 1: from west to BED_X (bedroom-1 wall)
nodes.wall_corr_1 = wall('wall_corr_1', [X_MIN, CORRIDOR_Z], [BED_X, CORRIDOR_Z], ['door_bed1'])
// Segment 2: from BED_X to BATH_X (bath wall)
nodes.wall_corr_2 = wall('wall_corr_2', [BED_X, CORRIDOR_Z], [BATH_X, CORRIDOR_Z], ['door_bath'])
// Segment 3: from BATH_X to east (bedroom-2 wall)
nodes.wall_corr_3 = wall('wall_corr_3', [BATH_X, CORRIDOR_Z], [X_MAX, CORRIDOR_Z], ['door_bed2'])
// Two vertical partitions between the three north rooms.
nodes.wall_part_1 = wall('wall_part_1', [BED_X, Z_MIN], [BED_X, CORRIDOR_Z])
nodes.wall_part_2 = wall('wall_part_2', [BATH_X, Z_MIN], [BATH_X, CORRIDOR_Z])
// Zones: one per room.
nodes.zone_living = {
object: 'node',
id: 'zone_living',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Living / Kitchen',
color: '#60a5fa',
polygon: [
[X_MIN, CORRIDOR_Z],
[X_MAX, CORRIDOR_Z],
[X_MAX, Z_MAX],
[X_MIN, Z_MAX],
],
} as unknown as AnyNode
nodes.zone_bed1 = {
object: 'node',
id: 'zone_bed1',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Bedroom 1',
color: '#f472b6',
polygon: [
[X_MIN, Z_MIN],
[BED_X, Z_MIN],
[BED_X, CORRIDOR_Z],
[X_MIN, CORRIDOR_Z],
],
} as unknown as AnyNode
nodes.zone_bath = {
object: 'node',
id: 'zone_bath',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Bath',
color: '#a7f3d0',
polygon: [
[BED_X, Z_MIN],
[BATH_X, Z_MIN],
[BATH_X, CORRIDOR_Z],
[BED_X, CORRIDOR_Z],
],
} as unknown as AnyNode
nodes.zone_bed2 = {
object: 'node',
id: 'zone_bed2',
type: 'zone',
parentId: 'level_0',
visible: true,
metadata: {},
name: 'Bedroom 2',
color: '#fcd34d',
polygon: [
[BATH_X, Z_MIN],
[X_MAX, Z_MIN],
[X_MAX, CORRIDOR_Z],
[BATH_X, CORRIDOR_Z],
],
} as unknown as AnyNode
nodes.level_0 = {
object: 'node',
id: 'level_0',
type: 'level',
parentId: 'building_2br',
visible: true,
metadata: {},
level: 0,
children: [
'wall_n',
'wall_e',
'wall_s',
'wall_w',
'wall_corr_1',
'wall_corr_2',
'wall_corr_3',
'wall_part_1',
'wall_part_2',
'zone_living',
'zone_bed1',
'zone_bath',
'zone_bed2',
],
} as unknown as AnyNode
// SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects
// (not string ids) per the schema — embed the full building node here.
;(nodes.site_2br as unknown as { children: unknown[] }).children = [nodes.building_2br!]
return {
nodes: nodes as Record<AnyNodeId, AnyNode>,
rootNodeIds: ['site_2br'] as AnyNodeId[],
}
}
export const template: SceneGraph = buildTemplate()
export const metadata = {
id: 'two-bedroom',
name: 'Two-bedroom apartment',
description:
'80 m² two-bedroom flat: 9 walls, 4 zones (living/kitchen, 2 bedrooms, bath), 4 doors and 5 windows.',
} as const
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LevelNode, SlabNode, StairNode, StairSegmentNode, WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerApplyPatch } from './apply-patch'
describe('apply_patch', () => {
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' })
registerApplyPatch(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('applies a batch of create + update', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [
{ op: 'create', node: wall, parentId: level.id },
{ op: 'update', id: wall.id, data: { thickness: 0.2 } },
],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.appliedOps).toBe(2)
expect(parsed.createdIds).toContain(wall.id)
// Wait a tick for RAF-scheduled dirty-marking to settle.
await new Promise((r) => setTimeout(r, 10))
const stored = bridge.getNode(wall.id)
expect(stored).not.toBeNull()
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',
arguments: {
patches: [{ op: 'update', id: 'wall_none', data: { thickness: 0.1 } }],
},
})
expect(result.isError).toBe(true)
})
test('rejects malformed patch shape', async () => {
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [{ op: 'nope', garbage: true } as unknown as object],
},
})
expect(result.isError).toBe(true)
})
})
+71
View File
@@ -0,0 +1,71 @@
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 } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { PatchSchema } from './schemas'
export const applyPatchInput = {
patches: z.array(PatchSchema),
}
export const applyPatchOutput = {
appliedOps: z.number(),
deletedIds: z.array(z.string()),
createdIds: z.array(z.string()),
}
export function registerApplyPatch(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'apply_patch',
{
title: 'Apply patch',
description:
'Apply a batch of create/update/delete operations atomically. All patches are validated before any are applied; the entire batch forms a single undo step.',
inputSchema: applyPatchInput,
outputSchema: applyPatchOutput,
},
async ({ patches }) => {
const bridgePatches: BridgePatch[] = patches.map((p) => {
if (p.op === 'create') {
return {
op: 'create',
node: p.node as unknown as AnyNode,
...(p.parentId !== undefined ? { parentId: p.parentId as AnyNodeId } : {}),
}
}
if (p.op === 'update') {
return {
op: 'update',
id: p.id as AnyNodeId,
data: p.data as Partial<AnyNode>,
}
}
return {
op: 'delete',
id: p.id as AnyNodeId,
...(p.cascade !== undefined ? { cascade: p.cascade } : {}),
}
})
try {
const result = bridge.applyPatch(bridgePatches)
await publishLiveSceneSnapshot(bridge, 'apply_patch')
const payload = {
appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[],
createdIds: result.createdIds as unknown as string[],
}
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.InvalidParams, msg)
}
},
)
}
+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,88 @@
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 { ItemNode, WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerCheckCollisions } from './check-collisions'
function makeItem(position: [number, number, number], dims: [number, number, number] = [1, 1, 1]) {
return ItemNode.parse({
position,
asset: {
id: 'x',
name: 'x',
category: 'x',
thumbnail: '',
src: 'asset://x',
dimensions: dims,
},
})
}
describe('check_collisions', () => {
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' })
registerCheckCollisions(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('detects overlapping item AABBs', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [10, 0] })
bridge.createNode(wall, level.id)
const a = makeItem([0, 0, 0])
const b = makeItem([0.5, 0, 0.5])
;(a as { wallId?: string }).wallId = wall.id
;(b as { wallId?: string }).wallId = wall.id
bridge.createNode(a, wall.id)
bridge.createNode(b, wall.id)
const result = await client.callTool({
name: 'check_collisions',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.collisions.length).toBeGreaterThanOrEqual(1)
const ids = parsed.collisions.flatMap((c: { aId: string; bId: string }) => [c.aId, c.bId])
expect(ids).toContain(a.id)
expect(ids).toContain(b.id)
})
test('returns empty array when items do not overlap', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [10, 0] })
bridge.createNode(wall, level.id)
const a = makeItem([-10, 0, -10])
const b = makeItem([10, 0, 10])
;(a as { wallId?: string }).wallId = wall.id
;(b as { wallId?: string }).wallId = wall.id
bridge.createNode(a, wall.id)
bridge.createNode(b, wall.id)
const result = await client.callTool({
name: 'check_collisions',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.collisions.length).toBe(0)
})
test('scopes to levelId', async () => {
const result = await client.callTool({
name: 'check_collisions',
arguments: { levelId: 'level_missing' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(Array.isArray(parsed.collisions)).toBe(true)
})
})
@@ -0,0 +1,79 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema'
import { getScaledDimensions } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas'
export const checkCollisionsInput = {
levelId: NodeIdSchema.optional(),
}
export const checkCollisionsOutput = {
collisions: z.array(
z.object({
aId: z.string(),
bId: z.string(),
kind: z.string(),
}),
),
}
type AABB = { minX: number; maxX: number; minZ: number; maxZ: number }
function itemAabb(item: ItemNode): AABB {
const [x, , z] = item.position
const [w, , d] = getScaledDimensions(item)
const halfW = w / 2
const halfD = d / 2
return {
minX: x - halfW,
maxX: x + halfW,
minZ: z - halfD,
maxZ: z + halfD,
}
}
function aabbOverlap(a: AABB, b: AABB): boolean {
return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ
}
export function registerCheckCollisions(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'check_collisions',
{
title: 'Check collisions',
description:
'Detect overlapping item footprints via an axis-aligned 2D bounding-box test. Optionally scoped to a single level.',
inputSchema: checkCollisionsInput,
outputSchema: checkCollisionsOutput,
},
async ({ levelId }) => {
const filter: { type: 'item'; levelId?: AnyNodeId } = { type: 'item' }
if (levelId) filter.levelId = levelId as AnyNodeId
const items = bridge.findNodes(filter) as ItemNode[]
const boxes = items.map((i) => ({ item: i, aabb: itemAabb(i) }))
const collisions: { aId: string; bId: string; kind: string }[] = []
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i]!
const b = boxes[j]!
if (aabbOverlap(a.aabb, b.aabb)) {
collisions.push({
aId: a.item.id as string,
bId: b.item.id as string,
kind: 'item-aabb',
})
}
}
}
const payload = { collisions }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,268 @@
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 { registerConstructionTools } from './construction-tools'
import { registerSceneQueryTools } from './scene-query'
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('story construction tools reject dedicated roof support levels', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const roofLevel = LevelNode.parse({
name: 'Roof',
level: 1,
children: [],
metadata: { role: 'roof', referenceLevelId: level.id },
})
bridge.createNode(roofLevel, building.id)
const shell = await client.callTool({
name: 'create_story_shell',
arguments: {
levelId: roofLevel.id,
footprint: [
[-4, -3],
[4, -3],
[4, 3],
[-4, 3],
],
},
})
expect(shell.isError).toBe(true)
const stair = await client.callTool({
name: 'create_stair_between_levels',
arguments: {
fromLevelId: level.id,
toLevelId: roofLevel.id,
position: [0, 0, 0],
runLength: 3,
totalRise: 2.8,
},
})
expect(stair.isError).toBe(true)
})
test('create_roof requires an explicit roof support level when roofLevelId is provided', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const occupiedUpper = LevelNode.parse({
name: 'Second Floor',
level: 1,
children: [],
})
bridge.createNode(occupiedUpper, building.id)
const result = await client.callTool({
name: 'create_roof',
arguments: {
levelId: level.id,
roofLevelId: occupiedUpper.id,
width: 8,
depth: 6,
},
})
expect(result.isError).toBe(true)
})
test('verify_scene flags roofs mixed into occupied levels', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
await client.callTool({
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,510 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import {
CeilingNode,
LevelNode,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const
const RAILING_MODES = ['none', 'left', 'right', 'both'] as const
export const createStoryShellInput = {
levelId: NodeIdSchema,
footprint: z.array(Vec2Schema).min(3),
wallHeight: z.number().positive().default(2.8),
wallThickness: z.number().positive().default(0.16),
createSlab: z.boolean().default(true),
createCeiling: z.boolean().default(true),
slabElevation: z.number().default(0.1),
ceilingHeight: z.number().positive().optional(),
namePrefix: z.string().optional(),
wallMaterialPreset: z.string().optional(),
slabMaterialPreset: z.string().optional(),
ceilingMaterialPreset: z.string().optional(),
}
export const createStoryShellOutput = {
levelId: z.string(),
wallIds: z.array(z.string()),
slabId: z.string().nullable(),
ceilingId: z.string().nullable(),
createdIds: z.array(z.string()),
}
export const createRoofInput = {
levelId: NodeIdSchema,
roofLevelId: NodeIdSchema.optional(),
useDedicatedRoofLevel: z.boolean().default(true),
roofLevelLabel: z.string().default('Roof'),
roofLevelElevation: z.number().optional(),
roofLevelHeight: z.number().positive().optional(),
center: Vec3Schema.optional(),
width: z.number().positive(),
depth: z.number().positive(),
roofType: z.enum(ROOF_TYPES).default('hip'),
roofHeight: z.number().positive().default(1.8),
wallHeight: z.number().min(0).default(0.35),
wallThickness: z.number().positive().default(0.16),
overhang: z.number().min(0).default(0.45),
materialPreset: z.string().optional(),
name: z.string().optional(),
}
export const createRoofOutput = {
referenceLevelId: z.string(),
roofLevelId: z.string(),
createdRoofLevelId: z.string().nullable(),
roofId: z.string(),
roofSegmentId: z.string(),
}
export const createStairBetweenLevelsInput = {
fromLevelId: NodeIdSchema,
toLevelId: NodeIdSchema,
position: Vec3Schema,
rotation: z.number().default(0),
width: z.number().positive().default(1),
runLength: z.number().positive().default(3),
totalRise: z.number().positive().default(2.8),
stepCount: z.number().int().positive().default(14),
railingMode: z.enum(RAILING_MODES).default('both'),
destinationSlabId: NodeIdSchema.optional(),
sourceCeilingId: NodeIdSchema.optional(),
createDestinationSlabOpening: z.boolean().default(true),
createSourceCeilingOpening: z.boolean().default(true),
openingWidth: z.number().positive().optional(),
openingLength: z.number().positive().optional(),
openingOffset: z.number().min(0).default(0.15),
openingCenter: Vec2Schema.optional(),
openingRotation: z.number().optional(),
materialPreset: z.string().optional(),
name: z.string().optional(),
}
export const createStairBetweenLevelsOutput = {
stairId: z.string(),
stairSegmentId: z.string(),
destinationSlabId: z.string().nullable(),
sourceCeilingId: z.string().nullable(),
openingPolygon: z.array(Vec2Schema),
}
function textResult<T extends Record<string, unknown>>(payload: T) {
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
function assertNode(bridge: SceneOperations, id: string, type: AnyNode['type']): AnyNode {
const node = bridge.getNode(id as AnyNodeId)
if (!node) throw new Error(`${type} not found: ${id}`)
if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`)
return node
}
function getBuildingIdForLevel(bridge: SceneOperations, levelId: string): AnyNodeId {
const building = bridge.getAncestry(levelId as AnyNodeId).find((node) => node.type === 'building')
if (!building) {
throw new Error(`Building ancestor not found for level: ${levelId}`)
}
return building.id as AnyNodeId
}
function isRoofLevel(level: AnyNode): boolean {
return (
level.type === 'level' &&
typeof level.metadata === 'object' &&
level.metadata !== null &&
'role' in level.metadata &&
level.metadata.role === 'roof'
)
}
function nextLevelIndex(
bridge: SceneOperations,
buildingId: AnyNodeId,
referenceLevel: AnyNode,
): number {
const existing = bridge
.getChildren(buildingId)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level')
.map((level) => level.level)
const referenceIndex = referenceLevel.type === 'level' ? referenceLevel.level : 0
const candidate = referenceIndex + 1
return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate
}
function nodesOnLevel(bridge: SceneOperations, levelId: string): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
}
function firstNodeOnLevel(
bridge: SceneOperations,
levelId: string,
type: 'slab' | 'ceiling',
): AnyNode | null {
return nodesOnLevel(bridge, levelId).find((node) => node.type === type) ?? null
}
function rotatePoint(x: number, z: number, rotation: number): [number, number] {
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
return [x * cos + z * sin, -x * sin + z * cos]
}
function rectangularOpening(args: {
position: [number, number, number]
rotation: number
width: number
length: number
offset: number
center?: [number, number] | undefined
openingRotation?: number | undefined
}): [number, number][] {
const width = args.width + args.offset * 2
const length = args.length + args.offset * 2
const center: [number, number] = args.center ?? [
args.position[0],
args.position[2] + args.length / 2,
]
const rotation = args.openingRotation ?? args.rotation
const halfW = width / 2
const halfL = length / 2
const local: [number, number][] = [
[-halfW, -halfL],
[halfW, -halfL],
[halfW, halfL],
[-halfW, halfL],
]
return local.map(([x, z]) => {
const [rx, rz] = rotatePoint(x, z, rotation)
return [center[0] + rx, center[1] + rz]
})
}
function withHole(
surface: AnyNode & { type: 'slab' | 'ceiling' },
hole: [number, number][],
): Partial<AnyNode> {
return {
holes: [...(surface.holes ?? []), hole],
holeMetadata: [...(surface.holeMetadata ?? []), { source: 'manual' }],
} as Partial<AnyNode>
}
export function registerConstructionTools(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_story_shell',
{
title: 'Create story shell',
description:
'Create one level-owned building shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story; do not make first-floor walls span multiple stories.',
inputSchema: createStoryShellInput,
outputSchema: createStoryShellOutput,
},
async ({
levelId,
footprint,
wallHeight,
wallThickness,
createSlab,
createCeiling,
slabElevation,
ceilingHeight,
namePrefix,
wallMaterialPreset,
slabMaterialPreset,
ceilingMaterialPreset,
}) => {
const level = assertNode(bridge, levelId, 'level')
if (isRoofLevel(level)) {
throw new Error(
`Cannot create a story shell on roof support level ${levelId}; create or choose an occupied story level instead`,
)
}
const points = footprint as [number, number][]
const wallIds: string[] = []
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
for (let i = 0; i < points.length; i++) {
const wall = WallNode.parse({
name: namePrefix ? `${namePrefix} Wall ${i + 1}` : undefined,
start: points[i],
end: points[(i + 1) % points.length],
thickness: wallThickness,
height: wallHeight,
frontSide: 'exterior',
backSide: 'interior',
...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}),
metadata: { role: 'exterior', storyShell: true },
})
wallIds.push(wall.id)
patches.push({ op: 'create', node: wall, parentId: levelId as AnyNodeId })
}
let slabId: string | null = null
if (createSlab) {
const slab = SlabNode.parse({
name: namePrefix ? `${namePrefix} Slab` : undefined,
polygon: points,
elevation: slabElevation,
...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}),
metadata: { role: 'story-slab' },
})
slabId = slab.id
patches.push({ op: 'create', node: slab, parentId: levelId as AnyNodeId })
}
let ceilingId: string | null = null
if (createCeiling) {
const ceiling = CeilingNode.parse({
name: namePrefix ? `${namePrefix} Ceiling` : undefined,
polygon: points,
height: ceilingHeight ?? wallHeight,
...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}),
metadata: { role: 'story-ceiling' },
})
ceilingId = ceiling.id
patches.push({ op: 'create', node: ceiling, parentId: levelId as AnyNodeId })
}
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, 'create_story_shell')
return textResult({
levelId,
wallIds,
slabId,
ceilingId,
createdIds: result.createdIds as string[],
})
},
)
server.registerTool(
'create_roof',
{
title: 'Create roof',
description:
'Create a roof container with one roof segment. By default creates a dedicated roof level above the reference level so exploded/solo level views can isolate the roof.',
inputSchema: createRoofInput,
outputSchema: createRoofOutput,
},
async ({
levelId,
roofLevelId,
useDedicatedRoofLevel,
roofLevelLabel,
roofLevelElevation,
roofLevelHeight,
center,
width,
depth,
roofType,
roofHeight,
wallHeight,
wallThickness,
overhang,
materialPreset,
name,
}) => {
const referenceLevel = assertNode(bridge, levelId, 'level')
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
let targetRoofLevelId = levelId as AnyNodeId
let createdRoofLevelId: string | null = null
if (roofLevelId !== undefined) {
const roofLevel = assertNode(bridge, roofLevelId, 'level')
if (!isRoofLevel(roofLevel)) {
throw new Error(
`roofLevelId ${roofLevelId} must reference a dedicated roof level with metadata.role = "roof"; omit roofLevelId to create one automatically`,
)
}
targetRoofLevelId = roofLevelId as AnyNodeId
} else if (useDedicatedRoofLevel && !isRoofLevel(referenceLevel)) {
const buildingId = getBuildingIdForLevel(bridge, levelId)
const roofLevel = LevelNode.parse({
name: roofLevelLabel,
level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel),
children: [],
metadata: {
role: 'roof',
label: roofLevelLabel,
referenceLevelId: levelId,
height: roofLevelHeight ?? Math.max(wallHeight + roofHeight, 0.2),
},
})
targetRoofLevelId = roofLevel.id as AnyNodeId
createdRoofLevelId = roofLevel.id
patches.push({ op: 'create', node: roofLevel, parentId: buildingId })
}
const segment = RoofSegmentNode.parse({
roofType,
width,
depth,
wallHeight,
roofHeight,
wallThickness,
overhang,
...(materialPreset ? { materialPreset } : {}),
})
const roof = RoofNode.parse({
name: name ?? 'Roof',
position: (center as [number, number, number] | undefined) ?? [0, 0, 0],
children: [segment.id],
...(materialPreset ? { materialPreset } : {}),
metadata: {
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
},
})
bridge.applyPatch([
...patches,
{ op: 'create', node: roof, parentId: targetRoofLevelId },
{ op: 'create', node: segment, parentId: roof.id as AnyNodeId },
])
await publishLiveSceneSnapshot(bridge, 'create_roof')
return textResult({
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
createdRoofLevelId,
roofId: roof.id,
roofSegmentId: segment.id,
})
},
)
server.registerTool(
'create_stair_between_levels',
{
title: 'Create stair between levels',
description:
'Create a straight stair and a single rectangular manual opening in the destination slab/source ceiling. This disables stair auto-opening mode to avoid duplicate or irregular holes.',
inputSchema: createStairBetweenLevelsInput,
outputSchema: createStairBetweenLevelsOutput,
},
async ({
fromLevelId,
toLevelId,
position,
rotation,
width,
runLength,
totalRise,
stepCount,
railingMode,
destinationSlabId,
sourceCeilingId,
createDestinationSlabOpening,
createSourceCeilingOpening,
openingWidth,
openingLength,
openingOffset,
openingCenter,
openingRotation,
materialPreset,
name,
}) => {
const fromLevel = assertNode(bridge, fromLevelId, 'level')
const toLevel = assertNode(bridge, toLevelId, 'level')
if (isRoofLevel(fromLevel) || isRoofLevel(toLevel)) {
throw new Error(
'Roof support levels are not occupied stories; create a separate occupied attic/story level if a stair-accessible attic is required',
)
}
const segment = StairSegmentNode.parse({
segmentType: 'stair',
width,
length: runLength,
height: totalRise,
stepCount,
...(materialPreset ? { materialPreset } : {}),
})
const stair = StairNode.parse({
name: name ?? 'Stair',
position: position as [number, number, number],
rotation,
stairType: 'straight',
fromLevelId,
toLevelId,
slabOpeningMode: 'none',
openingOffset,
width,
totalRise,
stepCount,
railingMode,
children: [segment.id],
...(materialPreset ? { materialPreset } : {}),
metadata: {
openingManaged: 'manual-rectangular',
},
})
const openingPolygon = rectangularOpening({
position: position as [number, number, number],
rotation,
width: openingWidth ?? width,
length: openingLength ?? runLength,
offset: openingOffset,
center: openingCenter as [number, number] | undefined,
openingRotation,
})
const patches: Array<
| { op: 'create'; node: AnyNode; parentId: AnyNodeId }
| { op: 'update'; id: AnyNodeId; data: Partial<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, 'create_stair_between_levels')
return textResult({
stairId: stair.id,
stairSegmentId: segment.id,
destinationSlabId: destinationSlab?.id ?? null,
sourceCeilingId: sourceCeiling?.id ?? null,
openingPolygon,
})
},
)
}
@@ -0,0 +1,54 @@
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 { registerCreateLevel } from './create-level'
describe('create_level', () => {
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' })
registerCreateLevel(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('creates a level on a building', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: building.id, elevation: 3, label: 'Second' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.levelId).toMatch(/^level_/)
const created = bridge.getNode(parsed.levelId)
expect(created).not.toBeNull()
expect(created!.type).toBe('level')
expect((created as { level: number }).level).toBe(3)
})
test('rejects unknown building id', async () => {
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: 'building_nope' },
})
expect(result.isError).toBe(true)
})
test('rejects non-building parent', async () => {
const wallLike = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: wallLike.id },
})
expect(result.isError).toBe(true)
})
})
+63
View File
@@ -0,0 +1,63 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { LevelNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const createLevelInput = {
buildingId: NodeIdSchema,
elevation: z.number().optional(),
height: z.number().optional(),
label: z.string().optional(),
}
export const createLevelOutput = {
levelId: z.string(),
}
export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_level',
{
title: 'Create level',
description:
'Create a new level node attached to the given building. height and label are stored in metadata.',
inputSchema: createLevelInput,
outputSchema: createLevelOutput,
},
async ({ buildingId, elevation, height, label }) => {
const parent = bridge.getNode(buildingId as AnyNodeId)
if (!parent) {
throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`)
}
if (parent.type !== 'building') {
throwMcpError(
ErrorCode.InvalidParams,
`Node ${buildingId} is a ${parent.type}, expected building`,
)
}
const metadata: Record<string, unknown> = {}
if (height !== undefined) metadata.height = height
if (label !== undefined) metadata.label = label
const levelNode = LevelNode.parse({
level: elevation ?? 0,
children: [],
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
...(label !== undefined ? { name: label } : {}),
})
const id = bridge.createNode(levelNode, buildingId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'create_level')
const payload = { levelId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+149
View File
@@ -0,0 +1,149 @@
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 { createSceneOperations } from '../operations'
import type { SceneMeta, SceneStore } from '../storage/types'
import { registerCreateWall } from './create-wall'
describe('create_wall', () => {
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' })
registerCreateWall(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('creates a wall with custom thickness', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_wall',
arguments: {
levelId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.15,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.wallId).toMatch(/^wall_/)
const created = bridge.getNode(parsed.wallId)
expect(created).not.toBeNull()
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' })
const operations = createSceneOperations({ bridge, store })
operations.setActiveScene(savedMeta)
registerCreateWall(liveServer, operations)
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',
arguments: {
levelId: 'level_nope',
start: [0, 0],
end: [1, 0],
},
})
expect(result.isError).toBe(true)
})
test('rejects invalid start tuple', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_wall',
arguments: {
levelId: level.id,
start: [0],
end: [1, 0],
},
})
expect(result.isError).toBe(true)
})
})
+70
View File
@@ -0,0 +1,70 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { WallNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
export const createWallInput = {
levelId: NodeIdSchema,
start: Vec2Schema,
end: Vec2Schema,
thickness: z.number().positive().optional(),
height: z.number().positive().optional(),
}
export const createWallOutput = {
wallId: z.string(),
}
export function registerCreateWall(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_wall',
{
title: 'Create wall',
description:
'Create a new wall on the given level between two 2D points. Thickness and height default to the core library defaults when omitted.',
inputSchema: createWallInput,
outputSchema: createWallOutput,
},
async ({ levelId, start, end, thickness, height }) => {
const parent = bridge.getNode(levelId as AnyNodeId)
if (!parent) {
throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
}
if (parent.type !== 'level') {
throwMcpError(
ErrorCode.InvalidParams,
`Node ${levelId} is a ${parent.type}, expected level`,
)
}
if (
typeof parent.metadata === 'object' &&
parent.metadata !== null &&
'role' in parent.metadata &&
parent.metadata.role === 'roof'
) {
throwMcpError(
ErrorCode.InvalidParams,
`Roof support level ${levelId} is not an occupied story; create walls on an occupied level instead`,
)
}
const wall = WallNode.parse({
start: start as [number, number],
end: end as [number, number],
...(thickness !== undefined ? { thickness } : {}),
...(height !== undefined ? { height } : {}),
})
const id = bridge.createNode(wall, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'create_wall')
const payload = { wallId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+101
View File
@@ -0,0 +1,101 @@
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 { SceneBridge } from '../bridge/scene-bridge'
import { registerCutOpening } from './cut-opening'
describe('cut_opening', () => {
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' })
registerCutOpening(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('creates a door opening on a wall', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'door',
position: 0.5,
width: 0.9,
height: 2.1,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.openingId).toMatch(/^door_/)
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 () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'window',
position: 0.25,
width: 1.2,
height: 1.2,
},
})
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 () => {
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: 'wall_nope',
type: 'door',
position: 0.5,
width: 1,
height: 2,
},
})
expect(result.isError).toBe(true)
})
test('rejects out-of-range position', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'door',
position: 1.5,
width: 1,
height: 2,
},
})
expect(result.isError).toBe(true)
})
})
+80
View File
@@ -0,0 +1,80 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { DoorNode, WindowNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { wallLength, wallLocalXFromT } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
export const cutOpeningInput = {
wallId: NodeIdSchema,
type: z.enum(['door', 'window']),
position: z.number().min(0).max(1),
width: z.number().positive(),
height: z.number().positive(),
}
export const cutOpeningOutput = {
openingId: z.string(),
}
export function registerCutOpening(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'cut_opening',
{
title: 'Cut opening',
description:
'Cut a door or window opening into an existing wall. position is a parametric 0..1 offset along the wall centreline.',
inputSchema: cutOpeningInput,
outputSchema: cutOpeningOutput,
},
async ({ wallId, type, position, width, height }) => {
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`)
}
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: [wallLocalXFromT(wall, position, width), height / 2, 0] as [
number,
number,
number,
],
}
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, 'cut_opening')
const payload = { openingId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,67 @@
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 { SceneBridge } from '../bridge/scene-bridge'
import { registerDeleteNode } from './delete-node'
describe('delete_node', () => {
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' })
registerDeleteNode(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('deletes a leaf node', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [2, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'delete_node',
arguments: { id: wall.id },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.deletedIds).toContain(wall.id)
expect(bridge.getNode(wall.id)).toBeNull()
})
test('refuses to delete a node with children without cascade', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'delete_node',
arguments: { id: building.id },
})
expect(result.isError).toBe(true)
})
test('cascades when cascade=true', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'delete_node',
arguments: { id: building.id, cascade: true },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.deletedIds.length).toBeGreaterThanOrEqual(1)
expect(bridge.getNode(building.id)).toBeNull()
})
test('errors on unknown id', async () => {
const result = await client.callTool({
name: 'delete_node',
arguments: { id: 'wall_nope' },
})
expect(result.isError).toBe(true)
})
})

Some files were not shown because too many files have changed in this diff Show More