Files
editor/packages/mcp/src/resources/scene-summary.ts
T
Wawa e618175bba Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

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

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

Open PRs against editor-v2 will need rebasing/conflict resolution.
2026-05-09 20:52:26 +00:00

219 lines
7.2 KiB
TypeScript

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()),
},
],
}),
)
}