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.
219 lines
7.2 KiB
TypeScript
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()),
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
}
|