Merge branch 'main' into feat/upgrade-and-bug-fix
This commit is contained in:
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user