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>
184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
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])
|
||
})
|
||
})
|