Merge branch 'main' into feat/upgrade-and-bug-fix

This commit is contained in:
Sudhir Yadav
2026-04-28 14:37:10 +05:30
committed by GitHub
171 changed files with 20690 additions and 41 deletions
@@ -1,5 +1,4 @@
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
@@ -340,12 +339,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 +371,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)
@@ -20,6 +20,7 @@ import {
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 {
@@ -940,9 +941,7 @@ export default function Editor({
commandPaletteEmptyAction,
}: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
@@ -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
}