feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7)

Ships the combined filesystem/Supabase storage adapter + MCP scene
lifecycle tools + Next.js API routes + editor /scene/[id] route, so
an MCP save is directly openable at /scene/<id> without any
injection hack. End-to-end verified: 10/10 e2e steps pass.

Storage (A1/A2/A3):
- SceneStore interface + error classes + slug helpers
- FilesystemSceneStore at $PASCAL_DATA_DIR (defaults XDG/~/.pascal)
  with atomic writes, .index sidecar, optimistic locking
- SupabaseSceneStore with scenes + scene_revisions tables, RLS
  migration SQL, mock-backed unit tests
- createSceneStore(env) auto-selects based on SUPABASE_URL +
  SUPABASE_SERVICE_ROLE_KEY

MCP tools (A4, A8, A9, A10):
- save_scene / load_scene / list_scenes / delete_scene / rename_scene
- list_templates / create_from_template (3 seed templates:
  empty-studio, two-bedroom, garden-house)
- generate_variants (7 mutation kinds, seeded RNG, save=true|false)
- photo_to_scene (vision sampling → scene graph → save)

Editor (A5, A6):
- /api/scenes + /api/scenes/[id] with RFC 7232 If-Match locking
- /scene/[id] and /scenes route pages with save button, SceneLoader
- Removed the window.__pascalScene dev injection hack

Security + UX edges (A7, A8):
- AssetUrl Zod validator: asset:// blob: data:image/ /path https:
  (http://localhost for dev) + PASCAL_ALLOWED_ASSET_ORIGINS env
  allowlist. Hardens scan.url, guide.url, item.asset.src,
  material.texture.url, MaterialMaps.*Map
- Auto-frame camera on empty→non-empty scene transition
  (camera-controls:fit-scene emitter event)

Shared utilities:
- rehydrateSiteChildren() extracted to packages/mcp/src/lib/ and
  used by both create-from-template and generate-variants to work
  around the SiteNode.children-as-objects vs. ids inconsistency
  (CROSS_CUTTING §2)
- Storage + MCP subpath exports added to packages/mcp/package.json
  (CROSS_CUTTING §4)

Tests: 293 pass / 0 fail across 40 files (was 142 pre-Phase-7).
Biome: clean.

Phase-7 e2e script at packages/mcp/test-reports/phase7-e2e.ts:
MCP HTTP + editor Next.js both point at $PASCAL_DATA_DIR =
/tmp/pascal-e2e, save_scene from MCP, GET /api/scenes/<id> from
editor server, /scenes list page renders all saved scenes, scene
page renders SceneLoader, delete_scene works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adrian Perez
2026-04-18 19:29:28 +02:00
co-authored by Claude Opus 4.7
parent 42bd05db9c
commit e8d0b13ff5
81 changed files with 8933 additions and 1213 deletions
@@ -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)
@@ -12,6 +12,7 @@ import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from '
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 {
@@ -22,8 +23,8 @@ import {
} from '../../lib/scene'
import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { StairEditSystem } from '../systems/stair/stair-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -732,6 +733,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
}