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>
175 lines
4.9 KiB
TypeScript
175 lines
4.9 KiB
TypeScript
import type { ThreeEvent } from '@react-three/fiber'
|
|
import mitt from 'mitt'
|
|
import type {
|
|
BuildingNode,
|
|
CeilingNode,
|
|
DoorNode,
|
|
FenceNode,
|
|
ItemNode,
|
|
LevelNode,
|
|
RoofNode,
|
|
RoofSegmentNode,
|
|
SiteNode,
|
|
SlabNode,
|
|
StairNode,
|
|
StairSegmentNode,
|
|
WallNode,
|
|
WindowNode,
|
|
ZoneNode,
|
|
} from '../schema'
|
|
import type { AnyNode } from '../schema/types'
|
|
|
|
// Base event interfaces
|
|
export interface GridEvent {
|
|
/** World-space intersection point on the grid plane. */
|
|
position: [number, number, number]
|
|
/**
|
|
* Building-local intersection point, relative to the currently selected building.
|
|
* Equals `position` when no building is selected.
|
|
* Use this for placing or committing anything that lives inside a building
|
|
* (walls, slabs, items, etc.).
|
|
*/
|
|
localPosition: [number, number, number]
|
|
nativeEvent: ThreeEvent<PointerEvent>
|
|
}
|
|
|
|
export interface NodeEvent<T extends AnyNode = AnyNode> {
|
|
node: T
|
|
position: [number, number, number]
|
|
localPosition: [number, number, number]
|
|
normal?: [number, number, number]
|
|
stopPropagation: () => void
|
|
nativeEvent: ThreeEvent<PointerEvent>
|
|
}
|
|
|
|
export type WallEvent = NodeEvent<WallNode>
|
|
export type FenceEvent = NodeEvent<FenceNode>
|
|
export type ItemEvent = NodeEvent<ItemNode>
|
|
export type SiteEvent = NodeEvent<SiteNode>
|
|
export type BuildingEvent = NodeEvent<BuildingNode>
|
|
export type LevelEvent = NodeEvent<LevelNode>
|
|
export type ZoneEvent = NodeEvent<ZoneNode>
|
|
export type SlabEvent = NodeEvent<SlabNode>
|
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
|
export type RoofEvent = NodeEvent<RoofNode>
|
|
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
|
export type StairEvent = NodeEvent<StairNode>
|
|
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
|
export type WindowEvent = NodeEvent<WindowNode>
|
|
export type DoorEvent = NodeEvent<DoorNode>
|
|
|
|
// Event suffixes, exported for use in hooks
|
|
export const eventSuffixes = [
|
|
'click',
|
|
'move',
|
|
'enter',
|
|
'leave',
|
|
'pointerdown',
|
|
'pointerup',
|
|
'context-menu',
|
|
'double-click',
|
|
] as const
|
|
|
|
export type EventSuffix = (typeof eventSuffixes)[number]
|
|
|
|
type NodeEvents<T extends string, E> = {
|
|
[K in `${T}:${EventSuffix}`]: E
|
|
}
|
|
|
|
type GridEvents = {
|
|
[K in `grid:${EventSuffix}`]: GridEvent
|
|
}
|
|
|
|
export interface CameraControlEvent {
|
|
nodeId: AnyNode['id']
|
|
}
|
|
|
|
export interface ThumbnailGenerateEvent {
|
|
projectId: string
|
|
captureMode?: 'standard' | 'viewport' | 'area'
|
|
cropRegion?: { x: number; y: number; width: number; height: number }
|
|
/**
|
|
* When true, snap levels to their true positions before capturing (for a
|
|
* consistent auto-thumbnail angle) and defer the capture if the tab is
|
|
* hidden, the background auto-save path. Omit for user-driven captures
|
|
* that should fire immediately from the current camera pose.
|
|
*/
|
|
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
|
|
'camera-controls:capture': CameraControlEvent
|
|
'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
|
|
}
|
|
|
|
type ToolEvents = {
|
|
'tool:cancel': undefined
|
|
}
|
|
|
|
type PresetEvents = {
|
|
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
|
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
|
}
|
|
|
|
type ThumbnailEvents = {
|
|
'thumbnail:before-capture': undefined
|
|
'thumbnail:after-capture': undefined
|
|
}
|
|
|
|
type SnapshotEvents = {
|
|
'snapshot:saved': undefined
|
|
'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] }
|
|
}
|
|
|
|
type AIChatEvents = {
|
|
'ai-chat:attach-images': {
|
|
images: { url: string; name: string; kind: 'snapshot' | 'render' }[]
|
|
}
|
|
}
|
|
|
|
type EditorEvents = GridEvents &
|
|
NodeEvents<'wall', WallEvent> &
|
|
NodeEvents<'fence', FenceEvent> &
|
|
NodeEvents<'item', ItemEvent> &
|
|
NodeEvents<'site', SiteEvent> &
|
|
NodeEvents<'building', BuildingEvent> &
|
|
NodeEvents<'level', LevelEvent> &
|
|
NodeEvents<'zone', ZoneEvent> &
|
|
NodeEvents<'slab', SlabEvent> &
|
|
NodeEvents<'ceiling', CeilingEvent> &
|
|
NodeEvents<'roof', RoofEvent> &
|
|
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
|
NodeEvents<'stair', StairEvent> &
|
|
NodeEvents<'stair-segment', StairSegmentEvent> &
|
|
NodeEvents<'window', WindowEvent> &
|
|
NodeEvents<'door', DoorEvent> &
|
|
CameraControlEvents &
|
|
ToolEvents &
|
|
PresetEvents &
|
|
ThumbnailEvents &
|
|
SnapshotEvents &
|
|
AIChatEvents
|
|
|
|
export const emitter = mitt<EditorEvents>()
|